cjs 和 esm 的差异总结&最佳实践

0 阅读14分钟

模块简介

CommonJs

CommonJS 模块是 Node.js 中打包 JavaScript 代码的原始方式。

模块局部变量将是私有的,因为 Node.js 将模块包装在一个函数中(参见模块包装器)。

nodejs.org/docs/latest…

node v20.17.0 文档中添加了用 require 导入 esm 模块的描述,仅支持加载满足以下要求的 ECMAScript 模块:

  • 模块是完全同步的(不包含顶层 await )
  • 并且满足以下条件之一:
    • 该文件有 .mjs 扩展名。
    • 该文件具有 .js 扩展名,最近的 package.json 包含 "type": "module"
    • 该文件具有 .js 扩展名,最近的 package.json 不包含 "type": "commonjs" ,且该模块包含 ES 模块语法。

如果加载的 ES 模块满足要求, require() 可以加载它并返回模块命名空间对象。

如果返回的命名空间有 default 导出,则会包含一个 __esModule: true 属性,以便由工具生成的消费代码能够识别真实 ES Modules 中的默认导出。

nodejs.org/docs/latest…

ECMAScript modules

ECMAScript modules 是官方标准格式,用于打包可重用的 JavaScript 代码。

nodejs.org/docs/latest…

一个 import 语句可以引用一个 ES 模块或一个 CommonJS 模块。 import 语句仅允许在 ES 模块中使用,但在 CommonJS 中支持动态的 import() 表达式来加载 ES 模块。

当从 ECMAScript modules 导入 CommonJS 时,会构建一个 CommonJS 模块的命名空间包装器,该包装器始终提供一个 default 导出键,指向 CommonJS 的 module.exports 值。

为了与 JS 生态系统中的现有用法更好地兼容,Node.js 还会尝试通过静态分析过程确定每个导入的 CommonJS 模块的 CommonJS 命名导出,并将它们作为单独的 ES 模块导出。 对于这些命名导出, module.exports 中新增的导出或实时绑定的更新无法被检测到。

nodejs.org/docs/latest…

CJS 和 ESM 差异

绑定方式

  • CJS:值拷贝
  • ESM:值引用

CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。请看下面这个模块文件lib.js的例子。

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。然后,在main.js里面加载这个模块。

// main.js
var mod = require('./lib');

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

上面代码说明,lib.js模块加载以后,它的内部变化就影响不到输出的mod.counter了。这是因为mod.counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

还是举上面的例子。

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

上面代码说明,ES6 模块输入的变量counter是活的,完全反应其所在模块lib.js内部的变化。

Module 的加载实现 - ECMAScript 6入门

加载方式

  • CJS:动态加载,运行时解析
  • ESM:静态加载,编译时解析

什么是静态/编译时解析?

很多文章在说到 CJS 和 ESM 的差异时,都会提到 ESM 是静态/编译时解析,但都是一笔带过,或者跟 Tree Shaking 绑定。那么到底什么是静态解析?

首先我认为静态解析应该分两部分讨论:

  1. js 引擎静态解析
  2. 打包工具静态解析
JS引擎静态解析

JavaScriptJS)是一种具有函数优先特性的轻量级、解释型或者说即时编译型的编程语言。

developer.mozilla.org/zh-CN/docs/…

JIT即时编译,Just-In-Time Compilation)是一种编译过程,代码会在运行时(而不是执行前)从中间表示或高级语言(如 JavaScript、Java 字节码)翻译为机器码。这种方式结合了解释执行和预先编译(AOT)的优点。

JIT 编译器通常会在代码执行时持续分析代码,识别出被频繁执行的部分(热点)。如果加速带来的收益大于编译的开销,JIT 编译器就会将这些部分编译为机器码。编译后的代码由处理器直接执行,从而显著提升性能。

JIT 在现代 Web 浏览器中被广泛用于优化 JavaScript 代码的性能。

developer.mozilla.org/zh-CN/docs/…

详细的 JS 引擎处理流程这里就不说了,主要步骤有以下几个:

  • JS 源码经过解析器解析成 AST
  • AST 被解释器转换成字节码
  • 执行字节码
  • 优化 → 生成机器码
  • 去优化 → 回退为字节码

ES modules: A cartoon deep-dive - Mozilla Hacks - the Web developer blog这篇文章对 ESM 的静态解析写得很详细,我只挑一些主要流程讲。

