如何更好的编译 React 组件库 / Node 模块 ?

2,752 阅读6分钟

很多时候,我们会把一些公共方法或者通用组件库,抽成一个包,并发布到 npm 上,这样其他开发者就可以通过安装 npm 包的方式使用到这些方法或组件库了。

可是,为什么发这些库之前为什么要使用编译工具先编译?选择什么编译工具编译?怎么样才能减小的编译产物体积?接下来本文将逐一解答。

为什么发包前需要编译源代码?

当我们编写好我们 Node 模块或 React 组件库后,我们不能直接给其他开发者使用。主要有以下两点原因:

  1. 兼容低版本的运行环境:比如源代码中使用了最新的 ES 语法,但是我们的 Node 模块是在低版本 Node.js 中运行的,这样的话就会报语法错误
  2. 不被编译工具编译:比如我们在前端项目中安装了组件库依赖,但是编译工具不去编译 node_modules 中的依赖,仅仅编译项目的源代码,这时候我们在浏览器中运行也是会直接报语法错误的

image.png

因此,为了让我们的库在最终消费的地方能够正常运行,我们在发布之前,最好还是要把我们的源代码编译一下,以兼容低版本的运行环境(你可以指定你的库最低的运行环境版本)。

使用编译工具编译源代码

目前社区有很多流行的 JavaScript 编译工具,比如 BabelSWC 等等。它们主要会做两个事情:

1. 把最新的 ES 语法转换成目标环境支持的语法代码

比如我们最熟悉不过的箭头函数,它是 ES2015 引入的新语法,在 ie 11 中是无法运行的,需要编译工具把它降级成普通函数:

image.png

2. 对目标环境不支持的 api 增加 polyfill

有些高级语法,它们并不能仅仅通过语法转换后就可以在目标环境中运行,需要编译工具额外增加 api。比如 Array.prototype.at 语法(它是 ES2022 引入的新语法,可以方便我们更简单的拿到数组中的值),它需要先在运行文件开头在 Array.prototype 上扩展一个 at() 方法,才可以在低版本的运行环境中使用:

image.png

在上图中,我们可以看到编译产物中通过 require() 的方式引用了 core-js 导出的 modules/es.array.at.js 代码模块,我们把这个代码模块叫做 polyfill(垫片)代码。由于 polyfill 是在运行时(runtime)引入的,一般我们把提供 polyfill 基础库叫运行时基础库。目前提供 polyfill 的运行时基础库主要包括:

  • core-js:JS 标准库的 polyfill,包含最新的 ES 标准语法和在提案中的语法
  • regenerator-runtime:包含 generatorasync 函数的运行时代码

使用 Babel 编译

Babel 是一个 JavaScript 编译器,它可以把我们的源代码编译成目标环境支持的语法代码并增加相应的 polyfill。

使用 @babel/core + @babel/preset-env

假设我们有以下的源代码:

// src/index.js
const add = async (a, b) => {
  return Promise.resolve({ res: a + b, succuss: true });
};

add(1, 2).then((res) => console.log({ ...res }));

我们需要安装一些依赖才能够编译我们的源代码:

$ pnpm i @babel/cli @babel/core @babel/preset-env -D

每个依赖的作用如下:

  • @babel/cli:Babel 官方推出的脚手架工具,帮助我们在命令行中运行编译
  • @babel/core:babel 核心的编译库,会注入各种 helper 方法到我们的编译产物
  • @babel/preset-env:会根据目标环境引入对应的 Babel 插件来实现编译和增加 polyfill 代码

然后我们在项目根目录中新建 babel.config.json 文件,并加入以下代码:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "ie": "11",
          "firefox": "60",
          "chrome": "67",
          "safari": "11.1"
        },
        "useBuiltIns": "usage",
        "corejs": "3"
      }
    ]
  ]
}

这里,我们把 @babel/preset-env 加入到 presets 中。其中每个选项的作用如下:

  • targets:指定我们的目标环境,这时会分析源码中使用了哪些高版本语法,然后按需加载对应的 Babel 插件进行编译
  • useBuiltIns:开启 polyfill 功能。默认值为 false,也就是不开启 polyfill;设置为 entry 则会根据目标环境引入 polyfill,但是可能会引入很多没有被使用到的 polyfill;设置为 usage 会按需引入 polyfill。这里我们使用 usage 配置以减少 polyfill 的引入
  • corejs:指定使用的 core-js 版本,这里我们使用 3

