esbuild 实践

4,385 阅读3分钟

前言

最近在开发公司的一个处于发展初期的项目,简单说就是一个monorepo的库,那么选择打包工具是必然要做的事情。

作为一个npm包,webapck是必然不在考虑范围内了,另外一个选择是rollup,但最后选择了esbuild

why esbuild

最直接的原因是快(在官网截了一张图)。仔细推演了之后,发现esbuild并没有什么不满足需求的地方,就直接上了。

实际使用过程中遇到的问题

前面有提到,我们的项目是一个monorepo,使用了yarn workspaceslerna,并且纯typescript

项目结构如下

1. 打包

每个子包都需要build一次,这里可以有两种做法。

1.1 做法a

yarn workspaces run build

这种做法我们需要先在每个子包的package.json中写好build的脚本,个人比较讨厌这种方式,缺点很明显,当需要改动build脚本时,每个子包的package.json都要修改一次。而且不是js,不够灵活。

1.2 做法b

在根目录下新增一个build.js,在文件内完成build的整个流程,大概的代码如下。

const { build } = require('esbuild');
const path = require('path');
const fs = require('fs');
const packages = fs.readdirSync(path.join(__dirname, 'packages'));

const createBuildConfig = (targetPath) => {
  const config = {
    entry: path.join(targetPath, 'src/index.ts'),
    outfile: path.join(targetPath, 'dist/index.js'),
    bundle: true,
    minify: true,
    target: 'es2015',
    format: 'esm',
  };

  return config;
};

packages.forEach(package => {
  const targetPath = path.join(__dirname, 'packages', package);
  const config = createBuildConfig(targetPath);
  build(config);
});

2. d.ts怎么办

一个typescript项目最终要提供d.ts出来给外部用,但是esbuild最终build出来的内容中并没有d.ts,因此我们要单独运行tsc,稍微修改一下上面的代码,build前先生成d.ts

// ...
const childProcess = require('childProcess');
// ...
packages.forEach(package => {
  const targetPath = path.join(__dirname, 'packages', package);
  
  // 因为只需要d.ts,因此加上--emitDeclarationOnly
  childProcess.execSync('tsc --emitDeclarationOnly', {
  	cwd: targetPath,
  })
  const config = createBuildConfig(targetPath);
  build(config);
});

3. jsx

默认情况下,jsx语法最终会编译成React.createElement(tag, props, child)

如果不使用React,如何将JSX遍历成自定义的createElement?在build的选项中新增如下配置

build({
  // ...
  jsxFactory: 'selfCreateElement',
  jsxFragment: 'Fragment',
})

如果使用了tsx,上面的配置可以添加到tsconfig.jsoncompilerOptions中。

即使如此,我们还需要在每个使用到jsx的文件顶部新增一行

import { selfCreateElement } from './selfCreateElement';

为了解放我们的双手,esbuild提供了inject,这样我们就不需要每一个jsx都引入selfCreateElement

// jsx-shim.js
export { selfCreateElement } from 'xxx';

// build.js
build({
  // ...
  jsxFactory: 'selfCreateElement',
  jsxFragment: 'Fragment',
  inject: ['jsx-shim.js'],
})

如果不想用inject,也可以写一个插件去动态插入各种代码片段,举个例子(非可运行代码)。

const plugin = {
  name: 'example',
  setup(build) {
    let svelte = require('svelte/compiler')
    let path = require('path')
    let fs = require('fs')

    build.onLoad({ filter: /\.(jsx|tsx)$/ }, async (args) => {

      // Load the file from the file system
      let source = await fs.promises.readFile(args.path, 'utf8')

      // Convert Svelte syntax to JavaScript
      try {
        const contents = 'import { selfCreateElement } from "xxx";' + source;
        return { contents };
      } catch (e) {
        return { errors: [convertMessage(e)] }
      }
    })
  }
}

require('esbuild').build({
  // ...
  plugins: [plugin],
});

4. 热更新

我们的项目虽然是一个库,但是最终还是要在web上用的,web上的打包还是使用的webpack,热更新最终还是走的webapck-dev-server

esbuild本身有提供serve选项,但看了一下,这个不太符合我们的需求,它会占用一个端口,而且生成的文件只存在于内存中。

因此,我们需要自己去watch文件的改动,然后重新build。

watch的功能自己去实现一套成本略大

找到了一个相对比较适合的库 github.com/rsms/estrel…estrella基于esbuild,自己封装了一层,并且用chokidar自己实现了watch,此外还有type check。

最终代码

// build.js
const { build } = require('estrella');
const path = require('path');
const fs = require('fs');
const childProcess = require('child_process');
const packages = fs.readdirSync(path.join(__dirname, '../packages'));

const CONFIG_FILE_NAME = 'esbuild.config.js';

const createBuildConfig = (targetPath) => {
  let config = {
    entry: path.join(targetPath, 'src/index.ts'),
    outfile: path.join(targetPath, 'dist/index.js'),
    bundle: true,
    minify: false,
    target: 'es2015',
    format: 'esm',
    tslint: true,
  };
  const configPath = path.join(targetPath, CONFIG_FILE_NAME);
  
  if (process.env.mode === 'development') {
  	config.sourcemap = 'inline';
  }
  
  if (fs.existsSync(configPath)) {
  	// 读取子包esbuild.config.js 配置
    const selfConfig = require(configPath);
    config = {
      ...config,
      ...selfConfig,
    };
  }

  return config;
};

packages.forEach(package => {
  const targetPath = path.join(__dirname, '../packages', package);
  const config = createBuildConfig(targetPath);

  childProcess.execSync('tsc --emitDeclarationOnly', {
    cwd: targetPath,
  });

  build(config);
});

esbuild的缺点

目前个人感受最深的就是,不够灵活,官方文档也提到,不支持ast操作,也就是开发者需要操作ast时,需要自己去处理ast。另外的就是生态一般,完全比不上webpack。