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
编译结果在处理辅助函数方面有如下三种可选方案:
- 在每一个需要辅助函数的文件中都注入辅助函数的实现。
- 使用
--noEmitHelpers
:仅使用辅助函数但不注入其实现,自行维护全局辅助函数。 - 使用
importHelpers
:辅助库作为单独的模块添加到项目中,编译器根据需求导入它们。
2 babel
与 swc
编译结果与辅助函数的处理
babel
、swc
与 tsc
是当前较为流行的同一类工具,都可以将使用高级语法编写的 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 实现。
可以看到,tsc
和 swc
都不处理 polyfill 的问题,由使用者自行决定如何去做语法层的向下兼容。而 babel
则借助插件 @babel/plugin-transform-runtime
的 corejs
(基于插件 babel-plugin-polyfill-corejs<2|3>
)参数决定如何注入相关语法的实现。
所以可以说只有 babel
及其官方插件体系提供的编译方案,真正实现了尽可能的向下兼容。
提示:
由于 babel
的插件为主的架构方式,其插件非常多,很容易迷失在插件之间。针对同一类问题,在不同的插件中可能拥有不同的处理方案和选项。
常规情况下,你可能不会主动使用 @babel/plugin-transform-runtime
插件,而是引入 @babel/preset-env
来配置 babel
的编译选项。@babel/preset-env
提供了 corejs
和 useBuiltIns
等参数以实现自定义引入 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
: 尽可能的避免辅助函数
部分辅助函数的作用之一,是为了使得编译后的逻辑与高级语法尽可能的保持完全一致。但在许多实际应用场景下,不完全一致也是没问题的。babel
和 swc
都提供了 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
AMD
–require.js
CMD
–sea.js
System
–System.js
模块加载与构建器UMD
– Universal Module Definition,同时兼容AMD
规范、CommonJS
规范和全局变量方式。UMD + ESM
4.2 Node.js 中的 CJS 与 ESM
Node.js 从一开始就设计并实现了 CommonJS(CJS) 规范。长期以来仅面向 Node.js 的应用模块没有其它更好的可选方案。
由于 ESM
与 CJS
方案之间存在巨大的差异性,它们很难共存。一个 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()
语法同时支持 CJS
和 ESM
模式。于是,作为公共库的向后兼容包袱没有了。下游 CJS
规范的应用,只需要改为动态导入方式引用 ESM
模块的依赖即可。
此后 ESM
模块化方案才真正开始在 Node.js 社区逐渐普及。许多流行的开源库发布新版本时,开始不再提供 CJS
方案的内容,仅输出 ESM
方案的结果。比较有代表性的有 chalk@5
等。
基于 CJS
模块加载方案的大量应用,CJS
与 ESM
方案仍会长期共存于前端开发的应用中,但未来很可能会被 ESM
标准完全替代。
5 扩展参考
5.1 playground
: 在线试一试
5.2 参考文档与链接
- https://www.typescriptlang.org/zh/tsconfig#importHelpers
- https://www.typescriptlang.org/zh/tsconfig#noEmitHelpers
- https://www.typescriptlang.org/zh/tsconfig#module
- https://www.typescriptlang.org/docs/handbook/modules.html
- @babel/plugin-transform-runtime
- @babel/runtime
- core-js: https://github.com/zloirock/core-js
- swc docs:https://swc.rs/docs
- @swc/helpers
- https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#import-expressions
- https://nodejs.org/en/blog/release/v12.17.0/