ESM 和 CJS 模塊雜談

Node.js 12.17.0,移除了--experimental-modules標識。雖然 ESM 還是試驗性的,但已經相對穩定了。 ​

之後的版本,nodejs 按以下流程判斷模塊系統是用 ESM 還是 CJS: 不滿足以上判斷條件的會以 CJS 兜底。如果你的工程遵循 CJS 規範,並不需要特殊的文件名後綴和設置package.json type字段等額外的處理。 ​

當然你也可以明確告訴 nodejs 要用 CJS,方法跟上面差不多:

實際上我們很少見到有項目通過.mjs.cjs這樣的文件後綴來區分模塊系統,一般都是使用package.json裏的type字段。

模塊入口

我們知道有很多第三方庫同時支持在 nodejs 和瀏覽器環境執行,這種庫通常會打包出 CJS 和 ESM 兩種產物,CJS 產物給 nodejs 用,ESM 產物給webpack之類的 bundler 使用。所以,當我們使用requireimport導入模塊moduleA時,入口文件路徑往往是不一樣的。那麼問題來了,如何讓 nodejs 或者 bundler 找到對應的入口文件呢? ​

一般我們通過 package.json 的main字段定義 CJS 的入口文件,module字段定義 ESM 的入口文件。

{
	"name": "moduleA",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js"
}

這樣,nodejs 和 bundler 就知道分別從./dist/cjs/index.js./dist/esm/index.js導入模塊了。 ​

Node.js v12.16.0 給package.json增加了exports字段,允許我們在不同條件下匹配不同的路徑。exports有很多用處,包括區分 nodejs 還是 browser 環境、區分 development 還是 production 環境、限制訪問私有路徑等。這裏重點講它對 CJS 和 ESM 模塊導入的影響。 ​

我們可以這麼定義:

{
	"name": "moduleA",
      "main": "./dist/cjs/index.js",
      "module": "./dist/esm/index.js",
      "exports": {
		"import": "./dist/esm/index.js",
           "require": "./dist/cjs/index.js"
	 }
}

當使用require('moduleA')時,實際導入的是node_modules/moduleA/dist/cjs/index.js,而使用import moduleA from 'moduleA'時,導入的是node_modules/moduleA/dist/esm/index.js。 ​

exports的優先級比mainmodule高,也就是說,匹配上exports的路徑就不會使用mainmodule的路徑。

咋一看好像exports並沒有給 CJS 和 ESM 帶來多少新東西。的確,普通的場景來說,mainmodule字段已經滿足需求,但是如果要針對不同路徑或者環境引入不同的 CJS 或者 ESM 模塊,exports就顯然更靈活。而且,exports是新規範,我們也有必要了解甚至在工程裏嘗試使用。 ​

當然,這裏還是建議大家保留mainmodule字段,用來兼容不支持exports字段的 nodejs 版本或 bundler。

互操作

nodejs14 以上版本 ESM 模塊能夠通過default importname importnamespace import等方式導入 CJS 模塊,但反過來 CJS 模塊只能通過dynamic importimport()導入 ESM 模塊。

// default_add.mjs
export default function add(a, b) {
  return a + b;
}

// name_add.mjs
export function add(a, b) {
  return a + b;
}

// index.cjs
import('./default_add.mjs').then(
  ({ default: add }) => {
    console.log('default import: ', add(1, 2)); // default import:  3
  }
);
import('./name_add.mjs').then(
  ({ add }) => {
    console.log('name import: ', add(1, 2)); // name import:  3
  }
);

區別

特性被移除

如果想用 ESM 寫 nodejs,這裏就要特別注意下。 ​

ESM 模塊裏沒有__dirname__filename這些變量,但我們可以通過import.meta.url和 nodejs 的url模塊(使用 firedirname 也可以)來解析出 dirname 和 filename。

// dir-path/index.mjs
import filedirname from 'filedirname';

const [filename, dirname] = filedirname(import.meta.url);
console.log('dirname: ', dirname); // dirname: dir-path
console.log('filename: ', filename); // filename: dir-path/index.mjs

ESM 引入 json 模塊目前只能通過實驗性的標識--experimental-json-modules來實現

