前端构建新世代,Esbuild 原来还能这么玩!

2022-01-07 20:45:46 浏览数 (1)

Hello,我是三元同学。之前停更了一段时间,因为得了流感,一直在家养病,没来得及更新文章,跟读者朋友们先说声抱歉~今天给大家带来的是我最近写的原创文章,由于近段时间一直在研究前端构建相关的领域,像 Esbuild、Vite 这些都接触得比较多了,而且这些工具现在在前端圈也比较热门,备受业界关注,因此我想我有必要把我研究过的一些东西分享给大家,希望能对你有所帮助。

什么是 Esbuild?

Esbuild 是由 Figma 的 CTO 「Evan Wallace」基于 Golang 开发的一款打包工具,相比传统的打包工具,主打性能优势,在构建速度上可以快 10~100 倍。

架构优势

1. Golang 开发

采用 Go 语言开发,相比于 单线程 JIT 性质的解释型语言 ,使用 Go 的优势在于 :

  • 一方面可以充分利用多线程打包,并且线程之间共享内容,而 JS 如果使用多线程还需要有线程通信(postMessage)的开销;
  • 另一方面直接编译成机器码,而不用像 Node 一样先将 JS 代码解析为字节码,然后转换为机器码,大大节省了程序运行时间。

2. 多核并行

内部打包算法充分利用多核 CPU 优势。Esbuild 内部算法设计是经过精心设计的,尽可能充分利用所有的 CPU 内核。所有的步骤尽可能并行,这也是得益于 Go 当中多线程共享内存的优势,而在 JS 中所有的步骤只能是串行的。

3. 从零造轮子

从零开始造轮子,没有任何第三方库的黑盒逻辑,保证极致的代码性能。

4. 高效利用内存

一般而言,在 JS 开发的传统打包工具当中一般会频繁地解析和传递 AST 数据,比如 string -> TS -> JS -> string,这其中会涉及复杂的编译工具链,比如 webpack -> babel -> terser,每次接触到新的工具链,都得重新解析 AST,导致大量的内存占用。而 Esbuild 中从头到尾尽可能地复用一份 AST 节点数据,从而大大提高了内存的利用效率,提升编译性能。

与 SWC 对比

速度

下面拿纯 Esbuild 和 SWC 来编译代码,作为 Transformer 来转换 800 个 tsx 文件,不写任何的 JS 胶水代码(如 esbuild-register、esbuild-loader、swc-loader 本身为了适配相应的宿主工具,会写一堆 JS 胶水代码,影响判断)。

Esbuild

SWC

TSC

第一次

138 ms

217 ms

8640 ms

第二次

154 ms

206 ms

8400 ms

第三次

142 ms

258 ms

8480 ms

平均

144.7 ms

227 ms

8507 ms

耗时倍率

x1

x 1.58

x 58.8

从这个例子可以看出,Esbuild 与 SWC 在性能上是在一个量级的,这里通过仓库的例子 Esbuild 略快,但不排除其他例子里面 SWC 比 Esbuild 略快的场景。

兼容性

Esbuild 本身的限制,包括如下:

  • 没有 TS 类型检查
  • 不能操作 AST
  • 不支持装饰器语法
  • 产物 target 无法降级到 ES5 及以下

意味着需要 ES5 产物的场景只用 Esbuild 无法胜任。

相比之下,SWC 的兼容性更好:

  • 产物支持 ES5 格式
  • 支持装饰器语法
  • 可以通过写 JS 插件操作 AST

应用场景

对于 Esbuild 和 SWC,很多时候我们都在对比两者的性能而忽略了应用场景。对于前端的构建工具来说主要有这样几个垂直的功能:

  • Bundler
  • Transformer
  • Minimizer

从上面的速度和兼容性对比可以看出,Esbuild 和 SWC 作为 transformer 性能是差不多的,但 Esbuild 兼容性远远不及 SWC。因此,SWC 作为 Transformer 更胜一筹。

但作为 Bundler 以及 Minimizer,SWC 就显得捉襟见肘了,首先官方的 swcpack 目前基本处于不可用状态,Minimizer 方面也非常不成熟,很容易碰到兼容性问题。

