背景:Dayjs 业务应用与问题
在携程商旅的业务场景中,Dayjs 作为一款备受认可的 JavaScript 日期处理库,被广泛应用于各类时间操作任务。其简洁的 API 和高效的处理能力,使其成为内部众多项目处理日期相关逻辑的首选工具。
然而,随着业务的不断拓展和应用场景的日益复杂,Dayjs 在一些特定的边界条件下暴露出了内部缺陷,导致时间获取出现异常。这些异常情况不仅影响了业务的准确性,还对用户体验造成了一定程度的负面影响。
为了从根本上解决这些问题,确保业务的稳定运行,我们决定将 Dayjs 的代码库 fork 至内部仓库,进行有针对性的拓展和优化。
通过这种方式,我们能够深入到 Dayjs 的内部代码逻辑,精准定位并修复存在的问题,同时根据业务的特殊需求进行定制化开发,以满足携程商旅复杂多变的业务场景。
CorpDayjs: 底层依赖时间工具包
为了解决 Dayjs 中存在的问题,我们选择将 Dayjs 作为 dependencies 开发一款 CorpDayjs 上层库进行拓展。
在 CorpDayjs 的构建上,我们同样选择使用 Rollup 作为传统前端 library 构建工具进行构建。
由于商旅内部大多数为 SSR (ServerSideRender) 应用,所以对于应用包的构建产物我们会构建出 ESM、CommonJs 两种不同的模块规范产物提供给业务使用。
简单来说,比如 CorpDayjs 中存在这样的代码:
import dayjs from 'dayjs'
// 对于 dayjs 进行部分拓展以及 Bugfix ,在进行导出
export { dayjs }
export default dayjs
默认情况下,上述代码经 Rollup 构建后,在不同构建模式下会产生下面不同的结果:
// output: esm 格式
import dayjs from 'dayjs';
export { default as dayjs, default } from 'dayjs';
对于 ESM 格式的产物,Rollup 基本会原封不动的将 CorpDayjs 中的代码进行导出。
// output: commonjs 格式
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var dayjs = require('dayjs');
exports.dayjs = dayjs;
exports.default = dayjs;
对于 CommonJS 格式的产物,由于源代码导出存在默认导出和具名导出两种模式,为了保证 ESM 格式和 CommonJS 模块的互操作性。
默认情况下 Rollup 会将具名导出的 dayjs 挂载在 exports 对象上,将默认导出的 dayjs 挂载在 default 属性上。
这就导致在 CommonJS 模块规范中,若其他 package 引用 CorpDayjs 模块,需要这样使用:
const corpDayjs = require('corpDayjs')
// 正确
corpDayjs.default('1996-11-17')
// 错误: TypeError: corpDayjs is not a function
corpDayjs('1996-11-17')
CorpFeLibrary:上层应用工具组件
CorpFeLibrary 在商旅内部直接面向业务,提供了众多 utils 工具函数集合,其中包括对时间、日期操作的部分本地化拓展和格式调整。
在 CorpFeLibrary 中,日期操作同样依赖于 CorpDayjs,通过将 CorpDayjs 作为 external dependencies 进行调用。
CorpFeLibrary 的代码简单来说就像下面的例子:
import dayjs from 'corpDayjs'
function localizedTemporal() {
// do something
}
export {
localizedTemporal
}
同样,CorpFeLibrary 会经构建工具(Rollup)构建,生成两种不同模块规范的内容供业务方调用。
// esmodule:
import dayjs from 'corpDayjs';
function localizedTemporal() {
// do something
console.log(dayjs().format('YYYY-MM-DD'));
}
export { localizedTemporal };
// commonjs:
'use strict';
var dayjs = require('corpDayjs');
function localizedTemporal() {
// do something
console.log(dayjs().format('YYYY-MM-DD'));
}
exports.localizedTemporal = localizedTemporal;
细心的小伙伴可能已经发现问题了,CorpFeLibrary 编译后的 EsModule 中,对 CorpDayjs 的导入能正常工作。
但是 CommonJs 的产物中,当 localizedTemporal 方法运行时我们会得到一个错误:
这不难理解,这是因为 CorpDayjs 对于 commonjs 的产物,将源代码中的默认导出挂载在了 exports.default属性上。
而 CorpFeLibrary 编译后的 commonjs 产物,对于标记为 external 的 CorpDayjs 导入,编译成了类似 require('corpDayjs')().format('YYYY-MM-DD') 的用法。
实际应通过 require('corpDayjs').default().format('YYYY-MM-DD'),额外通过一层 default 属性访问才能得到预期结果。
首先,通过 CorpDayjs 的编译结果来看。输出的 Commonjs 规范中有如下代码:
// 1.
Object.defineProperty(exports, '__esModule', { value: true });
// 2.
exports.default = dayjs;
第一段代码通过 Object.defineProperty 为模块的 exports 对象挂载 __esModule 属性,并赋值为true。
第二段代码将原本 ES 模块中的 export default dayjs,编译成 exports.default = dayjs,将默认导出的 dayjs 挂载到 exports.default 属性上。
那么这两段代码的含义究竟是什么?接下来我们脱离上述例子,从本质探究这个问题产生的原因。
ESM & Commonjs 模块互操作性
__esModule:模块识别的关键标识
首先 CorpDayjs 编译后的
Object.defineProperty(exports, '__esModule', { value: true });
在传统的 NodeJs CommonJS 模块规范中,只有单一的导出机制。
当编写的源代码模块中存在 export default 的默认导出时,由于 CommonJs 并不区分默认导出和具名导出。
所以对于使用了 export default 的源码导出会对编译后的 commonJs 模块规范中对 exports 定义 __esModule 为 true 表示该模块是从 ES 模块转换而来。
__esModule属性就像是一个 “身份标识”,帮助 CommonJS 环境在处理这个转换后的模块时,能够正确识别并处理其特殊的导出结构。
当然需要留意的是 __esModule 通常仅提供给前端构建工具识别,比如在使用 Babel、Webpack 等构建工具时,__esModule 标识会经常被使用。这些工具会将 ES6 模块转换为 CommonJS 模块,并添加 __esModule 属性。
Node.js 本身的 CommonJS 模块规范并不会识别 __esModule 标识。Node.js 加载模块时,只是简单地读取 module.exports。
exports.default:跨模块导出的纽带
而 CorpDayjs 中的第二段代码:
exports.default = dayjs;
ES6 模块的默认导出(export default)是一种非常方便的导出方式,但 CommonJS 模块没有直接对应的概念。
通常为了在将 ES6 模块转换为 CommonJS 模块时能够兼容这种默认导出,构建工具会将 ES6 模块的默认导出内容赋值给 exports.default。
也就是对于代码中的 export default dayjs 在转换为 CommonJS 模块后,就变成了 exports.default = dayjs。
这样,在使用 CommonJS 方式导入这个模块时,就可以通过 exports.default 来获取 ES6 模块的默认导出内容。
exports.default 正是为了在将 ES6 模块转换为 CommonJS 模块时,兼容 ES6 模块的默认导出机制。
无论是 __esModule 属性还是 exports.default 它们都是为了在不同模块系统之间实现平滑过渡和兼容而产生的。
Rollup 与 TypeScript 的协作关系
TSC:ES Module 转换为 CommonJS 的机制解析
通常对于 TypeScript 代码,我们会通过 TypeScript Compiler 来进行编译。
TypeScript Compiler 在构建时存在两个参数 module、esModuleInterop ,这两个参数正是和上边讲到的 __esModule、exports.default 强相关。
| key | 概念 | 使用场景 |
|---|---|---|
| module | 指定 TypeScript 编译器生成的 JavaScript 代码中使用的模块系统 | 根据目标运行环境选择合适的模块系统。比如,在 Node.js 中通常使用 "commonjs",而在现代浏览器中可以使用 "esnext"。 |
| esModuleInterop | 启用对 import 和 require 之间更好的互操作性 | 当需要从 CommonJS 模块中导入默认导出时,启用该选项可以减少手动处理命名空间对象的麻烦。 |
比如对于上边 dayjs 的例子,TsCompiler 标记 module 为 commonjs 以及 esModuleInterop 为 false(或者默认未设置)时,编译后的结果为:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.localizedTemporal = void 0;
const corpDayjs_1 = require("corpDayjs");
function localizedTemporal() {
// do something
console.log((0, corpDayjs_1.default)().format('YYYY-MM-DD'));
}
exports.localizedTemporal = localizedTemporal;
exports.default = localizedTemporal;
TsCompiler 标记 module 为 commonjs 以及 esModuleInterop 为 true 时:
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.localizedTemporal = void 0;
const corpDayjs_1 = __importDefault(require("corpDayjs"));
function localizedTemporal() {
// do something
console.log((0, corpDayjs_1.default)().format('YYYY-MM-DD'));
}
exports.localizedTemporal = localizedTemporal;
exports.default = localizedTemporal;
可以看到在 TypeScript 中标记 module 为 commonjs 时 esModuleInterop 标记不同的值对于引入的 CorpDayjs 有不同的表现:
-
esModuleInterop 设置为 false 时:对于 ModuleTarget 为 CommonJs 的模块规范,模块内的所有默认引入编译后都会直接使用模块的 default 属性(无论原本 CorpDayjs 导出的模块中是否存在 default 属性)。
-
esModuleInterop 设置为 true 时: 对于 ModuleTarget 为 CommonJs 的模块规范,模块内的所有默认引入编译后首先会判断导出的模块是否存在 __esModule 属性,如果存在则直接使用模块导出对象,如果不存在则为引入的模块的默认导出(CorpDayjs) 额外包裹一层 default 属性。
在 TypeScript 中输出模块规范设置为 CommonJs 时,不同的 esModuleInterop 会产线不同的导入效果。
回归到问题本身,我们需要将 esModuleInterop 设置为 true 就可以解决上述 TypeError: dayjs is not a function 的问题了。
但实际并非如此。当我们在 CorpFeLibrary 的构建过程中,将 TypeScript Compiler module 设置为 commonjs 同时将 esModuleInterop 设置为 true 时,发现并不能解决这个问题。
协同共进:Rollup 与 TypeScript 在模块转换中的协作模式
CorpFeLibrary 是使用 TypeScript 配合 Rollup 进行的构建打包。
对于 CorpFeLibrary 的入口文件构建过程就像下面这张图:
-
首先,TypeScript Plugin 会将 index.ts 入口文件经过 TS Compiler 编译成为 index.js。
-
之后,Rollup 会将 TSC 编译后的 index.js 在进行处理输出最终的 index.js 文件。
这恰恰也就是为什么设置 TSC 中的 esModuleInterop 在 CorpFeLibrary 的构建过程中无效的原因。
Rollup 是一个 JavaScript 模块打包工具,主要设计用于处理 ES Module 格式的模块,默认情况下 Rollup 并不会识别 CommonJs 规范的导入导出。
自然,如果需要将 TSC 编译后的代码交给 Rollup 需要保证输出后的代码模块规范为 ESM 格式而非 CommonJs。
当我们设置 TSC CompilerOptions 中 module 为 esNext (表示输出模块规范为 ESM)时,esModuleInterop 并不会生效。
esModuleInterop 表示启用对 import 和 require 之间更好的互操作性,输出的模块规范为 ESM 情况下, ESNext 模块系统本身对于 ES 模块的导入导出支持非常好,当项目中只涉及纯 ES 模块的交互时,esModuleInterop 的作用就不太明显,类似于失效的效果。
也就是经过第一步:TypeScript Plugin 会将 index.ts 入口文件经过 TS Compiler 编译成为 index.js 的步骤会输出:
import dayjs from 'corpDayjs';
function localizedTemporal() {
// do something
console.log(dayjs().format('YYYY-MM-DD'));
}
export { localizedTemporal };
之后,实际会将上边这段 TS Compiler 输出后的结果交给 Rollup 再次进行构建,也就是再次执行上边的第二步。
再次经过 Rollup 构建后会获得最终的输出内容:
// commonjs 格式
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var dayjs = require('corpDayjs');
function localizedTemporal() {
// do something
console.log(dayjs().format('YYYY-MM-DD'));
}
exports.default = localizedTemporal;
exports.localizedTemporal = localizedTemporal;
此时,又再次回到了上边的问题。对于 TypeScript Compiler 编译后的 JavaScript 代码在交给 Rollup 去执行构建。
我们得到的仍然是
var dayjs = require('corpDayjs');
console.log(dayjs().format('YYYY-MM-DD'));
再次运行这段代码,我们仍然会得到 TypeError: dayjs is not a function。
Rollup:ES Module 转 CommonJS 的策略与实践
由于需要输出给 Rollup 的是 EsNext 模块规范,所以无论如何修改 TsConfig 中的 esModuleInterop 实际对于编译后的代码是无法产生变化。
回到原本的问题,我们希望在 Rollup 输出的 CommonJs 模块规范格式的代码中希望可以实现类似于 esModuleInterop 的作用。
简单说,就是对于 require('corpDayjs') 某个模块时,如果导入的 corpDayjs 模块存在 __esModule 表示时。
希望引入 corpDayjs 的模块可以通过 require('corpDayjs').default 来访问默认导出从而解决 TypeError: dayjs is not a function 这个问题。
在 Rollup 里,output 属性中有一个 interop 属性,它的作用正是在非 ESModule 格式输出中控制如何处理外部依赖的默认导入、命名空间导入以及动态导入。
在 Rollup 配置中,output.interop 属性提供了许多的设置选项,可以足够灵活应对不同的模块互操作性需求。
具体支持以下几种设置值:"compat"、"auto"、"esModule"、"default"、"defaultOnly",此外,还允许传入一个函数 (id: string) => "compat" | "auto" | "esModule" | "default" | "defaultOnly" 来自定义处理逻辑。
接下来,我们将通过实际示例深入探究这些设置各自的含义与应用场景,假设我们设置 input 输入文件的内容为下面的代码:
import corpDayjsDefault, * as corpDayjs from 'corpDayjs';
console.log(corpDayjsDefault, corpDayjs.bar, corpDayjs);
import('corpDayjs').then(console.log);
编译的 output.format 设置为 cjs 不同的 interop 属性会导致不同的编译结果:
default
设置为 default 时,输出为:
'use strict';
var corpDayjs = require('corpDayjs');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var corpDayjs__namespace = /*#__PURE__*/_interopNamespaceDefault(corpDayjs);
console.log(corpDayjs, corpDayjs__namespace.bar, corpDayjs__namespace);
import('corpDayjs').then(console.log);
default 模式模式下,表示 Rollup 会假设 require 得到的值是默认导出,对于 corpDayjs 的直接引入会假设 require 得到的值直接是默认导出不做任何处理。
同时会通过辅助函数 _interopNamespaceDefault 创建命名空间对象,对于处理 * as corpDayjs 等命名空间导入会额外进行一层 _interopNamespaceDefault 的包裹从而在非 ESM 规范中实现命名空间导入的方式。
这也是 rollup 的默认行为,所以这种情况下对于 require('corpDayjs') 由于会假设 require 得到的值是默认导出,但实际 corpDayjs 编译成为 CommonJs 后会将默认导出转化为 exports.default。
所以 Rollup 下默认的 output.interop: default 配置,我们会得到上边提到的 TypeError: corpDayjs is not a function。
esModule
当 output.interop 设置为 "esModule" 时,Rollup 会假定所引入的模块是经过转译处理后的 ES 模块。在
ES 模块的体系中,一个模块有其对应的命名空间(即模块中导出的所有内容构成的一个对象),而默认导出(default export)则作为这个导出对象的 .default 属性存在。
所以 output.interop.esModule 设置为 esModule 时,Rollup 会将所有的外部引入假定为是经过转译处理后的 ES 模块:
'use strict';
var corpDayjs = require('corpDayjs');
console.log(corpDayjs.default, corpDayjs.bar, corpDayjs);
import('corpDayjs').then(console.log);
对于 corpDayjs 的默认引入会使用 .default 来访问,同时对于命名空间的方式会直接访问其属性。
将 output.interop 设置为 "esModule" 时,是可以解决我们上边的问题。不过他会假定所有外部依赖均为经过转译处理后的 ES 模块,这显然是不够人性化的。
auto
"auto" 模式是 esModule 和 default 两种模式的结合。
它通过注入一些辅助函数,在运行时检测导入模块的值是否包含 __esModule 属性从而决定如何访问引入的模块。
__esModule 属性是正是我们上述讲过 TypeScript 的 esModuleInterop、Babel 等工具所采用的一种技巧,用于表明该值是经过转译后的 ES 模块的命名空间。
比如上边的代码设置 output.interop: 'auto' 在编译之后:
'use strict';
var corpDayjs = require('corpDayjs');
function _interopNamespace(e) {
if (e && e.__esModule) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var corpDayjs__namespace = /*#__PURE__*/_interopNamespace(corpDayjs);
console.log(corpDayjs__namespace.default, corpDayjs__namespace.bar, corpDayjs__namespace);
import('corpDayjs').then(console.log);
对于编译后的外部模块引入 Rollup 会额外使用 _interopNamespace 工具方法来优先处理导入的模块。
_interopNamespace 辅助函数中的内容大致如下:
-
if (e && e.__esModule) return e;如果导入的模块对象 e 存在且包含 __esModule 属性,说明它是一个转译后的 ES 模块,直接返回该对象。 -
var n = Object.create(null);创建一个新的空对象 n,用于作为命名空间对象。 -
遍历 e 的所有属性(除了 default),将这些属性复制到 n 中。如果属性有 getter 方法,则直接复制;否则,为 n 的对应属性定义一个 getter 方法,该方法返回 e 中对应属性的值。
-
n.default = e;将原始的导入对象 e 作为 n 的 default 属性。 -
return Object.freeze(n)冻结命名空间对象 n,防止其属性被修改。
使用 _interopNamespace 后的模块:
-
var external__namespace = /*#__PURE__*/ _interopNamespace(corpDayjs)调用 _interopNamespace 函数处理require ('external1')的结果,得到命名空间对象 corpDayjs__namespace -
console.log(...)打印命名空间对象的默认导出、命名导出 bar 以及整个命名空间对象。
auto 模式会通过 _interopNamespace 检测使用 require 导入的函数是否存在 __esModule 属性。
如果存在,则表示导入的模块是 ESModule 编译后的模块系统,直接返回。
如果不存在该属属性,则使用后续的操作包裹 default 等属性。
从而实现在访问时均可以通过 default 属性来实现非 ESM 模块的默认导出行为。
实际 Rollup 设置 output.interop: 'auto' 就类似于 TsCompiler 中设置 esModuleInterop: true 的效果,这也是我们解决上边 TypeError 的最佳方式。
compat
compat 模式类似 auto,但检查的方式是通过是导入模块 default 属性而非 __esModule 属性,使用 _interopNamespaceCompat 辅助函数。
经过 compat 设置后的代码最终输出为:
'use strict';
var corpDayjs = require('corpDayjs');
function _interopNamespaceCompat(e) {
if (e && typeof e === 'object' && 'default' in e) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var corpDayjs__namespace = /*#__PURE__*/_interopNamespaceCompat(corpDayjs);
console.log(corpDayjs__namespace.default, corpDayjs__namespace.bar, corpDayjs__namespace);
import('corpDayjs').then(console.log);
defaultOnly
当 output.interop 属性设置为 "defaultOnly" 时,它与 "default" 模式有相似之处,但也存在一些细微的不同。
"defaultOnly" 模式下会禁止命名导入,命名导入是指从模块中导入特定的具名导出,例如 import { format } from 'CorpDayjs';。
一旦 Rollup 在打包过程中遇到命名导入,无论目标格式是 ES 模块(es)还是 SystemJS 模块(system),都会抛出错误。
这样做的目的是确保 ES 版本的代码能够在 Node 环境中正确导入非内置的 CommonJS 模块。因为在 ES 模块规范和 CommonJS 模块规范之间存在一些差异,禁止命名导入可以避免一些潜在的兼容性问题。
上面的代码设置为 defaultOnly 输出后的结果为:
'use strict';
var corpDayjs = require('corpDayjs');
function _interopNamespaceDefaultOnly (e) { return Object.freeze({ __proto__: null, default: e }); }
var corpDayjs__namespace = /*#__PURE__*/_interopNamespaceDefaultOnly(corpDayjs);
console.log(corpDayjs, corpDayjs__namespace.bar, corpDayjs__namespace);
import('corpDayjs').then(console.log);
经过 defaultOnly 编译输出后的导入模块会被 _interopNamespaceDefaultOnly 函数优先处理从而确保导入的模块仅存在一层 default 属性表示默认导出。
回归问题本质:剖析 TypeError: dayjs is not a function
在业务实践中,一个看似简单的 TypeError 错误,牵出了 __esModule 与 default 属性背后复杂的机制和问题根源。深入探究这些属性的来源,是解决问题的关键所在。
__esModule 和 default 属性的出现,本质上是为了在非 ESM 模块规范的构建产物中,模拟 ESM 模块多样化的导出方式。
这一机制虽然在一定程度上实现了不同模块系统之间的兼容性,但也增加了开发者理解和处理代码的难度。
在明确了问题产生的原因后,解决问题的思路也逐渐清晰。 以 Rollup 构建过程为例,我们可以通过显式设置 interop 属性来实现预期的效果。然而,不同的构建工具在处理模块互操作性时,其默认方式存在差异。例如,TypeScript 的 esModuleInterop 与 Rollup 的 interop 虽然都旨在解决模块互操作性问题,但具体含义和实现细节有所不同。
为了避免此类问题在日常业务中频繁出现,我们建议采用统一的导出方式,即全部使用具名导出。以 CorpDayjs 为例,推荐的做法是摒弃默认导出,直接采用具名导出:
// 源代码中的导出
export {
dayjs
}
采用具名导出的方式,无论是 ESM 规范还是 CommonJS 规范的构建产物,在常见的构建工具编译后都能实现开箱即用的引用,无需额外的处理成本。这不仅降低了开发者的理解负担,还能有效减少类似问题的发生,提高代码的可维护性和稳定性。