ESM 和 CJS 模塊雜談
Node.js 12.17.0,移除了--experimental-modules標識。雖然 ESM 還是試驗性的,但已經相對穩定了。
之後的版本,nodejs 按以下流程判斷模塊系統是用 ESM 還是 CJS: package.json type字段等額外的處理。
當然你也可以明確告訴 nodejs 要用 CJS,方法跟上面差不多:
- 文件以
.cjs爲後綴 package.json裏定義了"type": "commonjs"--eval或者STDIN管道方式執行 nodejs,帶上--input-type=commonjs標識
實際上我們很少見到有項目通過.mjs、.cjs這樣的文件後綴來區分模塊系統,一般都是使用package.json裏的type字段。
模塊入口
我們知道有很多第三方庫同時支持在 nodejs 和瀏覽器環境執行,這種庫通常會打包出 CJS 和 ESM 兩種產物,CJS 產物給 nodejs 用,ESM 產物給webpack之類的 bundler 使用。所以,當我們使用require和import導入模塊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的優先級比main和module高,也就是說,匹配上exports的路徑就不會使用main和module的路徑。
咋一看好像exports並沒有給 CJS 和 ESM 帶來多少新東西。的確,普通的場景來說,main和module字段已經滿足需求,但是如果要針對不同路徑或者環境引入不同的 CJS 或者 ESM 模塊,exports就顯然更靈活。而且,exports是新規範,我們也有必要了解甚至在工程裏嘗試使用。
當然,這裏還是建議大家保留main和module字段,用來兼容不支持exports字段的 nodejs 版本或 bundler。
互操作
nodejs14 以上版本 ESM 模塊能夠通過default import、name import、namespace import等方式導入 CJS 模塊,但反過來 CJS 模塊只能通過dynamic import即import()導入 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_PATH、resolve.extensions和resolve.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.cjs從a.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 的代碼,因爲需要兼容舊版本的瀏覽器嘛。處理過程大致如下:
- ESM 規範編寫代碼,使用
import、export; - babel 等編譯器將 ESM 代碼轉成 CJS 代碼;
- 但是瀏覽器不支持 CJS 規範啊,所以 webpack 按照 CJS 規範實現了類似
require和module.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 default和module.exports對等起來。這種不一致導致 esbuild 對 nodejs 和 browser 這兩個環境下使用的三方庫的處理出現錯誤。
最後
這篇文章結合熱門話題講了一些 ESM 和 CJS 的知識點,講得比較雜,但也算是個人的總結吧,希望對大家有用。
參考資料
- github.com/nodejs/node…
- nodejs.medium.com/announcing-…
- nodejs.org/api/package…
- nodejs.org/api/esm.htm…
- nodejs.org/api/modules…
- zhuanlan.zhihu.com/p/113009496
- github.com/evanw/esbui…
- redfin.engineering/node-module…
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/7048276970768957477