而 Esbuild 作为 Bundler 已经被 Vite 作为开发阶段的依赖预打包工具,同时也被大量用作线上 esm CDN 服务,比如esm.sh等等;作为 Minimizer ,Esbuild 也已足够成熟,目前已经被 Vite 作为 JS 和 CSS 代码的压缩工具用上了生产环境。

综合来看,SWC 与 Esbuild 的关系类似于当下的 Babel 和 Webpack,前者更适合做兼容性自定义要求高的 Transformer(比如移动端业务场景),而后者适合做 Bundler 和 Minimizer,以及兼容性自定义要求均不高的 Transformer。

插件机制

esbuild 插件就是一个对象,里面有namesetup两个属性,name是插件的名称,setup是一个函数,其中入参是一个 build 对象,这个对象上挂载了一些钩子可供我们自定义一些构建逻辑。以下是一个简单的esbuild插件示例:

代码语言:javascript复制
let envPlugin = {
  name: 'env',
  setup(build) {
     // 文件解析时触发
    // 将插件作用域限定于env文件,并为其标识命名空间"env-ns"
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    // 加载文件时触发
    // 只有命名空间为"env-ns"的文件才会被处理
    // 将process.env对象反序列化为字符串并交由json-loader处理
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  // 应用插件
  plugins: [envPlugin],
}).catch(() => process.exit(1))

使用如下:

代码语言:javascript复制
*// 应用了env插件后,构建时将会被替换成process.env对象*

import { PATH } from 'env'

console.log(`PATH is ${PATH}`)

不过在编写插件的时候有一些需要注意的地方:

  1. Esbuild 插件机制只可作用于 build API,而不适用于 transformAPI,这意味着 webpack 当中的 esbuild-loader 这种只使用 Esbuild transform 功能的地方无法利用 Esbuild 的插件机制。
  2. 插件中的 filter 正则是使用 go 原生的正则实现的,用来过滤文件,为了不使性能过于劣化,规则应该尽可能严格。同时它本身和 JS 的正则也有所区别,比如前瞻(?<=)、后顾(?=)和反向引用(1)就不支持。
  3. 实际的插件应该考虑到自定义缓存(减少 load 的重复开销)、sourcemap 合并(源代码正确映射)和错误处理。可以参考 Svelte plugin。

虚拟模块支持

与 Rollup 对比

作为打包器,一般需要两种形式的模块,一种存在于真实的磁盘文件系统中,另一种并不在磁盘而在内存当中,也就是虚拟模块。Rollup 本身就天然支持虚拟模块,Vite 基于它的插件机制,也重度使用了虚拟模块的功能,以 wasm 文件的处理为例:

代码语言:javascript复制
const wasmHelperId = '/__vite-wasm-helper'
// helper 函数实现
const wasmHelper = async (opts = {}, url: string) => {
  // 省略具体实现
}
export const wasmPlugin = (config: ResolvedConfig): Plugin => {
  return {
    name: 'vite:wasm',
    resolveId(id) {
      if (id === wasmHelperId) {
        return id
      }
    },
    async load(id) {
      if (id === wasmHelperId) {
        return `export default ${wasmHelperCode}`
      }
      if (!id.endsWith('.wasm')) {
        return
      }
      const url = await fileToUrl(id, config, this)
      // 虚拟模块
      return `
import initWasm from "${wasmHelperId}"
export default opts => initWasm(opts, ${JSON.stringify(url)})
`
    }
  }
}

但 Rollup 的虚拟模块也有一些限制,为了与真实模块区分开,默认约定要在路径前面拼上一个''。这样会对路径产生一定的入侵性,直接放到浏览器进行 import 会出问题(Vite 内部也将 替换成 __xx 这种形式,以免直接将 带 路径放到浏览器中 import):

image.png

Esbuild 中对于虚拟模块的支持更加友好一些,直接通过 namespace 来区分真实模块和虚拟模块,这样也不会有 这样 hack 操作。

编译能力

使用 Esbuild 的虚拟模块,可以完成很丰富的功能,除了上述插件实例中在内存中计算出 env 的值作为模块内容,还可以模块名当做一个函数来进行编译,甚至可以在编译阶段实现函数递归的过程。比如这个 Esbuild 插件:

