从 tsconfig 参数 importHelpers 谈 ECMAScript 高级语法编译与辅助函数的处理方案

目录
[隐藏]

1 TypepScript 语法编译与辅助函数简介

使用 TypepScript 内置的 tsc 工具,可以将 ts 源文件转译为标准的 JavaScript 代码文件。配置文件 tsconfig.json 可用于配置 tsc 编译的具体方案。

tsc 的编译参数 target 可指定输出结果的语言标准版本,所以实际上它也可以用作为 ECMAScript 高版本源码转译为低版本的工具。

在语法转译过程中,可能会因语法降级兼容而需要额外的辅助函数,如兼容继承(_extends)、展开运算符(__assign)、异步函数(__awaiter)等。

1.1 tsc 编译结果与辅助函数示例与分析

下面举一个简单的例子。

对于 index.ts 文件,其内容如下:

// index.ts
export * './common';

tsconfig.json 配置如下:

{
    "compilerOptions": {
        "module": "commonjs",
        "outDir": "cjs",
        "target": "es2020"
    }
}

tsc@4.7.4 输出的结果参考如下:

// index.js
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
    for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./common"), exports);

可以看到,在输出为 commonjs 规范的结果上,直接注入了辅助函数 __createBinding__exportStar,一行代码变为了 16 行。

如果有许多 ts 文件转译结果都需要 __exportStar 辅助函数,显然就造成了输出结果中大量的重复注入。

1.2 tsc 的编译参数 noEmitHelpers

为了解决这种问题,TypeScript 在 2015 年引入了编译参数 noEmitHelpers。当开启该参数时,tsc 编译结果将不再注入辅助函数。

我们修改一下 tsconfig.json 配置为:

{
    "compilerOptions": {
        "noEmitHelpers": true,
        "module": "commonjs",
        "outDir": "cjs"
    }
}

则上面示例的 index.ts 文件输出结果变为如下:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("../common"), exports);

没了辅助函数的注入,代码简单了许多。但仍然会有辅助函数名称的使用,默认辅助函数在全局函数中已有定义。

这种情况下,只需要维护一份文件用于定义转译结果用到的全局辅助函数即可。

对于中小型项目,维护一份项目用到的辅助函数文件算不上复杂,但也会存在如同步更新等问题。对于复杂的大型项目来说则维护成本与心智负担就显得较为明显。另外,如果一个项目中引入了多个 tsc 编译输出的外部工具库,辅助函数依然避免不了重复引入。

1.3 tsc 的编译参数 importHelpers

tsc 很快就引入了新的变异参数 importHelpers 以及  tslib 库,用于解决辅助函数统一性的问题。

importHelpers 参数允许每个项目从 tslib 中导入一次辅助函数,而不是在每个文件中都包含他们。

修改 tsconfig.json 配置如下:

{
    "compilerOptions": {
        "importHelpers": true,
        "module": "commonjs",
        "outDir": "cjs"
    }
}

则上面示例的 index.ts 文件输出结果变为:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
tslib_1.__exportStar(require("../common"), exports);

可以看到,所有的辅助函数都变为了从 tslib 库引入。这样就可以实现辅助函数只会存在一份的结果。

1.4 tsc 与辅助函数小结

综上简单总结一下,tsc 编译结果在处理辅助函数方面有如下三种可选方案:

  1. 在每一个需要辅助函数的文件中都注入辅助函数的实现。
  2. 使用 --noEmitHelpers:仅使用辅助函数但不注入其实现,自行维护全局辅助函数。
  3. 使用 importHelpers:辅助库作为单独的模块添加到项目中,编译器根据需求导入它们。

2 babelswc 编译结果与辅助函数的处理

babelswctsc 是当前较为流行的同一类工具,都可以将使用高级语法编写的 Javascript 代码编译转换而实现向下兼容。

2.1 babel@babel-runtime

babel 可以通过使用插件 @babel/plugin-transform-runtime 实现类似 importHelpers 的效果,其使用的辅助函数库则是 @babel/runtime。下面是该插件在 babel 配置文件 .babelrc 中的配置示例:

{
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": false,
        "helpers": true,
        "regenerator": true,
        "version": "7.0.0"
      }
    ]
  ]
}

可以看到,helpers 参数的功能与 tsc 中的 importHelpers 类似。

另外还有一个 corejs 参数,它用于设置是否在必要的地方使用 core-js 库,实现针对高级语法标准在低语言版本中的兼容。

关于该插件的各参数具体功能可以参考 babel 官方文档:

