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