代码语言:javascript复制
{
  name: 'fibo',
  setup(build) {
    build.onResolve({ filter: /^fib(d )/ }, args => {
      return { path: args.path, namespace: 'fib' }
    })
    build.onLoad({ filter: /^fib(d )/, namespace: 'fib' }, args => {
      const match = /^fib((d ))/.exec(args.path);
      n = Number(match[1]);
      
      console.log(n);
      let contents = n < 2 ? `export default ${n 1}` : `
          import n1 from 'fib(${n - 1})'
          import n2 from 'fib(${n - 2})'
          export default n1   n2`
      return { contents }
    })
  }
}

引入这个插件,可以解析如下的 import 语句:

代码语言:javascript复制
import fib5 from 'fib(5)'

console.log(fib5)

// 13

所有的模块都是虚拟模块,在真实文件系统中并不存在

另外,还能借助虚拟模块来进行 URL Import,支持如下的 import 代码:

代码语言:javascript复制
import React from 'https://esm.sh/react@17'

这也可以在插件当中实现,可参考示例。

落地场景

1. 代码压缩工具

Esbuild 的代码压缩功能非常优秀,可以甩开传统的压缩工具一个量级以上的性能差距。Vite 在 2.6 版本也官宣在生产环境中直接使用 Esbuild 来压缩 JS 和 CSS 代码。

2. 代替 ts-node

社区已经有了相应的方案 esno: https://github.com/antfu/esno

代码语言:javascript复制
ts-node index.ts
// 替换为
esno hello.ts

3. 代替 ts-jest

使用 esbuild-jest 代替ts-jest,我曾经尝试在某些大型包中使用 esbuild-jest 来作为 transformer,相比 ts-jest,整体大概提升 3 倍测试效率。

Github 地址:https://github.com/aelbore/esbuild-jest

4. 第三方库 Bundler

Vite 中在开发阶段使用 Esbuild 来进行依赖的预打包,将所有用到的第三方依赖转成 ESM 格式 Bundle 产物,并且未来有用到生产环境的打算。

同时业界也有一些平台基于纯 Esbuild 来做线上 cjs -> esm 的 CDN 服务,比如 esm.sh 和 skypack:

5. 打包 Node 库

为什么要打包 Node 库:

  • 减少 node_modules 代码,避免业务安装一大堆 node_modules 的代码,减少安装体积
  • 提高启动速度,所有代码打到一个文件,减少了大量的文件 io 操作
  • 更安全。所有代码打包也是锁定依赖版本的一种方式,可以避免之前出现的 coa 包导致的大面积 CI 挂掉的问题,可参考云谦的这篇文章

这方面 Esbuild 的作用跟现在 vercel 团队出品的 ncc 差不多,但会对代码的写法有一些限制,无法分析动态 require 或者 import 语句含有变量的情况:

6. 小程序编译

对于小程序的场景,也可以使用 Esbuild 来代替 Webpack,大大提升编译速度,对于 AST 的转换则通过 Esbuild 插件嵌入 SWC 来实现,实现快速编译。详见 132 的分享 esbuild 上生产。

7. Web 构建

Web 场景就显得比较复杂了,对于兼容性和周边工具生态的要求比较高,比如低浏览器语法降级、CSS 预编译器、HMR 等等,如果要用纯 Esbuild 来做,还需要补充很多能力。

之前三元同学基于 Esbuild 实现了一套 Web 开发脚手架 ewas,已经在 Github 开源,并且已成功落地到我之前的小册项目当中,相比 create-react-app 启动速度提升了 100 倍以上(30s -> 0.3s)。仓库地址: https://github.com/sanyuan0704/ewas。

如今 Remix 1.0 正式发布,底层使用 Esbuild 构建,带来了极致的性能体验,成为 Next.js 强有力的竞争对手。

但总体来说,目前 Esbuild 对于真实的 Web 场景还有很多能力不支持,还有一些硬伤,包括语法不支持降级到ES5,拆包不灵活、不支持 HMR,对于真正能作为 Webpack 一样的构建工具来讲还有很长的路要走。

0 人点赞