ESM 的解析分为 3 个步骤:

  1. Construction — find, download, and parse all of the files into module records.
    构建阶段——查找、下载并解析所有文件到模块记录中。

  2. Instantiation —find boxes in memory to place all of the exported values in (but don’t fill them in with values yet). Then make both exports and imports point to those boxes in memory. This is called linking.
    实例化阶段——在内存中找到地址来放置所有导出的值(但此时不填充这些值)。然后让导出和导入都指向内存中的这些地址。这被称为链接。

  3. Evaluation —run the code to fill in the boxes with the variables’ actual values.
    运行—运行代码以用变量的实际值填充地址。

image.png

构建

  1. 查找模块并加载

从入口文件开始,逐层遍历依赖树,确定依赖模块,并加载文件。

image 1.png

  1. 解析模块生成模块记录(module record)

文件加载完成后,解析生成模块记录。

image 2.png

一旦模块记录被创建,它就会被放置在模块映射中。这意味着从现在开始,每当它被请求时,加载器都可以从该映射中获取它。

image 3.png

实例化

首先,JS 引擎创建一个模块环境记录,管理模块记录的变量。然后它为所有的导出项在内存中找到地址。模块环境记录会跟踪内存中的哪个地址与每个导出项相关联。

这些内存中的地址还没有它们的值。只有经过运行后,它们的实际值才会被填充。这个规则有一个例外:任何导出的函数声明在这个阶段会被初始化。这使得运行过程更加容易。

为了实例化模块图,引擎将执行一种称为深度优先后序遍历的操作。这意味着它会深入到图的底部——到达那些不依赖于其他任何东西的底层依赖——并设置它们的导出内容。

image 4.png

引擎完成连接模块下所有的导出——所有该模块所依赖的导出。然后它回到上一级来连接该模块的导入。

导出和导入都指向内存中的同一位置。先连接导出可以确保所有的导入都能连接到匹配的导出。(这也是能够导出值能引用传递的原因)

image 5.png

运行

JS 代码实际运行时,将导出数据填充到内存中对应的地址。

总结

在 JS 引擎中,ESM 的静态解析就是下载 JS 文件 → 解析生成 AST → 解析生成模块记录,并实例化 → 下载子模块文件…这么个流程(具体哪些阶段是否并行没有细究)

打包工具静态解析

打包工具的静态解析主要流程如下:

  • JS 源码解析成 AST
  • 遍历 AST,递归解析依赖模块,构建完整的模块依赖图
  • 基于依赖图进行高级优化(Tree Shaking、作用域提升等)

这里的模块依赖图和 JS 引擎中的模块记录不是一个层面的东西,是打包工具自己实现的,用于记录模块依赖关系的数据结构,不需要有上述的实例化链接和填值的流程。大多文章提到的静态解析,应该是指打包工具的静态解析。

Tree Shaking

  • CJS:不支持
  • ESM:支持

CJS 不支持 Tree Shaking 的原因也是因为无法静态解析,不能构建完整的模块依赖图。

循环依赖

  • CJS:

当存在循环的 require() 调用时,模块可能在返回时尚未执行完成。

考虑这种情况:

// a.js
console.log('a starting');
exports.done= false;
const b= require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done= true;
console.log('a done');
// b.js
console.log('b starting');
exports.done= false;
const a= require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done= true;
console.log('b done');
// main.js
console.log('main starting');
const a= require('./a.js');
const b= require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

这个程序的输出将会是:

$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

当 a.js 执行遇到 require 时,跳转到 b.js 继续执行,同时缓存当前的位置。当 b.js 执行遇到 require 时,回到 a.js 执行,但不是从头开始,而是从前面缓存的位置继续执行,从而避免了无限循环。

  • ESM:

正如前面静态解析所说,ESM 会先构建&实例化模块记录。循环引用具体的执行顺序没查到具体说明,推测是用 Set 类似的结构,记录当前模块不再有依赖模块或 Set 中已有重复模块,然后按照深度优先后序遍历执行模块。

// main.js
import { aValue, updateAValue } from './moduleA.js';

console.log('【main】开始执行');
console.log('【main】导入的 aValue =', aValue);
updateAValue('被main修改后的值');
console.log('【main】修改后,再次获取 aValue =', aValue);
// moduleA.js
import { bValue, logBValue } from './moduleB.js';

console.log('【moduleA】开始执行');
export let aValue = 'A的初始值';
export function updateAValue(newVal: string) {
  aValue = newVal;
  console.log('【moduleA】函数 updateAValue 被调用,aValue 改为:', aValue);
}

console.log('【moduleA】导入的 bValue =', bValue);
logBValue(); // 调用 moduleB 的函数
// moduleB.js
import { aValue } from './moduleA.js';

console.log('【moduleB】开始执行');
export let bValue = 'B的初始值';
export function logBValue() {
  console.log('【moduleB】函数 logBValue 被调用,此时内部的 aValue =', aValue);
}

