likes
comments
collection
share

前端打包工具 Esbuild

作者站长头像
站长
· 阅读数 50

esbuild 作为一款新的构建工具,构建打包速度几乎完全碾压市面上流行的 webpack5,rollup 等工具。目前国内流行的 Vite 也使用了 esbuild 来预构建依赖,官方宣称速度快了 10 - 100 倍

前端打包工具 Esbuild

esbulid 为什么这么快呢?

  1. 使用 Golang 开发,构建逻辑代码直接被编译为原生机器码,而不用像 JS 一样先代码解析为字节码,然后转换为机器码,大大节省了程序运行时间。
  2. 多核并行。内部打包算法充分利用多核 CPU 优势,所有的步骤尽可能并行,这也是得益于 Go 当中多线程共享内存的优势。
  3. 从零造轮子。 几乎没有使用任何第三方库,所有逻辑自己编写,大到 AST 解析,小到字符串的操作,保证极致的代码性能。
  4. 高效的内存利用。Esbuild 中从头到尾尽可能地复用一份 AST 节点数据,而不用像 JS 打包工具中频繁地解析和传递 AST 数据(如 string -> TS -> JS -> string),造成内存的大量浪费。

大多数前端打包工具都是基于 JavaScript 实现的,而 Esbuild 则选择使用 Go 语言编写,两种语言各自有其擅长的场景,但是在资源打包这种 CPU 密集场景下,Go 性能更优。一般来说,JS 的操作是毫秒级,而 Go 则是纳秒级。

虽然现代 JS 引擎与10年前相比有巨大的提升,但 JavaScript 本质上依然是一门解释型语言,JavaScript 程序每次执行都需要先由解释器一边将源码翻译成机器语言,一边调度执行;

而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。

也就意味着,Go 语言编写的程序比 JavaScript 少了一个动态解释的过程。

前端打包工具 Esbuild

这种语言层面的差异在打包场景下特别突出,说的夸张一点,JavaScript 运行时还在解释代码的时候,Esbuild 已经在解析用户代码;JavaScript 运行时解释完代码刚准备启动的时候,Esbuild 可能已经打包完毕,退出进程了!

多线程优势

Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言,直到引入 WebWorker 规范之后才有可能在浏览器、Node 中实现多线程操作。

Rollup、Webpack 的代码,两者均未使用 WebWorker 提供的多线程能力。反观 Esbuild,它最核心的卖点就是性能,它的实现算法经过非常精心的设计,尽可能饱和地使用各个 CPU 核,特别是打包过程的解析、代码生成阶段已经实现完全并行处理。

除了 CPU 指令运行层面的并行外,Go 语言多个线程之间还能共享相同的内存空间,而 JavaScript 的每个线程都有自己独有的内存堆。这意味着 Go 中多个处理单元,例如解析资源 A 的线程,可以直接读取资源 B 线程的运行结果,而在 JavaScript 中相同的操作需要调用通讯接口 woker.postMessage 在线程间复制数据。

  • Go 在线程之间共享内存,而 JavaScript 必须在线程之间序列化数据。
  • Go 和 JavaScript 都有并行的垃圾收集器,但是 Go 的堆在所有线程之间共享,而对于 JavaScript, 每个 JavaScript 线程中都有一个单独的堆。

主要特点:

Esbuild 功能使用

使用 Esbuild 有 2 种方式,分别是 命令行调用代码调用

命令行调用

# 新建项目文件
mkdir learn-esbuild
# 新建一个项目
pnpm init -y 
# 安装 esbuild
pnpm add —D esbuild
// src/app.jsx
import * as React from 'react'
import * as Server from 'react-dom/server'

let Greet = () => <h1>Hello, world!</h1>
console.log(Server.renderToString(<Greet />))
pnpm add react react-dom

接着到package.json中添加build脚本:

"build": "esbuild src/app.jsx --bundle --outfile=dist/out.js",

前端打包工具 Esbuild

