You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何Node.js从ES模块导入时会预计算所有导出成员?

CommonJS包在ESM环境下懒加载失效的问题与解决办法

问题背景

我维护着一个超大型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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 20:19:51