console.log('【moduleB】导入的 aValue =', aValue);
bValue = 'B在模块内被修改了';

这个程序的输出将会是:

【moduleB】开始执行
【moduleB】导入的 aValue = undefined
【moduleA】开始执行
【moduleA】导入的 bValue = B在模块内被修改了
【moduleB】函数 logBValue 被调用,此时内部的 aValue = A的初始值
【main】开始执行
【main】导入的 aValue = A的初始值
【moduleA】函数 updateAValue 被调用,aValue 改为: 被main修改后的值
【main】修改后,再次获取 aValue = 被main修改后的值

this 指向

  • CJS:this 指向当前模块
  • ESM:this 指向 undefined

如何识别文件导出格式?

首先应该明确如何识别 cjs 和 esm?

默认情况下,Node.js 会将以下内容视为 CommonJS 模块:

  • 具有 .cjs 扩展名的文件
  • 具有 .js 扩展名或没有扩展名的文件,当最近的父级 package.json 文件包含一个值为 "commonjs" 的字段 "type" 时。
  • 具有 .js 扩展名或没有扩展名的文件,当最近的父级 package.json 文件不包含顶层字段 "type" 或任何父级文件夹中都没有 package.json ;除非该文件包含只有在被评估为 ES 模块时才会报错的语法。 nodejs.org/docs/latest…

最后一点有点绕,问了 AI,意思就是如果没有声明 type,并且文件用了 esm 语法,就会被当成 esm,否则视作 cjs。

打包工具差异

webpack

"type": "module",
"devDependencies": {
	"webpack": "^5.106.2",
	"webpack-cli": "^5.1.4"
}

默认配置

webpack 默认配置下会打包为 IIFE,入口文件导出的内容也会被去除,不方便对比,这里改成打包library。(这里要吐槽下,这么多年了,module 竟然还是实验特性)

// webpack.config.js
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  mode: 'production',
  optimization: {
    minimize: false,
  },
  entry: './index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'lib.js',
    clean: true,
    library: {
      type: 'module',
    },
  },
  experiments: {
    outputModule: true,
  },
  plugins: [],
  module: {},
};
// foo.js
export default 'hello foo!';
export const foo = 'foo';

// bar.js
exports.default = 'hello bar!';
exports.bar = 'bar';

// index.js
import fooDefault, { foo } from './foo.js';
import barDefault, { bar } from './bar.js';

export default function () {
  console.log(fooDefault, foo, bar, barDefault);
}

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

用 bar.js 模拟 cjs 和 esm 混用的效果

输出

ERROR in ./index.js 5:31-34
export 'bar' (imported as 'bar') was not found in './bar.js' (module has no exports)

ERROR in ./index.js 5:36-46
export 'default' (imported as 'barDefault') was not found in './bar.js' (module has no exports)

这里报错是因为bar.js 被当成 esm 解析,但是文件内的 cjs 的导出方式不能识别成 esm 的导出方式,所以解析错误。

尝试一:命名空间导入

import fooDefault, { foo } from './foo.js';
import * as barDefault from './bar.js';

const { bar } = barDefault;

export default function () {
  console.log(fooDefault, foo, bar, barDefault);
}

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

输出

/******/ // The require scope
/******/ var __webpack_require__ = {};
/******/ 
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ 	// define getter functions for harmony exports
/******/ 	__webpack_require__.d = (exports, definition) => {
/******/ 		for(var key in definition) {
/******/ 			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 			}
/******/ 		}
/******/ 	};
/******/ })();
/******/ 
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ 	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/ 
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ 	// define __esModule on exports
/******/ 	__webpack_require__.r = (exports) => {
/******/ 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 		}
/******/ 		Object.defineProperty(exports, '__esModule', { value: true });
/******/ 	};
/******/ })();
/******/ 
/************************************************************************/

// NAMESPACE OBJECT: ./bar.js
var bar_namespaceObject = {};
__webpack_require__.r(bar_namespaceObject);

;// ./foo.js
/* harmony default export */ const foo = ('hello foo!');
const foo_foo = 'foo';

;// ./bar.js
exports.default = 'hello bar!';
exports.bar = 'bar';

;// ./index.js

const { bar } = bar_namespaceObject;

function func() {
  console.log(foo, foo_foo, bar, bar_namespaceObject);
}

/* harmony default export */ const index = (func);

const a = 1;
const b = 2;

export { a, b, bar, bar_namespaceObject as barDefault, index as default, foo_foo as foo, foo as fooDefault };

输出结果保留了 exports,直接在浏览器导入会报错。

image 6.png

尝试二:将文件解析为 cjs

bar.js 文件名改成bar.cjs

