模組系統的作用
傳統script標籤的程式碼載入容易導致全域性作用域汙染,而且要維繫一系列script的書寫順序,專案一大,維護起來越來越困難。模組系統通過宣告式的暴露和引用模組使得各個模組之間的依賴變得明顯。
es module如何工作的
這部分推薦去看es-modules-a-cartoon-deep-dive,原文裡有圖,以下的內容是個人理解整理。
分三步:
- 構造,尋找並且下載所有的檔案並且解析成模組記錄(Module Records)(包含當前模組程式碼的抽象語法樹,當前模組的依賴模組的資訊)。
- 例項化,將模組記錄例項化將各個模組之間的import,export部分對應的都在記憶體中指向到一起(linking)
- 執行,將import, export記憶體裡指向的地址填上實際的值。
構造階段(Construction)
構造階段要做三件事情:
解釋(interpret)import後的模組指示符(module specifier)成實際url或者檔案地址
不同平臺根據自己平臺的模組解析演算法(Module Resolution Algorithm)解釋模組指示符,瀏覽器端目前只接受url做為指示符。不過瀏覽器將來會同樣支援內建模組比如kv-storage。
模組指示符裡的變數
模組指示符裡不能有變數但是node中commonJS是可以有的,因為在commonJS的模組程式碼裡,require
宣告前的程式碼是會先執行的,es module是最後一步再去執行,這一步才知道各個變數的具體值是多少。所以可以在node中有如下寫法:
require(`${path}/sum.js`);
複製程式碼
不過es module裡有另一種寫法動態引入import()
可以支援在程式碼執行時動態引入模組,可以在指示符裡攜帶變數
import(`${path}/sum.js`);
複製程式碼
瀏覽器根據url下載檔案或者node根據檔案地址去載入檔案
將檔案解析成模組記錄
瀏覽器解析常規js檔案時會解析完後再執行。和模組的解析策略不一樣,這裡要告訴瀏覽器解析的是個模組。在html中:
<script type="module">
import {sum} from "./sum.js"
</script>
複製程式碼
ps: 在node中因為沒有瀏覽器這種類似打tag的形式,有種方案是模組檔案是.mjs
字尾結尾的方案,不過目前尚未敲定。
解析模組檔案為模組記錄,找到依賴的模組再去下載模組然後解析成模組記錄,直到所有的模組都解析成模組記錄為止。模組記錄會存在當前全域性的一個模組對映裡(Module Map),可以理解成一個快取,下次再有相同url的模組請求就直接從模組對映裡拿出模組記錄即可。
例項化階段
將上面得到的模組記錄類例項化。
首先在記憶體中指定位置給各個模組的export
匯出的變數或者函式,接著將模組中對應的import
部分同樣指向對應的export
的記憶體地址。
舉個?
// main.js
import {obj} from "./obj.js"
// obj.js
const obj = {a: 123};
export {obj}
複製程式碼
obj.js
檔案裡匯出的obj
和main.js
檔案裡引用的obj
是指向同一個記憶體地址的,這中方法就是動態繫結(live binding)。
<script type='module'>
import {obj} from "./obj.js"
console.log(obj); //{a: 123}
setTimeout(() => {
console.log(obj) //{b: 233}
}, 2000);
</script>
複製程式碼
let obj = {
a: 123
};
setTimeout(() => {
obj = { b: 233 };
}, 1000);
export { obj };
複製程式碼
下面我們看下node中同樣的程式碼的效果。
// test1.js
var obj = require("./test2.js");
console.dir(obj); // {a: 123}
setTimeout(() => {
console.dir(obj); // {a: 123}
}, 2000);
// test2.js
let obj = { a: 123 };
setTimeout(() => {
obj = { b: 233 };
}, 1000);
module.exports = obj;
複製程式碼
在commonJS中require
一個物件是在記憶體中複製一份匯出模組的物件。動態繫結主要解決的問題就是迴圈引用的問題,迴圈引用在下面的執行階段進行解釋。
注意:
es module中可以在模組匯出的部分更改匯出值如上面程式碼所示,但是不能在引入部分更改。
import {obj} from "./sum.js"
obj = '233' // Uncaught TypeError: Assignment to constant variable.
複製程式碼
如上報錯會提示不能給常量賦值,不過如果是物件的話可以更改內部的key,由於動態繫結的原因,匯出部分也會發生改變
// main.js
import {obj} from "./obj.js"
setTimeout(() => {
obj.a = '嘻嘻'
}, 1000);
// obj.js
let obj = { a: 123 };
console.log(obj); // {a: 123}
setTimeout(() => {
console.log(obj); // {a: "嘻嘻"}
}, 2000);
export { obj };
複製程式碼
執行階段(evaluate)
原文中是evaluate,我這裡理解成了執行,如有不對歡迎指出。引擎開始執行模組了,每個模組只會被執行一次。在上面提到過的module map裡的模組記錄裡會存有當前模組的狀態是例項化中還是例項完成還是執行完成等。可以避免同一個模組檔案被多次執行。
迴圈引用問題
如下在node中,兩個模組互相引用。
// test1.js
var b = require("./test2").b;
console.dir("test1: " + b); // 'test1: test2' ?
var a = "test1";
exports.a = a;
// test2.js
var a = require("./test1").a;
console.log("test2: " + a); // test2: undefined ?
var b = "test2";
setTimeout(() => {
console.log("test2: " + a); // test2: undefined ?
}, 1000);
exports.b = b;
node test1.js // 啟動
複製程式碼
ps: emoji裡表示列印順序
node執行某個模組時會將當前模組的程式碼放入函式中,向這個函式傳遞module
, module.exports
, __dirname
等引數。初始的module
就是一個空物件。
test1.js執行遇到require('./test2)
時會進入test2模組開始執行,這個時候又碰到引用test1模組的東西;因為test1模組沒有執行完成,它的module.exports
還是空物件,所以這個時候test2裡的a
是undefined
。因為commonJS不是動態繫結的,so等到test1模組執行完a
變數裡還是undefined
es module
// es1
import { b } from "./es2.js";
console.log("es1: " + b); // es1: es2 ?
var a = "es1";
export { a };
// es2
import { a } from "./es1.js";
console.log("es2: " + a); // es2: undefined ?
var b = "es2";
setTimeout(() => {
console.log("es2: " + a); // es2: es1 ?
}, 1000);
export { b };
複製程式碼
以上程式碼入口是es1檔案。根據列印順序來看先是執行的es2模組,之後es1裡的a
填充了實際值,由於是動態繫結es2中的a
中的值也在之後能取到值了。
es module的好處
- 動態繫結解決了迴圈呼叫的問題(見上文)
- 靜態分析(statically analysis) 因為在程式碼未執行階段就已經知道當前模組匯入了什麼,匯出了什麼,所以有些工具就可以進行靜態分析。比如vscode中引入模組程式碼時會提示當前模組裡匯出的內容。
es module的壞處
- 相容性
- 尚未有針對node的解決方案