然后我们在终端调用以下命令编译我们的源代码:

$ pnpm babel src --out-dir lib

image.png

注意:要让代码正常运行需要安装 core-js 依赖并将其加入到库的 package.json 的 dependencies。

注意:regenerator-runtime 在 @babel/helpers@^7.18.0 就作为 inline helper 了,不需要显式指定此依赖。详见 PR

从上图中我们可以看到,我们使用 @babel/preset-env@babel/core 对高版本语法进行降级和注入 polyfill。但是存在两个问题:

  1. 污染全局空间:从编译产物中可以看到,是直接通过 require() 的方式引入 core-js 的,而 core-js 会直接修改全局环境中的变量或者对象原型链,可能会覆盖其他地方定义的内容
  2. 文件体积增大:工具函数(helper)直接写在编译产物中(比如 _objectSpread),如果有多个文件需要被编译,那么这些工具函数将会重复出现

使用 @babel/plugin-transform-runtime

@babel/plugin-transform-runtime 能够把 helper 方法和 Polyfill 通过模块的形式导入,很好的解决了上面的两个问题。

使用前先安装一下依赖:

$ pnpm i @babel/plugin-transform-runtime -D

然后我们修改我们的 babel.config.json 配置文件:

{
+ "plugins": [
+   [
+     "@babel/plugin-transform-runtime"
+     { "corejs": 3 }
+   ]
+ ],
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "ie": "11",
          "firefox": "60",
          "chrome": "67",
          "safari": "11.1"
        },
-       "useBuiltIns": "usage",
-       "corejs": "3",
      }
    ]
  ]
}

@babel/plugin-transform-runtime 可以帮我们注入 polyfill,因此 @babel/preset-envuseBuiltIns 配置可以去掉,在 @babel/plugin-transform-runtime 指定 corejs 版本以注入对应的 polyfill。

image.png

可以看到,@babel/plugin-transform-runtime 可以让我们的编译产物使用非全局版本的 polyfill 而是通过模块化导入;另外把工具函数也通过模块的方式导入,大大减小了产物体积。对于开发库的场景来说这种方式会是更好的选择。

在代码运行之前,我们需要安装 @babel/runtime-corejs3 依赖并将其加入到库的 package.json 的 dependencies 里。

使用 ICE PKG 编译

ICE PKG 是一个用于开发 npm 包的工具,支持开发 React 组件、Node 模块、React 类库等。它拥有开箱即用的编译能力,并且使用了 SWC 代替了 Babel 作为底层的编译工具,有着比 Babel 快数十倍的编译速度。

我们使用上面相同的示例代码,并且在终端执行 pnpm ice-pkg build 命令后就可以看到下面的构建产物:

image.png

可以看到,ICE PKG 默认是使用了与 @babel/plugin-transform-runtime 类似的方案,把 helper 方法和 polyfill 通过模块化的方式从 @swc/helpers 导入,避免污染全局环境,同时也减小了产物体积。

transform-runtime 方案一样,在代码运行之前,我们需要安装 @swc/helpers 依赖并将其加入到库的 package.json 的 dependencies 里,这样在库发布以后,才可以正常运行了。

另外,ICE PKG 默认支持编译出 ES2017 的构建产物,一些常见的 JavaScript 语法特性比如 async/await、generator 等都会被保留,进一步减小产物的体积。在前端项目中使用的时候,结合框架按照 targets 按需进行编译。

image.png

总结

本文先是详细介绍了 JavaScript 编译工具的作用,并且演示如何使用 @babel/preset-env + @babel/core 编译转换源代码并引入对应的 polyfill,但发现全局引入了 polyfill 会直接污染全局环境,helper 方法重复导入会大大增大了产物体积。

然后介绍使用 @babel/plugin-transform-runtime 插件把 helper 方法和 polyfill 使用模块化的方式导入,很好解决了上面遇到的两个问题。

最后介绍了如何使用 ICE PKG 结合 @swc/helper 更简单更快速的开发 npm 包。

是否有更好的方式编译我们的库源码呢?欢迎大家讨论。