说明我们已经成功通过命令行完成了 Esbuild 打包!但命令行的使用方式不够灵活,只能传入一些简单的命令行参数,稍微复杂的场景就不适用了,所以一般情况下我们还是会用代码调用的方式。

代码调用

Esbuild 对外暴露了一系列的 API,主要包括两类: Build APITransform API,我们可以在 Nodejs 代码中通过调用这些 API 来使用 Esbuild 的各种功能。

// build.js
const { build, buildSync, serve } = require("esbuild");

async function runBuild() {
  // 异步方法,返回一个 Promise
  const result = await build({
    // ----  如下是一些常见的配置  --- 
    // 当前项目根目录
    absWorkingDir: process.cwd(),
    // 入口文件列表,为一个数组
    entryPoints: ["./src/index.jsx"],
    // 打包产物目录
    outdir: "dist",
    // 是否需要打包,一般设为 true
    bundle: true,
    // 模块格式,包括`esm`、`commonjs`和`iife`
    format: "esm",
    // 需要排除打包的依赖列表
    external: [],
    // 是否开启自动拆包
    splitting: true,
    // 是否生成 SourceMap 文件
    sourcemap: true,
    // 是否生成打包的元信息文件
    metafile: true,
    // 是否进行代码压缩
    minify: false,
    // 是否开启 watch 模式,在 watch 模式下代码变动则会触发重新打包
    watch: false,
    // 是否将产物写入磁盘
    write: true,
    // Esbuild 内置了一系列的 loader,包括 base64、binary、css、dataurl、file、js(x)、ts(x)、text、json
    // 针对一些特殊的文件,调用不同的 loader 进行加载
    loader: {
      '.png': 'base64',
    }
  });
  console.log('result', result);
}

runBuild();
node build.js

前端打包工具 Esbuild

在项目打包方面,除了buildbuildSync,Esbuild 还提供了另外一个比较强大的 API——serve。这个 API 有 3 个特点。

  1. 开启 serve 模式后,将在指定的端口和目录上搭建一个静态文件服务,这个服务器用原生 Go 语言实现,性能比 Nodejs 更高。
  2. 类似 webpack-dev-server,所有的产物文件都默认不会写到磁盘,而是放在内存中,通过请求服务来访问。
  3. 每次请求到来时,都会进行重新构建(rebuild),永远返回新的产物。
// build.js
const { build, buildSync, serve } = require("esbuild");

function runBuild() {
  serve(
    {
      port: 8000,
      // 静态资源目录
      servedir: './dist'
    },
    {
      absWorkingDir: process.cwd(),
      entryPoints: ["./src/index.jsx"],
      bundle: true,
      format: "esm",
      splitting: true,
      sourcemap: true,
      ignoreAnnotations: true,
      metafile: true,
    }
  ).then((server) => {
    console.log("HTTP Server starts at port", server.port);
  });
}

runBuild();

我们在浏览器访问localhost:8000可以看到 Esbuild 服务器返回的编译产物如下所示:

前端打包工具 Esbuild

单文件转译——Transform API

Esbuild 还专门提供了单文件编译的能力,即Transform API,与 Build API 类似,它也包含了同步和异步的两个方法,分别是transformSynctransform

// transform.js
const { transform, transformSync } = require("esbuild");

async function runTransform() {
  // 第一个参数是代码字符串,第二个参数为编译配置
  const content = await transform(
    "const isNull = (str: string): boolean => str.length > 0;",
    {
      sourcemap: true,
      loader: "tsx",
    }
  );
  console.log(content);
}

runTransform();

Esbuild 插件开发

插件开发其实就是基于原有的体系结构中进行扩展自定义。 Esbuild 插件也不例外,通过 Esbuild 插件我们可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。

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

