幾個月前,我寫了一篇文章來描述 Node.js 現存的 CommonJS 模組和新的 ES6 模組系統的許多不同,也說明了在 Node.js 核心中實現這個新模型的內在的一些挑戰。現在,我想分享一下關於這件事情的進展情況。
明白你什麼時候該知道你需要知道的東西
在這之前,如果你還沒準備好,你可以花一點時間來看一下我之前的描述這兩個模組架構上存在許多根本區別的文章。總結來說就是:CommonJS 與 ES6 Modules 之間的關鍵不同在於程式碼什麼時候知道一個模組的結構和使用它。
舉個栗子,假如我現在有一個簡單的 ComminJS 模組(模組名叫'foobar'
):
1 2 3 4 5 6 7 8 |
function foo() { return 'bar'; } function bar() { return 'foo'; } module.exports.foo = foo; module.exports.bar = bar; |
現在我們在一個叫 app.js
的 JS 檔案中引用它
1 2 |
const {foo, bar} = require('foobar'); console.log(foo(), bar()); |
當我執行 $node app.js
的時候,Node.js 已二進位制的形式載入 app.js
檔案,解析它,並且開始執行裡面的程式碼。在執行過程中,裡面的 require()
方法被呼叫,然後它會同步的去載入 foobar.js
的內容進記憶體,同步的解析編譯裡面的 JavaScript 程式碼,同步的執行裡面的程式碼,然後返回 module.exports
的值當做 app.js
裡的 require('foobar')
的返回值。當 app.js
裡的 require()
方法返回的時候,foobar
模組的結構就已經知道了,並且可以被使用。所有的這些事情都發生在 Node.js 程式事件迴圈的同一個週期裡。
要理解 CommonJS 與 ES6 Modules 之間的不同至關重要的是,一個 CommonJS 的模組在沒有被執行完之前,它的結構(API)是不可知的 — 即使在它被執行完以後,它的結構也可以隨時被其他程式碼修改。
現在我們用 ES6 的寫法來寫同樣的模組:
1 2 3 4 5 6 |
export function foo() { return 'bar'; } export function bar() { return 'foo'; } |
並且在程式碼中引用它:
1 2 3 |
import {foo, bar} from 'foobar'; console.log(foo()); console.log(bar()); |
從 ECMAScript 統一的標準來看,ES6 Modules 的步驟與 CommonJS 裡已經實現的有很大的不同。第一步從硬碟上載入檔案內容大致上是相同的,但是可能是非同步的。當內容載入完成後,會解析它。在解析的同時,模組裡被 export 宣告定義的結構會在元件內容被執行之前就探知出來。一旦結構被探知出來,元件的程式碼就會被執行。這裡重要的是記住所有的 import 和 export 語句都會在程式碼執行之前被解析出來。另一點是在 ES6 中是允許這個解析的步驟非同步執行的。這就意味著,在 Node.js 的機制中,載入指令碼內容、解析模組的 import 和 export 、執行模組程式碼將發生在多個事件迴圈裡。
時機很重要
在評估 ES6 Modules 的可實現性之前,我們關注的重點是怎麼樣無縫銜接的實現它。比如我們希望它可以可以實現同時對兩種模組的支援,這樣可以很大程度上對使用者是透明的。
可惜,事情並不是這麼簡單…
尤其是 ES6 Modules 的載入、解析和執行都是非同步的,這就導致不能通過 require()
來引用一個 ES6 模組。原因是 require()
是一個完全同步的函式。如果我們去修改 require()
的語義讓它可以進行非同步載入的話,那對於現有的生態系統將會產生巨大的破壞。所以我們有考慮在 ES6 的 import()
函式提議(詳情)通過之後建模實現一個 require.import()
函式。這個函式會返回一個 Promise
在 ES6 模組載入完成後標記完成。這不是最好的方案,但是它可以讓你在現有的 Node.js 裡以 CommonJS 的格式來使用。
有一點好訊息是在 ES6 模組裡可以很方便地使用 import
來引用一個 CommonJS 模組。因為在 ES6 模組裡非同步載入不是必須的。ECMAScript 規範進行一些小修改就可以更好地支援這種方式。但是所有這些工作過後,還有一個重要的事情…
命名引用
命名引用是 ES6 Modules 裡的一個基本的特性。舉個例子:
1 |
import {foo, bar} from 'foobar'; |
變數 foo
和 bar
在解析階段就從 foobar
中被引用進來 —— 在所有程式碼被執行之前。因為 ES6 Modules 的結構是之前就可以被探知到的。
另一方面,在 CommonJS 裡模組結構在程式碼沒有執行之前是不能被探知的。也就是說,如果不對 ECMAScript 規範做重大更改的話,在 CommonJS 模組裡是不能使用命名引用的。開發者會引用到 ES6 Modules 裡面的名為 “default” 的匯出。比如,上面的例子在 CommonJS 裡是這樣的:
1 2 |
import foobar from 'foobar'; console.log(foobar.foo(), foobar.bar()); |
區別很小但是很重要。所以當你想使用 import
來引用一個 CommonJS 模組的時候,下面這種寫法是根本行不通的:
1 |
import {foo, bar} from 'foobar'; |
這裡的 foo
和 bar
不會直接被解析成 CommonJS 模組裡匯出的 foo()
和 bar()
方法。
但是在 Babel 裡可以!
使用過像 Babel 這種的 ES6 Modules 語法轉換工具的人應該很熟悉命名引用。Babel 的工作原理是把 ES6 的寫法轉換成可以在 Node.js 裡執行的 CommonJS 的形式。雖然語法看起來很像 ES6,但是實際上並不是。這一點很重要,Babel 裡的 ES6 命名引用與完全按照規範實現的 ES6 命名引用有本質的不同。
Michael Jackson Script
實際上CommonJS 和 ES6 Modules 之間還有另外一個重要的不同就是,ECMAScript 編譯器必須提前知道它載入的程式碼是 CommonJS 的還是 ES6 Modules 的。原因是之前說的 ES6 Modules 必須在程式碼執行前就解析出模組中的 import
和 export
宣告。
這就意味著 Node.js 需要某些機制來預先識別它在載入那種型別的檔案。在探索了很多方案以後,我們迴歸到了以前最糟糕的方案,就是引入一個新的 *.mjs
檔案字尾來表示一個 ES6 Modules 的 JavaScript 檔案。(之前我們親切的叫它 “Michael Jackson Script”)
時間線
在目前的時間點上,在 Node.js 可以開始處理支援實現 ES6 Modules 之前,還有很多關於規範現實的問題和虛擬機器方面的問題。相關工作還在進行,但是需要一些時間 —— 我們目前估計至少需要一年左右。