/******/ var __webpack_modules__ = ({

/***/ 776
(__unused_webpack_module, exports) {

exports["default"] = 'hello bar!';
exports.bar = 'bar';

/***/ }

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/ 
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ 	// Check if module is in cache
/******/ 	var cachedModule = __webpack_module_cache__[moduleId];
/******/ 	if (cachedModule !== undefined) {
/******/ 		return cachedModule.exports;
/******/ 	}
/******/ 	// Create a new module (and put it into the cache)
/******/ 	var module = __webpack_module_cache__[moduleId] = {
/******/ 		// no module.id needed
/******/ 		// no module.loaded needed
/******/ 		exports: {}
/******/ 	};
/******/ 
/******/ 	// Execute the module function
/******/ 	__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 
/******/ 	// Return the exports of the module
/******/ 	return module.exports;
/******/ }
/******/ 
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ 	// define getter functions for harmony exports
/******/ 	__webpack_require__.d = (exports, definition) => {
/******/ 		for(var key in definition) {
/******/ 			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 			}
/******/ 		}
/******/ 	};
/******/ })();
/******/ 
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ 	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/ 
/************************************************************************/

;// ./foo.js
/* harmony default export */ const foo = ('hello foo!');
const foo_foo = 'foo';

// EXTERNAL MODULE: ./bar.cjs
var bar = __webpack_require__(776);
;// ./index.js

function func() {
  console.log(foo, foo_foo, bar.bar, bar);
}

/* harmony default export */ const index = (func);

const a = 1;
const b = 2;

const __webpack_exports__bar = bar.bar;
export { a, b, __webpack_exports__bar as bar, bar as barDefault, index as default, foo_foo as foo, foo as fooDefault };

可以看到,即使是使用 esm 语法导入,webpack 仍然使用了__webpack_require__ 来处理 cjs 文件。barDefault 的值为{default: 'hello bar!', bar: 'bar'} ,要想取到exports.default ,只能通过barDefault.default

尝试三:去除type

去除package.json"type": "module" 。输出跟改后缀名一样,说明即使把文件通过 cjs 的方式导出,webpack 仍能解析 import 导入。

顺便也尝试了一下将package.json 改成"type": "commonjs" 。结果报错,也印证了 type 缺省时,默认值和 commonjs 不一致,能够自动识别文件解析类型。

ERROR in ./index.js 1:0
Module parse failed: 'import' and 'export' may appear only with 'sourceType: module' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> import fooDefault, { foo } from './foo.js';
| import barDefault, { bar } from './bar.js';

尝试四:通过require导入

如果不方便改package.json ,同时 cjs 文件是第三方依赖,不方便修改后缀,怎么处理?尝试通过 require 导入bar.js

import fooDefault, { foo } from './foo.js';

const barDefault = require('./bar.js');
const { bar } = barDefault;

function func() {
  console.log(fooDefault, foo, bar, barDefault);
}

export default func;

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

输出

/******/ // The require scope
/******/ var __webpack_require__ = {};
/******/ 
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ 	// define getter functions for harmony exports
/******/ 	__webpack_require__.d = (exports, definition) => {
/******/ 		for(var key in definition) {
/******/ 			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 			}
/******/ 		}
/******/ 	};
/******/ })();
/******/ 
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ 	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/ 
/************************************************************************/

;// ./foo.js
/* harmony default export */ const foo = ('hello foo!');
const foo_foo = 'foo';

;// ./index.js

const barDefault = require('./bar.js');
const { bar } = barDefault;

function func() {
  console.log(foo, foo_foo, bar, barDefault);
}

/* harmony default export */ const index = (func);

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo_foo as foo, foo as fooDefault };

如果直接通过 require 导入,webpack 是不会对 require 做转换的,如果直接运行在浏览器则会报错。

image 7.png

尝试五:修改 webpack 解析类型为 javascript/auto

这个是 ai 给的处理方案。仍然通过 require 导入bar.js ,但将导出和导入文件的解析方式改为javascript/auto

// webpack.config.js
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  mode: 'production',
  optimization: {
    minimize: false,
  },
  entry: './index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'lib.js',
    clean: true,
    library: {
      type: 'module',
    },
  },
  experiments: {
    outputModule: true,
  },
  plugins: [],
  module: {
    rules: [
      {
        test: /(bar|index)\.js$/,
        type: 'javascript/auto',
      },
    ],
  },
};

输出