let envPlugin = {
  name: 'env',
  setup(build) {
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

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

钩子函数的使用

onResolve 钩子 和 onLoad钩子

在 Esbuild 插件中,onResolve 和 onload是两个非常重要的钩子,分别控制路径解析和模块内容加载的过程。

这两个钩子函数中都需要传入两个参数: Options 和 Callback

Options 是一个对象,对于onResolve 和 onload 都一样,包含filternamespace两个属性,类型定义如下:

interface Options {
  filter: RegExp;
  namespace?: string;
}

filter 为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。

namespace 为选填参数,一般在 onResolve 钩子中的回调参数返回namespace属性作为标识,我们可以在onLoad钩子中通过 namespace 将模块过滤出来。如上述插件示例就在onLoad钩子通过env-ns这个 namespace 标识过滤出了要处理的env模块。

除了 Options 参数,还有一个回调参数 Callback,它的类型根据不同的钩子会有所不同。相比于 Options,Callback 函数入参和返回值的结构复杂得多,涉及很多属性。

在 onResolve 钩子中函数参数和返回值梳理如下:


build.onResolve({ filter: /^env$/ }, (args: onResolveArgs): onResolveResult => {
  // 模块路径
  console.log(args.path)
  // 父模块路径
  console.log(args.importer)
  // namespace 标识
  console.log(args.namespace)
  // 基准路径
  console.log(args.resolveDir)
  // 导入方式,如 import、require
  console.log(args.kind)
  // 额外绑定的插件数据
  console.log(args.pluginData)
  
  return {
      // 错误信息
      errors: [],
      // 是否需要 external
      external: false;
      // namespace 标识
      namespace: 'env-ns';
      // 模块路径
      path: args.path,
      // 额外绑定的插件数据
      pluginData: null,
      // 插件名称
      pluginName: 'xxx',
      // 设置为 false,如果模块没有被用到,模块代码将会在产物中会删除。否则不会这么做
      sideEffects: false,
      // 添加一些路径后缀,如`?xxx`
      suffix: '?xxx',
      // 警告信息
      warnings: [],
      // 仅仅在 Esbuild 开启 watch 模式下生效
      // 告诉 Esbuild 需要额外监听哪些文件/目录的变化
      watchDirs: [],
      watchFiles: []
  }
}

在 onLoad 钩子中函数参数和返回值梳理如下:

build.onLoad({ filter: /.*/, namespace: 'env-ns' }, (args: OnLoadArgs): OnLoadResult => {
  // 模块路径
  console.log(args.path);
  // namespace 标识
  console.log(args.namespace);
  // 后缀信息
  console.log(args.suffix);
  // 额外的插件数据
  console.log(args.pluginData);
  
  return {
      // 模块具体内容
      contents: '省略内容',
      // 错误信息
      errors: [],
      // 指定 loader,如`js`、`ts`、`jsx`、`tsx`、`json`等等
      loader: 'json',
      // 额外的插件数据
      pluginData: null,
      // 插件名称
      pluginName: 'xxx',
      // 基准路径
      resolveDir: './dir',
      // 警告信息
      warnings: [],
      // 同上
      watchDirs: [],
      watchFiles: []
  }
});

还有onStartonEnd两个钩子用来在构建开启和结束时执行一些自定义的逻辑,使用上比较简单,如下面的例子所示:

let examplePlugin = {
  name: 'example',
  setup(build) {
    build.onStart(() => {
      console.log('build started')
    });
    build.onEnd((buildResult) => {
      if (buildResult.errors.length) {
        return;
      }
      // 构建元信息
      // 获取元信息后做一些自定义的事情,比如生成 HTML
      console.log(buildResult.metafile)
    })
  },
}

在使用这些钩子的时候,有 2 点需要注意。

  1. onStart 的执行时机是在每次 build 的时候,包括触发 watch 或者 serve模式下的重新构建。
  2. onEnd 钩子中如果要拿到 metafile,必须将 Esbuild 的构建配置中metafile属性设为 true

参考:

esbuild 源码:github.com/evanw/esbui…

esbuild 文档:esbuild.github.io/

转载自:https://juejin.cn/post/7182495384197922873
评论
请登录