// index.mjs
import { readFile } from 'fs/promises';
const json = JSON.parse(
  await readFile(new URL('./package.json', import.meta.url))
);
console.log(json);
node index.mjs --experimental-json-modules

ESM 不支持 native 模塊導入,移除 require.resolve,不過這兩項可以通過module.createRequire()實現。 ​

另外,ESM 移除NODE_PATHresolve.extensionsresolve.cache(ESM 有自己的緩存機制)。 ​

上面說到的很多在 ESM 裏移除的能力,我們可以通過module.createRequire(),在 ESM 裏也能使用require(正常來說,ESM 模塊裏使用require會報錯),從而曲線救國。

// util.cjs
exports.add = function add(a, b) {
  return a + b;
};

// index.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { add } = require('./util.cjs');

console.log(add(1, 2)); // 3

嚴格模式 vs 非嚴格模式

CJS 默認是非嚴格模式,而 ESM 默認是嚴格模式。

引用 vs 拷貝

CJS 模塊require導入的是值的拷貝,而 ESM 導入的是值的引用。

// a.cjs
let age = 18;

exports.setAge = function setAge(val) {
  age = val;
};
exports.age = age;

// index.cjs
const { age, setAge } = require('./a.cjs');

console.log(age); // 18
setAge(19);
console.log(age); // 18

// a.mjs
export let age = 18;
export function setAge(val) {
  age = val;
}

// index.mjs
import { age, setAge } from './a.mjs';

console.log(age); // 18
setAge(19);
console.log(age); // 19

可以看到,index.cjsa.cjs引入了age,並通過setAge修改了a.cjs裏的age,但是最後打印的age沒有變,而 ESM 則相反。

動態 vs 靜態

我們都知道 javascript 是一門 JIT 語言,v8 引擎拿到 js 代碼後會邊編譯邊執行,在編譯的時候 v8 就給import導入的模塊建立靜態的引用,並且不能在運行時不能更改。所以import都放在文件開頭,不能放在條件語句裏。 ​

require導入模塊是在運行時纔對值進行拷貝,所以require的路徑可以使用變量,並且require可以放在代碼的任何位置。 ​

基於這個差異,ESM 比 CJS 好做 tree-shaking。

異步 vs 同步

ESM 是頂層 await 的設計,而 require 是同步加載,所以 require 無法導入 ESM 模塊,但是可以通過import()導入。

web 項目中 ESM 的處理

我們平時用 react、vue 開發業務的時候都是遵循 ESM 規範,但最終交給瀏覽器執行的並不是 ESM 的代碼,因爲需要兼容舊版本的瀏覽器嘛。處理過程大致如下:

  1. ESM 規範編寫代碼,使用importexport;
  2. babel 等編譯器將 ESM 代碼轉成 CJS 代碼;
  3. 但是瀏覽器不支持 CJS 規範啊,所以 webpack 按照 CJS 規範實現了類似requiremodule.exports的模塊加載機制。

這裏順便說一下最近比較熱門的話題:esbuild 0.14.4 版本在 CJS 和 ESM 的轉換上引入了 breaking change,掀起社區熱烈的討論,esbuild 也在 changelog 裏詳細記錄了事情的來由。大概情況就是 babel 爲了將 ESM 準確降級成 CJS,把export default 0處理成module.exports.default = 0,然後通過__esModule是否爲 true 決定import foo from 'bar'時 foo 是module.exports.default還是module.exports來保證import foo from 'bar'const foo = require('bar')等價。但是 nodejs ESM 的實現是將export defaultmodule.exports對等起來。這種不一致導致 esbuild 對 nodejs 和 browser 這兩個環境下使用的三方庫的處理出現錯誤。

最後

這篇文章結合熱門話題講了一些 ESM 和 CJS 的知識點,講得比較雜,但也算是個人的總結吧,希望對大家有用。

參考資料

  1. github.com/nodejs/node…
  2. nodejs.medium.com/announcing-…
  3. nodejs.org/api/package…
  4. nodejs.org/api/esm.htm…
  5. nodejs.org/api/modules…
  6. zhuanlan.zhihu.com/p/113009496
  7. github.com/evanw/esbui…
  8. redfin.engineering/node-module…
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/7048276970768957477