esbuild bundler
Что это такое?
ESBuild - это сборщик пакетов
Преимущества:
Он простой
Он быстрый
Минималистичный
Достаточно новый и на современных технологиях
Из недостатков можно выделить то, что у него меньше решений различных вопросов (нет настройки css modules) и чуть меньше коммюнити
Как стартовать?
Стандартно, команда для сборки приложения выглядит следующим образом:
что собираем
какой тип сборки
папка с результатом сборки
esbuild ./src/index.js --bundle --outdir= "dist"
Конфигурация
ESBuild смотрит так же на нашу конфигурацию TS
tsconfig.json
{
"compilerOptions": {
// куда собираем
"outDir": "./build/",
// запрет на any
"noImplicitAny": true,
"module": "ESNext",
// в какой формат копилируем, в данном случае ecmascript 6
"target": "es6",
"jsx": "react-jsx",
// Компилятор будет обрабатывать не только TS файлы, но и JS файлы
"allowJs": true,
// Строгий режим
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
// Обязательное поле при использовании с esbuild
"isolatedModules": true
},
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
}
}
Чтобы собрать свой плагин для ESBuild, нам нужно создать инстанс Plugin, который будет иметь имя и поля со стейджами выполнения. Сами стейджи мы получаем из передаваемого параметра build
(onStart
, onEnd
и тд)
Тут мы реализовали очистку dist перед рекомпиляцией приложения в эту папку.
config / build / plugins / CleanPlugin.ts
import {Plugin} from 'esbuild';
import {rm} from 'fs/promises';
export const CleanPlugin: Plugin = {
name: 'CleanPlugin',
setup(build) {
build.onStart(async () => {
try {
const outdir = build.initialOptions.outdir;
if(outdir) {
// АККУРАТНО!!!!
await rm(outdir, { recursive: true })
}
} catch (e) {
console.log('Не удалось очистить папку')
}
})
},
}
Далее нам нужно будет дефолтно вставить HTML-страницу для сборки приложения. Если в Webpack у нас есть плагин, который вставит наш код в нужный index.html
, то в ESBuild нужно будет поработать и самому из JS сгенерировать нужный шаблон
config / build / plugins / HTMLPlugin.ts
import {Plugin} from 'esbuild';
import {rm, writeFile} from 'fs/promises';
import path from 'path';
interface HTMLPluginOptions {
template?: string;
title?: string;
jsPath?: string[];
cssPath?: string[];
}
const renderHtml = (options: HTMLPluginOptions): string => {
return options.template || `
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${options.title}</title>
${options?.cssPath?.map(path => `<link href=${path} rel="stylesheet">`).join(" ")}
</head>
<body>
<div id="root"></div>
${options?.jsPath?.map(path => `<script src=${path}></script>`).join(" ")}
<script>
const evtSource = new EventSource('http://localhost:3000/subscribe')
evtSource.onopen = function () { console.log('open') }
evtSource.onerror = function () { console.log('error') }
evtSource.onmessage = function () {
console.log('message')
window.location.reload();
}
</script>
</body>
</html>
`
}
const preparePaths = (outputs: string[]) => {
return outputs.reduce<Array<string[]>>((acc, path) => {
const [js, css] = acc;
const splittedFileName = path.split('/').pop();
if(splittedFileName?.endsWith('.js')) {
js.push(splittedFileName)
} else if(splittedFileName?.endsWith('.css')) {
css.push(splittedFileName)
}
return acc;
}, [[], []])
}
export const HTMLPlugin = (options: HTMLPluginOptions): Plugin => {
return {
name: 'HTMLPlugin',
setup(build) {
const outdir = build.initialOptions.outdir;
build.onStart(async () => {
try {
if(outdir) {
await rm(outdir, { recursive: true })
}
} catch (e) {
console.log('Не удалось очистить папку')
}
})
build.onEnd(async (result) => {
const outputs = result.metafile?.outputs;
const [jsPath, cssPath] = preparePaths(Object.keys(outputs || {}));
if(outdir) {
await writeFile(
path.resolve(outdir, 'index.html'),
renderHtml({ jsPath, cssPath, ...options})
)
}
})
},
}
}
Так у нас будет выглядеть конфигурация ESBuild, в которой нам нужно будет указать основные поля для сборки
config / build / esbuild-config.ts
import ESBuild, {BuildOptions} from 'esbuild'
import path from 'path'
import {CleanPlugin} from './plugins/CleanPlugin';
import {HTMLPlugin} from "./plugins/HTMLPlugin";
const mode = process.env.MODE || 'development';
const isDev = mode === 'development';
const isProd = mode === 'production';
function resolveRoot(...segments: string[]) {
return path.resolve(__dirname, '..', '..', ...segments)
}
const config: BuildOptions = {
outdir: resolveRoot('build'),
entryPoints: [resolveRoot( 'src', 'index.jsx')],
entryNames: '[dir]/bundle.[name]-[hash]',
allowOverwrite: true,
bundle: true,
tsconfig: resolveRoot('tsconfig.json'),
minify: isProd,
sourcemap: isDev,
metafile: true,
loader: {
'.png': 'file',
'.svg': 'file',
'.jpg': 'file',
},
plugins: [
CleanPlugin,
HTMLPlugin({
title: 'Ulbi tv',
})
],
}
export default config;
Сборка entry-поинтов в конфигурацию
И так же для упрощения вызова команды esbuild, нам стоит создать заранее файлы, которые будут поднимать нам сборку (прод/разработка)
Дев сборка. Она в себя будет включать сервер ноды с Express, которая будет контролировать работу сборщика.
config / build / esbuild-dev.ts
import ESBuild from 'esbuild';
import path from 'path';
import config from './esbuild-config';
import express from 'express';
import {EventEmitter} from 'events';
const PORT = Number(process.env.PORT) || 3000;
const app = express();
const emitter = new EventEmitter();
app.use(express.static(path.resolve(__dirname, '..', '..', 'build')))
app.get('/subscribe', (req, res) => {
const headers = {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
};
res.writeHead(200, headers);
res.write('');
emitter.on('refresh', () => {
res.write('data: message \n\n')
})
})
function sendMessage() {
emitter.emit('refresh', '123123')
}
app.listen(PORT, () => console.log('server started on http://localhost:' + PORT))
ESBuild.build({
...config,
watch: {
onRebuild(err, result) {
if(err) {
console.log(err)
} else {
console.log('build...')
sendMessage()
}
}
}
}).then((result) => {
console.log(result)
}).catch(err => {
console.log(err);
})
Тут мы вызовем конфигурацию для production сборки, которая просто вернёт собранное приложение
config / build / esbuild-prod.ts
import ESBuild from 'esbuild';
import path from 'path';
import config from './esbuild-config';
ESBuild.build(config)
.catch(console.log)
Вызывается каждая сборка достаточно просто. Нам нужно просто вызывать команду сборки конфига esbuild, который мы описали в файле
package.json
"scripts": {
"build": "cross-env MODE=production ts-node config/build/esbuild-prod.ts",
"start": "ts-node config/build/esbuild-dev.ts"
},