提問
CommonJS 中的 require/exports 和 ES6 中的 import/export 區別?
回答
- CommonJS 模塊是運行時加載,ES6 Modules 是編譯時加載並輸出接口。
- CommonJS 輸出是值的拷貝;ES6 Modules輸出的是值的引用,被輸出模塊的內部的改變會影響引用的改變。
- CommonJs 導入的模塊路徑可以是一個表達式,因為它使用的是 require() 方法,甚至這個表達式計算出來的內容是錯誤的路徑,也可以通過編譯到執行階段再出錯;而ES6 Modules 只能是字符串,並且路徑不正確,編譯階段就會拋錯。
- CommonJS this 指向當前模塊,ES6 Modules this 指向 undefined
- ES6 Modules 中沒有這些頂層變量:arguments、require、module、exports、__filename、__dirname
此總結出自 如何回答好這個高頻面試題:CommonJS和ES6模塊的區別?,筆者在這里做一些其他的分析
關於第一個差異運行時加載和編譯時加載
這是最大的一個差別。commonjs 模塊在引入時就已經運行了,它是“運行時”加載的;但 es6 模塊在引入時並不會立即執行,內核只是對其進行了引用,只有在真正用到時才會被執行,這就是“編譯時”加載(引擎在編譯代碼時建立引用)。很多人的誤區就是 JS 為解釋型語言,沒有編譯階段,其實並非如此。舉例來說 Chrome 的 v8 引擎就會先將 JS 編譯成中間碼,然后再虛擬機上運行。
CommonJS 加載的是一個對象(即module.exports屬性),該對象只有在腳本運行完才會生成。而 ES6 模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成。
由此引發一些區別,如 require 理論上可以運用在代碼的任何地方,可以在引入的路徑里加表達式,甚至可以在條件判斷語句里處理是否引入的邏輯。因為它是運行時的,在腳本執行時才能得知路徑與引入要求,故而甚至時路徑填寫了一個壓根不存在的地址,它也不會有編譯問題,而在執行時才拋出錯誤。
// ...a lot code
if (true) {
require(process.cwd() + '/a');
}
但是 import 則不同,它是編譯時的,在編譯時就已經確定好了彼此輸出的接口,可以做一些優化,而 require 不行。所以它必須放在文件開頭,而且使用格式也是確定的,路徑里不許有表達式,路徑必須真實能找到對應文件,否則編譯階段就會拋出錯誤。
import a from './a'
// ...a lot code
關於第一個差異,是因為CommonJS 加載的是一個對象(即module.exports屬性),該對象只有在腳本運行完才會生成。而 ES6 模塊不是對象,它的對外接口只是一種靜態定義,在代碼靜態解析階段就會生成。
關於第二點 CommonJS 輸出的是值的拷貝
的補充
// a.js
var name = '張三';
var sex = 'male';
var tag = ['good look']
setTimeout(function () {
console.log('in a.js after 500ms change ', name)
sex = 'female';
tag.push('young');
}, 500)
// exports.name = name;
// exports.sex = sex;
// exports.tag = tag;
module.exports = {
name,
sex,
tag
}
// b.js
var a = require('./a');
setTimeout(function () {
console.log(`after 1000ms in commonjs ${a.name}`, a.sex)
console.log(`after 1000ms in commonjs ${a.name}`, a.tag)
}, 1000)
console.log('in b.js');
若運行 b.js,得到下面的輸出
$ node b.js
in b.js
in a.js after 500ms change 張三
after 1000ms in commonjs 張三 male
after 1000ms in commonjs 張三 [ 'good look', 'young' ]
把 a 和 b 看成兩個不相干的函數,a 之中的 sex 是基礎屬性當然影響不到 b,而 a 和 b 的 tag 是引用類型,並且是共用一份地址的,自然 push 能影響。
補充說明 require 原理
require 是怎么做的?先根據 require('x') 找到對應文件,在 readFileSync 讀取, 隨后注入exports、require、module三個全局變量再執行源碼,最終將模塊的 exports 變量值輸出
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(stripBOM(content), filename);
};
讀取完畢后編譯
Module.prototype._compile = function(content, filename) {
var self = this;
var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
};
上面代碼等同於
(function (exports, require, module, __filename, __dirname) {
// 模塊源碼
});
模塊的加載實質上就是,注入exports、require、module三個全局變量,然后執行模塊的源碼,然后將模塊的 exports 變量的值輸出。
補充說明 Babel 下的 ES6 模塊轉化
Babel 也會將 export/import的時候,Babel也會把它轉換為exports/require的形式。
// m1.js
export const count = 0;
// index.js
import {count} from './m1.js'
console.log(count)
Babel 編譯后就應該是
// m1.js
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.count = void 0;
const count = 0;
// index.js
"use strict";
var _m = require("./m1.js");
console.log(_m.count);
exports.count = count;
正因為有 Babel 做了轉化,所以 require 和 import 才能被混用在一個項目里,但是你應該知道這是兩個不同的模塊系統。
題外話
留個思考題給大家,這兩種模塊系統對於循環引用的區別?有關於循環引用是啥,參見我這篇Node 模塊循環引用問題