/******/ var __webpack_modules__ = ({

/***/ 948
(__unused_webpack_module, exports) {

exports["default"] = 'hello bar!';
exports.bar = 'bar';

/***/ }

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/ 
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ 	// Check if module is in cache
/******/ 	var cachedModule = __webpack_module_cache__[moduleId];
/******/ 	if (cachedModule !== undefined) {
/******/ 		return cachedModule.exports;
/******/ 	}
/******/ 	// Create a new module (and put it into the cache)
/******/ 	var module = __webpack_module_cache__[moduleId] = {
/******/ 		// no module.id needed
/******/ 		// no module.loaded needed
/******/ 		exports: {}
/******/ 	};
/******/ 
/******/ 	// Execute the module function
/******/ 	__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 
/******/ 	// Return the exports of the module
/******/ 	return module.exports;
/******/ }
/******/ 
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ 	// define getter functions for harmony exports
/******/ 	__webpack_require__.d = (exports, definition) => {
/******/ 		for(var key in definition) {
/******/ 			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 			}
/******/ 		}
/******/ 	};
/******/ })();
/******/ 
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ 	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/ 
/************************************************************************/

;// ./foo.js
/* harmony default export */ const foo = ('hello foo!');
const foo_foo = 'foo';

;// ./index.js

const barDefault = __webpack_require__(948);
const { bar } = barDefault;

function func() {
  console.log(foo, foo_foo, bar, barDefault);
}

/* harmony default export */ const index = (func);

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo_foo as foo, foo as fooDefault };

在这种方式下,使用 import 导入,打包输出结果也是和上面一样的。

尝试六:使用createRequire

node(v12.2.0)文档中提到:如果需要,可以在 ES 模块中使用 module.createRequire() 构建一个 require 函数。

import fooDefault, { foo } from './foo.js';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

const barDefault = require('./bar.js');
const { bar } = barDefault;

function func() {
  console.log(fooDefault, foo, bar, barDefault);
}

export default func;

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

直接打包会报错,因为 webpack 默认 target 是 web,不包含 node 内置依赖。

ERROR in node:module
Module build failed: UnhandledSchemeError: Reading from "node:module" is not handled by plugins (Unhandled scheme).
Webpack supports "data:" and "file:" URIs by default.
You may need an additional plugin to handle "node:" URIs.

将 webpack 的 target 改为 node。

/******/ var __webpack_modules__ = ({

/***/ 689
(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {

__webpack_require__.r(__webpack_exports__);
exports.default = 'hello bar!';
exports.bar = 'bar';

/***/ }

/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/ 
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ 	// Check if module is in cache
/******/ 	var cachedModule = __webpack_module_cache__[moduleId];
/******/ 	if (cachedModule !== undefined) {
/******/ 		return cachedModule.exports;
/******/ 	}
/******/ 	// Create a new module (and put it into the cache)
/******/ 	var module = __webpack_module_cache__[moduleId] = {
/******/ 		// no module.id needed
/******/ 		// no module.loaded needed
/******/ 		exports: {}
/******/ 	};
/******/ 
/******/ 	// Execute the module function
/******/ 	__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 
/******/ 	// Return the exports of the module
/******/ 	return module.exports;
/******/ }
/******/ 
/************************************************************************/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ 	// define getter functions for harmony exports
/******/ 	__webpack_require__.d = (exports, definition) => {
/******/ 		for(var key in definition) {
/******/ 			if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 				Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 			}
/******/ 		}
/******/ 	};
/******/ })();
/******/ 
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ 	__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/ 
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ 	// define __esModule on exports
/******/ 	__webpack_require__.r = (exports) => {
/******/ 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 		}
/******/ 		Object.defineProperty(exports, '__esModule', { value: true });
/******/ 	};
/******/ })();
/******/ 
/************************************************************************/

;// ./foo.js
/* harmony default export */ const foo = ('hello foo!');
const foo_foo = 'foo';

;// ./index.js

const index_require = /* createRequire() */ undefined;

const barDefault = __webpack_require__(689);
const { bar } = barDefault;

function func() {
  console.log(foo, foo_foo, bar, barDefault);
}

/* harmony default export */ const index = (func);

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo_foo as foo, foo as fooDefault };

打包结果包含 exports,只适用于 node,无法在浏览器使用。

总结

  1. cjs 和 esm 格式混用时,webpack 默认打包结果是无法转换 require 或 exports 的,需要通过文件后缀名、module 解析类型等方法,使 cjs 专用语法转换为通用语法,不然无法在浏览器正常运行。
  2. 一般情况下,package.json 中不声明 type,webpack 能自动识别文件解析类型,能减少一些配置工作,不懂的情况下不建议声明。
  3. 通过 require 导入 esm 模块时,得到的导入对象包含所有导出属性,如果要获取export default ,需要取 default 属性。
const fooDefault = require('./foo.js');

// export default
fooDefault.default
  1. 通过 import 导入 cjs 模块时,如果 exports.__esModule 为 true,则默认导入为exports.default 。否则,默认导入包含所有导出属性,如果要获取exports.default ,需要取 default 属性。
