为何Node.js从ES模块导入时会预计算所有导出成员?
问题背景
我维护着一个超大型CommonJS JavaScript包,包内拆分为多个子模块,通过顶层index.js统一导出,TypeScript里的写法如下:
export * as submodule1 from './submodule1'; export * as submodule2 from './submodule2'; // 更多子模块...
由于库体积过大,当前的导出方式会导致所有子模块在加载时被require,不管用户是否用到全部子模块,造成明显的性能损耗。
CommonJS环境的懒加载方案
因为确认库加载时无副作用,我把导出逻辑改成了通过Object.defineProperty延迟加载,只有当用户实际访问子模块符号时,才会加载对应的文件及其依赖:
Object.defineProperty(exports, 'submodule1', { get: () => require('./submodule1'), });
这个方案在CommonJS环境里工作正常,但到了ESM环境完全失效。
ESM环境的预加载现象验证
我做了个小实验验证问题:
测试代码
node_modules/lazylib/index.js(CommonJS模块):
// 让CJS导出能被ESM识别的技巧 exports.someSymbol = void 0; Object.defineProperty(exports, 's' + 'omeSymbol', { get: () => { console.log('evaluated'); return 42; }, });
test.js(CommonJS测试文件):
const ll = require('lazylib')
test.mjs(ESM测试文件):
import * as ll from 'lazylib';
运行结果
$ node test.js # 无输出,符合预期 $ node test.mjs evaluated # <--- 不符合预期的预加载触发
现象原因
Node.js在处理ESM导入CommonJS模块时,会执行模块命名空间对象的扁平化处理:为了让ESM能正确解析CommonJS模块的导出,Node.js会在加载阶段遍历CommonJS模块exports对象的所有可枚举属性,并且立即调用这些属性的getter来获取值,以此构建符合ESM规范的命名空间对象。这直接导致所有通过Object.defineProperty定义的懒加载getter在模块加载时就被触发,懒加载完全失效。
可行解决方案
1. 导出工厂函数
将子模块的获取改为通过主动调用函数触发,避免顶层导出的getter被提前执行:
// 库的index.js exports.getSubmodule1 = () => require('./submodule1'); exports.getSubmodule2 = () => require('./submodule2');
用户使用时需要主动调用函数获取子模块:
// ESM环境中 import * as lib from 'your-lib'; const submodule1 = lib.getSubmodule1(); // 同步加载,或改为异步根据需求调整
2. 为ESM单独提供入口(推荐)
在package.json中通过"exports"字段区分CJS和ESM入口,为ESM环境单独实现懒加载:
// package.json { "exports": { ".": { "require": "./index.js", "import": "./index.mjs" } } }
然后在ESM入口里使用动态导入实现懒加载:
// index.mjs export async function getSubmodule1() { return await import('./submodule1.mjs'); } // 或者用Proxy实现属性访问时自动加载 export const submodule1 = new Proxy({}, { async get(target, prop) { const mod = await import('./submodule1.mjs'); return mod[prop]; } });
3. 嵌套导出避免扁平化
把所有子模块放在一个嵌套对象里,Node.js只会扁平化顶层的导出属性,不会递归触发内部的getter:
// 库的index.js exports.modules = { get submodule1() { return require('./submodule1'); }, get submodule2() { return require('./submodule2'); } };
用户使用时通过lib.modules.submodule1访问子模块,此时只有当实际访问时才会触发加载。
内容的提问来源于stack exchange,提问作者rix0rrr