2.2 swc@swc/helpers

swc 提供了编译参数 externalHelpers@swc/helpers 库处理辅助函数的问题。

开启了 externalHelpers 参数后,export * from './common' 会被编译为如下内容:

"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
var _exportStar = require("@swc/helpers/lib/_export_star.js").default;
_exportStar(require("./common"), exports);

可以看到与 tsc 开启 importHelpers 后的编译结果非常相似。而且更进一步的,将每一个辅助函数都以单文件的方式引入,以尽可能的不引入未使用的其它辅助函数。

2.3 为什么需要 core-js

tsc 等编译器在语法编译过程中,仅会对能够使用低级语法标准兼容实现的高级语法,使用辅助函数的方式改写。低级语法无法直接实现的(如 'abcabca'.repalceAll('a', '')这类原型扩展方法等)则不会处理,直接原样输出。

无论是 tslib@babel/runtime 还是 @swc/helpers,其仅包含语法转译所需的辅助函数,不包括高级语法的 polyfill 实现。tslib 库的 es6 源码仅有两百多行。

为了真正的实现完全向下兼容,则需要 core-js 这样的库的帮助。core-js 提供了大量高级语法的 polyfill 实现。

可以看到,tscswc 都不处理 polyfill 的问题,由使用者自行决定如何去做语法层的向下兼容。而 babel 则借助插件 @babel/plugin-transform-runtimecorejs (基于插件 babel-plugin-polyfill-corejs<2|3>)参数决定如何注入相关语法的实现。

所以可以说只有 babel 及其官方插件体系提供的编译方案,真正实现了尽可能的向下兼容。

提示:

由于 babel 的插件为主的架构方式,其插件非常多,很容易迷失在插件之间。针对同一类问题,在不同的插件中可能拥有不同的处理方案和选项。
常规情况下,你可能不会主动使用 @babel/plugin-transform-runtime 插件,而是引入 @babel/preset-env 来配置 babel 的编译选项。@babel/preset-env 提供了 corejsuseBuiltIns 等参数以实现自定义引入 core-js 的方式。

3 面向辅助函数的编译方案选择

1.4 小节我们做了一下总结,针对辅助函数可有三种选择方案。它们应该如何选择呢?

在实际的项目开发中,因项目的用途与规模差异,最佳方案并非是单一固定的。

一般来说,在当前的开发模式与工具体系下,自行维护全局辅助函数的方式并不适用绝大多数的项目。基于 tree-shaking 的构建方式,可以在最终输出结果上移除并未实际使用的代码。

3.1 私有业务类项目的可选方案

对于常规的业务项目来说,需要完整的最终解决方案。对于业务逻辑复杂规模较大的项目,推荐使用 tslib 等库:

  • 在考虑输出结果大小的情况下,推荐开启 helpers 参数,统一从 tslib 等库引用辅助函数。
  • 另外可结合 babel 插件引入 core-js 实现向预设的最低语言版本兼容适配。
    • 提示:babel 可以实现根据源码分析实际需要引入 core-js 中哪些具体的 polyfill 子文件。
  • 也可以自行根据实际情况决定如何引入 core-js
  • 如果项目需要兼容的运行环境非常低,也可以考虑直接引入完整的 core-js 库。

helpers 参数在几个编译工具中默认都是关闭的,实际上注入辅助函数在大多数项目中是无关紧要的。

3.2 用于共享使用的公共库

对于发布至 npm 的公共库来说,其优先考虑的是尽可能友好的适配各种具体的应用场景。

如果库规模较小,例如仅几个文件,则输出结果包含的辅助函数也非常少。可以选择默认注入辅助函数的方式。

如果项目工程较为庞大复杂,而且是面向 Node.js 类的项目,推荐开启 helper 选项。引入辅助函数库可以在最终的项目中避免大量的重复。

3.3 Loose: 尽可能的避免辅助函数

部分辅助函数的作用之一,是为了使得编译后的逻辑与高级语法尽可能的保持完全一致。但在许多实际应用场景下,不完全一致也是没问题的。babelswc 都提供了 loose 参数,当开启了该参数时,输出结果可以不那么严谨,一些辅助函数不会再被注入。以如下示例:

const b = [1, , 2];
const a = [...b];

swc 在开启了 externalHelpers 参数的情况下,为了会编译为如下内容:

"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
var _toConsumableArray = require("@swc/helpers/lib/_to_consumable_array.js").default;
var b = [1, , 2];
var a = _toConsumableArray(b);