import barDefault from './bar.js';

// exports.default
barDefault.default

rollup

默认配置

// rollup.config.js
export default {
  input: './index.js',
  output: {
    file: './dist/lib.js',
    format: 'es',
  },
  plugins: [],
};
// foo.js
export default 'hello foo!';
export const foo = 'foo';

// bar.js
exports.default = 'hello bar!';
exports.bar = 'bar';

// index.js
import fooDefault, { foo } from './foo.js';
import barDefault, { bar } from './bar.js';

export default function () {
  console.log(fooDefault, foo, bar, barDefault);
}

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

输出

./index.js → ./dist/lib.js...
[!] RollupError: index.js (2:21): "bar" is not exported by "bar.js", imported by "index.js".
https://rollupjs.org/troubleshooting/#error-name-is-not-exported-by-module
index.js (2:21)
1: import fooDefault, { foo } from './foo.js';
2: import barDefault, { bar } from './bar.js';

这里报错是因为bar.js 被当成 esm 解析,但是文件内的 cjs 的导出方式不能识别成 esm 的导出方式,所以解析错误。

尝试一:命名空间导入

import fooDefault, { foo } from './foo.js';
import * as barDefault from './bar.js';

const { bar } = barDefault;

export default function () {
  console.log(fooDefault, foo, bar, barDefault);
}

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

输出

var fooDefault = 'hello foo!';
const foo = 'foo';

exports.default = 'hello bar!';
exports.bar = 'bar';

var barDefault = /*#__PURE__*/Object.freeze({
  __proto__: null
});

const { bar } = barDefault;

function index () {
  console.log(fooDefault, foo, bar, barDefault);
}

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo, fooDefault };

输出结果保留了 exports,直接在浏览器导入会报错。

image 8.png

尝试二:将文件解析为 cjs

bar.js 文件名改成bar.cjs

var fooDefault = 'hello foo!';
const foo = 'foo';

exports.default = 'hello bar!';
exports.bar = 'bar';

var barDefault = /*#__PURE__*/Object.freeze({
  __proto__: null
});

const { bar } = barDefault;

function index () {
  console.log(fooDefault, foo, bar, barDefault);
}

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo, fooDefault };

输出结果还是保留了 exports,rollup 无法针对 cjs 进行转换。

尝试三:去除type

去除package.json"type": "module" 。输出跟改后缀名一样,无法针对 cjs 进行转换。

尝试四:通过require导入

import fooDefault, { foo } from './foo.js';

const barDefault = require('./bar.js');
const { bar } = barDefault;

function func() {
  console.log(fooDefault, foo, bar, barDefault);
}

export default func;

export const a = 1;
const b = 2;
export { b };
export { fooDefault, foo, bar, barDefault };

输出

var fooDefault = 'hello foo!';
const foo = 'foo';

const barDefault = require('./bar.cjs');
const { bar } = barDefault;

function func() {
  console.log(fooDefault, foo, bar, barDefault);
}

const a = 1;
const b = 2;

export { a, b, bar, barDefault, func as default, foo, fooDefault };

输出结果保留了 require,如果直接运行在浏览器则会报错。

image 7.png

尝试五:使用@rollup/plugin-commonjs插件

// rollup.config.js
import commonjs from '@rollup/plugin-commonjs';

export default {
  input: './index.js',
  output: {
    file: './dist/lib.js',
    format: 'es',
  },
  plugins: [commonjs()],
};

输出

var fooDefault = 'hello foo!';
const foo = 'foo';

const barDefault = require('./bar.js');
const { bar } = barDefault;

function func() {
  console.log(fooDefault, foo, bar, barDefault);
}

const a = 1;
const b = 2;

export { a, b, bar, barDefault, func as default, foo, fooDefault };

使用 require 导入时,即使使用了 @rollup/plugin-commonjs 插件,仍然会保留 require。

尝试使用 import 导入。

输出

var fooDefault = 'hello foo!';
const foo = 'foo';

function getDefaultExportFromCjs (x) {
	return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}

var bar = {};

var hasRequiredBar;

function requireBar () {
	if (hasRequiredBar) return bar;
	hasRequiredBar = 1;
	bar.default = 'hello bar!';
	bar.bar = 'bar';
	return bar;
}

var barExports = requireBar();
var barDefault = /*@__PURE__*/getDefaultExportFromCjs(barExports);

function index () {
  console.log(fooDefault, foo, barExports.bar, barDefault);
}

const a = 1;
const b = 2;

var bar$1 = barExports.bar;
export { a, b, bar$1 as bar, barDefault, index as default, foo, fooDefault };

@rollup/plugin-commonjs 默认只能转换 import 导入的 cjs 模块。

要使用 require 导入,需要配置 transformMixedEsModules。

  plugins: [
    commonjs({
      transformMixedEsModules: true,
    }),
  ]

输出

var bar$1 = {};

var hasRequiredBar;

function requireBar () {
	if (hasRequiredBar) return bar$1;
	hasRequiredBar = 1;
	bar$1.default = 'hello bar!';
	bar$1.bar = 'bar';
	return bar$1;
}

var fooDefault = 'hello foo!';
const foo = 'foo';

const barDefault = requireBar();
const { bar } = barDefault;

function index () {
  console.log(fooDefault, foo, bar, barDefault);
}

const a = 1;
const b = 2;

export { a, b, bar, barDefault, index as default, foo, fooDefault };

与使用 import 导入的差异在于不会尝试读取 esm 的默认导出。

总结

  1. cjs 和 esm 格式混用时,rollup 默认打包结果是无法转换 require 或 exports 的,需要配置 @rollup/plugin-commonjs 插件,使 cjs 专用语法转换为通用语法,不然无法在浏览器正常运行。
  2. package.json 中声明 type 或文件名后缀不影响 rollup 解析模块的方式。

库打包格式

如果希望一个库既能在 node 运行,又能在浏览器运行,应该如何打包?(这里讨论的是兼容老版本node 的情况,新版本可以直接使用 import 导入)

方案一:打包为 umd

umd 设计上只考虑了 amd、commonjs、全局对象三种导出方式,原则上是不支持 esm 导入的。如果像下面一样导入,则会报错。

<script type="module">
  import MyLib from './lib.js';
</script>

image 8.png

为什么 esm 项目中能导入 umd 格式文件?因为打包工具对 umd 进行了转换。以下面文件为例。

// lib.umd.js
(function (global, factory) {
	typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
	typeof define === 'function' && define.amd ? define(['exports'], factory) :
	(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.lib = {}));
})(this, (function (exports) { 'use strict';

	var fooDefault = 'hello foo!';
	const foo = 'foo';

	function getDefaultExportFromCjs (x) {
		return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
	}

	var bar = {};

	var hasRequiredBar;

	function requireBar () {
		if (hasRequiredBar) return bar;
		hasRequiredBar = 1;
		bar.default = 'hello bar!';
		bar.bar = 'bar';
		return bar;
	}

	var barExports = requireBar();
	var barDefault = /*@__PURE__*/getDefaultExportFromCjs(barExports);

	function func() {
	  console.log(fooDefault, foo, barExports.bar, barDefault);
	}

	const a = 1;
	const b = 2;

	exports.a = a;
	exports.b = b;
	exports.bar = barExports.bar;
	exports.barDefault = barDefault;
	exports.default = func;
	exports.foo = foo;
	exports.fooDefault = fooDefault;

	Object.defineProperty(exports, '__esModule', { value: true });

}));
// index.js
import * as lib from './lib.umd.js';

console.log(lib);

webpack 输出