swc 在不开启 externalHelpers 参数的情况下,会为了注入 _toConsumableArray 而注入多达 6 个辅助函数。

而在开启了 loose 参数后,swc 的编译结果简化为如下内容:

"use strict";
var b = [1, , 2];
var a = [].concat(b);

上面的示例示例中,变量 a 的结果是有些差异的,但是在最终的实际业务逻辑处理上可能并不会有差异出现。

3.4 ESM(ES Module):面向未来的方案

分析一下辅助函数的作用和类型,主要有两种:高级语法兼容(target)和模块加载器适配(module)。

如果编译结果不再需要这两方面的兼容,则就不需要辅助函数了。将输出结果改为 ESM 最新规范方式即可。

以 tsc 为例,可以将 tsconfig.json 中的配置改为:

{
    "compilerOptions": {
        "target": "ESNext",
        "module": "ESNext"
    }
}

得益于语言标准的发展,ESM 模块化标准方案在社区中的普及速度也非常快:

  • 对于面向非 Node.js 的公共库,可以完全输出为 ESM 模式,由业务应用构建流程具体决定如何进一步处理。
  • 对于面向 Node.js 的公共库,社区发展方向也为向 ESM 标准靠近。
  • 对于业务应用开发,ESM 模块加载方案也是未来的方向。

需要注意的是:

  • ESM 方案不再需要模块加载器的辅助函数,具体交给最终的业务构建器(如webpack、rollup、esbuild等)整合与处理。
  • 只要 target 的取值不是 ESNext,为了处理高级语法的向下兼容,仍然避免不了引入辅助函数。

4 前端中的模块化与 ESM(ES Modules) 标准的发展

ES6(ECMAScript 2015)以前,JavaScript 并没有模块化加载标准,开源社区中基于大型项目开发实践,沉淀了 AMD、CommonJS、CMD 等著名的模块加载方案。

4.1 UMD 与 ESM

长期以来,社区中面向多运行环境的开源公共库为了能够同时兼容 AMD 与 CommonJS,输出的源码结果大多会遵循 UMD 规范。

随着 ESM 的标准化以及前端工程化构建工具的发展,特别是在 rollup 提出了基于 ESM 的 tree shaking 构建优化方案后,
大量面向浏览器应用的公共库开始同时输出遵循 UMD 规范源码的同时,也提供遵循 ESM 标准化的内容。

  • CommonJS(CJS)Node.js
  • AMDrequire.js
  • CMDsea.js
  • SystemSystem.js 模块加载与构建器
  • UMD – Universal Module Definition,同时兼容 AMD 规范、CommonJS 规范和全局变量方式。
  • UMD + ESM

4.2 Node.js 中的 CJS 与 ESM

Node.js 从一开始就设计并实现了 CommonJS(CJS) 规范。长期以来仅面向 Node.js 的应用模块没有其它更好的可选方案。

由于 ESMCJS 方案之间存在巨大的差异性,它们很难共存。一个 Node.js 应用可能会加载非常多的外部依赖,这使得无论是公共库还是私有的业务模块,转向 ESM 标准化方案都存在较大的包袱。主要体现在:

  • 向下兼容:更改模块加载方案会导致下游应用无法直接引入和使用
  • 外部依赖:为了引入并使用其它公共库依赖,无法直接使用 ESM 方案

因为这种历史包袱的存在,ES Module 虽然在 ES6+ 开始就存在于标准中,但一直无法在 Node.js 中得到支持。

转折点发生在 ES2020,其语言标准中新增了 Dynamic import() 动态导入规范。
2020年5月,Node.js 在发布的 12.17.0 LTS 版本中正式启用了 ES Module 模块化方案的支持。

在 Node.js 中,Dynamic import() 语法同时支持 CJSESM 模式。于是,作为公共库的向后兼容包袱没有了。下游 CJS 规范的应用,只需要改为动态导入方式引用 ESM 模块的依赖即可。

此后 ESM 模块化方案才真正开始在 Node.js 社区逐渐普及。许多流行的开源库发布新版本时,开始不再提供 CJS 方案的内容,仅输出 ESM 方案的结果。比较有代表性的有 chalk@5等。

基于 CJS 模块加载方案的大量应用,CJSESM 方案仍会长期共存于前端开发的应用中,但未来很可能会被 ESM 标准完全替代。

5 扩展参考

5.1 playground: 在线试一试

5.2 参考文档与链接

点赞 (0)

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

Captcha Code