/******/ (() => { // webpackBootstrap
/******/ 	var __webpack_modules__ = ({

/***/ 470
(__unused_webpack_module, exports) {

(function (global, factory) {
   true
    ? factory(exports)
    : 0;
})(this, function (exports) {
  'use strict';

  var fooDefault = 'hello foo!';
  const foo = 'foo';

  function getDefaultExportFromCjs(x) {
    return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default')
      ? x['default']
      : x;
  }

  var bar = {};

  var hasRequiredBar;

  function requireBar() {
    if (hasRequiredBar) return bar;
    hasRequiredBar = 1;
    bar.default = 'hello bar!';
    bar.bar = 'bar';
    return bar;
  }

  var barExports = requireBar();
  var barDefault = /*@__PURE__*/ getDefaultExportFromCjs(barExports);

  function func() {
    console.log(fooDefault, foo, barExports.bar, barDefault);
  }

  const a = 1;
  const b = 2;

  exports.a = a;
  exports.b = b;
  exports.bar = barExports.bar;
  exports.barDefault = barDefault;
  exports.default = func;
  exports.foo = foo;
  exports.fooDefault = fooDefault;

  Object.defineProperty(exports, '__esModule', { value: true });
});

/***/ }

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/compat get default export */
/******/ 	(() => {
/******/ 		// getDefaultExport function for compatibility with non-harmony modules
/******/ 		__webpack_require__.n = (module) => {
/******/ 			var getter = module && module.__esModule ?
/******/ 				() => (module['default']) :
/******/ 				() => (module);
/******/ 			__webpack_require__.d(getter, { a: getter });
/******/ 			return getter;
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/ 	
/************************************************************************/
var __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be in strict mode.
(() => {
"use strict";
/* harmony import */ var _lib_umd_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(470);
/* harmony import */ var _lib_umd_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_lib_umd_js__WEBPACK_IMPORTED_MODULE_0__);

console.log(_lib_umd_js__WEBPACK_IMPORTED_MODULE_0__);

})();

/******/ })()
;

rollup 输出

function _mergeNamespaces(n, m) {
	m.forEach(function (e) {
		e && typeof e !== 'string' && !Array.isArray(e) && Object.keys(e).forEach(function (k) {
			if (k !== 'default' && !(k in n)) {
				var d = Object.getOwnPropertyDescriptor(e, k);
				Object.defineProperty(n, k, d.get ? d : {
					enumerable: true,
					get: function () { return e[k]; }
				});
			}
		});
	});
	return Object.freeze(n);
}

function getDefaultExportFromCjs (x) {
	return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
}

var lib_umd$2 = {exports: {}};

var lib_umd$1 = lib_umd$2.exports;

var hasRequiredLib_umd;

function requireLib_umd () {
	if (hasRequiredLib_umd) return lib_umd$2.exports;
	hasRequiredLib_umd = 1;
	(function (module, exports) {
		(function (global, factory) {
		  factory(exports)
		    ;
		})(lib_umd$1, function (exports) {

		  var fooDefault = 'hello foo!';
		  const foo = 'foo';

		  function getDefaultExportFromCjs(x) {
		    return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default')
		      ? x['default']
		      : x;
		  }

		  var bar = {};

		  var hasRequiredBar;

		  function requireBar() {
		    if (hasRequiredBar) return bar;
		    hasRequiredBar = 1;
		    bar.default = 'hello bar!';
		    bar.bar = 'bar';
		    return bar;
		  }

		  var barExports = requireBar();
		  var barDefault = /*@__PURE__*/ getDefaultExportFromCjs(barExports);

		  function func() {
		    console.log(fooDefault, foo, barExports.bar, barDefault);
		  }

		  const a = 1;
		  const b = 2;

		  exports.a = a;
		  exports.b = b;
		  exports.bar = barExports.bar;
		  exports.barDefault = barDefault;
		  exports.default = func;
		  exports.foo = foo;
		  exports.fooDefault = fooDefault;

		  Object.defineProperty(exports, '__esModule', { value: true });
		}); 
	} (lib_umd$2, lib_umd$2.exports));
	return lib_umd$2.exports;
}

var lib_umdExports = requireLib_umd();
var lib_umd = /*@__PURE__*/getDefaultExportFromCjs(lib_umdExports);

var lib = /*#__PURE__*/_mergeNamespaces({
	__proto__: null,
	default: lib_umd
}, [lib_umdExports]);

console.log(lib);

可以看到 webpack 和 rollup 都对 umd 前面一堆判断做了处理,直接运行了 factory。

typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
	typeof define === 'function' && define.amd ? define(['exports'], factory) :
	(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.lib = {}));

当然,处理后的 umd 相当于通过 cjs 导入,不具有 esm 的静态加载、tree shaking 的特性,一般只考虑于使用在 cdn 引入的场景。

方案二:通过package.json区分导入格式(推荐)

打包生产多种格式文件,通过 package.json 字段区分导入格式。

  • main:默认入口文件,默认值为根目录 index.js
  • browser:浏览器环境指定替代入口,webpack target 为 web 时优先使用
  • module:打包工具生态的实用约定,不是官方标准
  • exports:优先级最高
    • conditionNames:打包工具中的相关字段,决定匹配字段的优先级。使用 import 导入时,会在该字段数组前动态添加["import", "module", "..."] 。使用 require 导入时,则动态添加["require", "module", "..."]

TS 相关配置

  • module - 指定生成 js 代码的模块格式
  • moduleResolution - 模块解析策略
    • node - 默认值
      • 支持 package.json的 maintypestypings字段
      • 支持 @types声明文件
    • node16 / nodenext
      • 支持 .mjs/.cjs扩展名
      • 支持 package.json的 exports字段
    • bundler
      • 支持 .ts.tsx 等更多扩展名
      • 支持导入语法带查询参数、虚拟模块等
      • 支持package.json打包器特定字段(browserdevelopment等)
      • 支持导入.json.css等资源文件
      • 深度集成环境变量(import.meta.env
      • 与打包器别名深度集成
  • esModuleInterop - ESM/CJS 互操作性 开启情况下,esm 可通过默认导入导入 cjs 的 exports.default
  • allowSyntheticDefaultImports - 允许合成默认导入 开启情况下,esm 默认导入没有默认导出的模块时,相当于import * as xx from xx