前端模組化的今生

  • 2019 年 12 月 17 日
  • 筆記

作者:Shenfq — 拼多多前端工程師

背景

眾所周知,早期 JavaScript 原生並不支援模組化,直到 2015 年,TC39 發布 ES6,其中有一個規範就是 ES modules(為了方便表述,後面統一簡稱 ESM)。但是在 ES6 規範提出前,就已經存在了一些模組化方案,比如 CommonJS(in Node.js)、AMD。ESM 與這些規範的共同點就是都支援導入(import)和導出(export)語法,只是其行為的關鍵詞也一些差異。

CommonJS

//add.js  const add = (a, b) => a + b  module.exports = add  //index.js  const add = require('./add')  add (1, 5)

AMD

//add.js  define (function() {    const add = (a, b) => a + b    return add  })  //index.js  require(['./add'], function (add) {    add (1, 5)  })

ESM

//add.js  const add = (a, b) => a + b  export default add  //index.js  import add from './add'  add (1, 5)

關於 JavaScript 模組化出現的背景在上一章(《前端模組化的前世》)已經有所介紹,這裡不再贅述。但是 ESM 的出現不同於其他的規範,因為這是 JavaScript 官方推出的模組化方案,相比於 CommonJS 和 AMD 方案,ESM 採用了完全靜態化的方式進行模組的載入。

ESM 規範

模組導出

模組導出只有一個關鍵詞:export,最簡單的方法就是在聲明的變數前面直接加上 export 關鍵詞。

export const name = 'Shenfq'

可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。

export function getName() {    return name  }  export class Logger {  	log (...args) {      console.log (...args)    }  }

上面的導出方法也可以使用大括弧的方式進行簡寫。

const name = 'Shenfq'  function getName() {    return name  }  class Logger {  	log (...args) {      console.log (...args)    }  }  export { name, getName, Logger }

最後一種語法,也是我們經常使用的,導出默認模組。

const name = 'Shenfq'  export default name

模組導入

模組的導入使用 import,並配合 from 關鍵詞。

//main.js  import name from './module.js'  //module.js  const name = 'Shenfq'  export default name

這樣直接導入的方式,module.js 中必須使用 export default,也就是說 import 語法,默認導入的是 default 模組。如果想要導入其他模組,就必須使用對象展開的語法。

//main.js  import { name, getName } from './module.js'  //module.js  export const name = 'Shenfq'  export const getName = () => name

如果模組文件同時導出了默認模組,和其他模組,在導入時,也可以同時將兩者導入。

//main.js  import name, { getName } from './module.js'  //module.js  const name = 'Shenfq'  export const getName = () => name  export default name

當然,ESM 也提供了重命名的語法,將導入的模組進行重新命名。

//main.js  import * as mod from './module.js'  let name = ''  name = mod.name  name = mod.getName ()  //module.js  export const name = 'Shenfq'  export const getName = () => name

上述寫法就相當于于將模組導出的對象進行重新賦值:

//main.js  import { name, getName } from './module.js'  const mod = { name, getName }

同時也可以對單獨的變數進行重命名:

//main.js  import { name, getName as getModName }

導入同時進行導出

如果有兩個模組 a 和 b ,同時引入了模組 c,但是這兩個模組還需要導入模組 d,如果模組 a、b 在導入 c 之後,再導入 d 也是可以的,但是有些繁瑣,我們可以直接在模組 c 裡面導入模組 d,再把模組 d 暴露出去。

//module_c.js  import { name, getName } from './module_d.js'  export { name, getName }

這麼寫看起來還是有些麻煩,這裡 ESM 提供了一種將 import 和 export 進行結合的語法。

export { name, getName } from './module_d.js'

上面是 ESM 規範的一些基本語法,如果想了解更多,可以翻閱阮老師的 《ES6 入門》。

ESM 與 CommonJS 的差異

首先肯定是語法上的差異,前面也已經簡單介紹過了,一個使用 import/export 語法,一個使用 require/module 語法。

另一個 ESM 與 CommonJS 顯著的差異在於,ESM 導入模組的變數都是強綁定,導出模組的變數一旦發生變化,對應導入模組的變數也會跟隨變化,而 CommonJS 中導入的模組都是值傳遞與引用傳遞,類似於函數傳參(基本類型進行值傳遞,相當於拷貝變數,非基礎類型【對象、數組】,進行引用傳遞)。

下面我們看下詳細的案例:

CommonJS

//a.js  const mod = require('./b')  setTimeout (() => {    console.log (mod)  }, 1000)  //b.js  let mod = 'first value'  setTimeout (() => {    mod = 'second value'  }, 500)  module.exports = mod
$ node a.js  first value

ESM

//a.mjs  import { mod } from './b.mjs'  setTimeout (() => {    console.log (mod)  }, 1000)  //b.mjs  export let mod = 'first value'  setTimeout (() => {    mod = 'second value'  }, 500)
$ node --experimental-modules a.mjs  # (node:99615) ExperimentalWarning: The ESM module loader is experimental.  second value

另外,CommonJS 的模組實現,實際是給每個模組文件做了一層函數包裹,從而使得每個模組獲取 require/module__filename/__dirname 變數。那上面的 a.js 來舉例,實際執行過程中 a.js 運行程式碼如下:

//a.js  (function(exports, require, module, __filename, __dirname) {  	const mod = require('./b')    setTimeout (() => {      console.log (mod)    }, 1000)  });

而 ESM 的模組是通過 import/export 關鍵詞來實現,沒有對應的函數包裹,所以在 ESM 模組中,需要使用 import.meta 變數來獲取 __filename/__dirnameimport.meta 是 ECMAScript 實現的一個包含模組元數據的特定對象,主要用於存放模組的 url,而 node 中只支援載入本地模組,所以 url 都是使用 file: 協議。

import url from 'url'  import path from 'path'  //import.meta: { url: file:///Users/dev/mjs/a.mjs }  const __filename = url.fileURLToPath (import.meta.url)  const __dirname = path.dirname (__filename)

載入的原理

步驟:

  1. Construction(構造):下載所有的文件並且解析為 module records。
  2. Instantiation(實例):把所有導出的變數入記憶體指定位置(但是暫時還不求值)。然後,讓導出和導入都指向記憶體指定位置。這叫做『linking (鏈接)』。
  3. Evaluation(求值):執行程式碼,得到變數的值然後放到記憶體對應位置。

模組記錄

所有的模組化開發,都是從一個入口文件開始,無論是 Node.js 還是瀏覽器,都會根據這個入口文件進行檢索,一步一步找到其他所有的依賴文件。

// Node.js: main.mjs  import Log from './log.mjs'
<!-- chrome、firefox -->  <script type="module" src="./log.js"></script>

值得注意的是,剛開始拿到入口文件,我們並不知道它依賴了哪些模組,所以必須先通過 js 引擎靜態分析,得到一個模組記錄,該記錄包含了該文件的依賴項。所以,一開始拿到的 js 文件並不會執行,只是會將文件轉換得到一個模組記錄(module records)。所有的 import 模組都在模組記錄的 importEntries 欄位中記錄,更多模組記錄相關的欄位可以查閱 tc39.es。

模組構造

得到模組記錄後,會下載所有依賴,並再次將依賴文件轉換為模組記錄,一直持續到沒有依賴文件為止,這個過程被稱為『構造』(construction)。

模組構造包括如下三個步驟:

  1. 模組識別(解析依賴模組 url,找到真實的下載路徑);
  2. 文件下載(從指定的 url 進行下載,或從文件系統進行載入);
  3. 轉化為模組記錄(module records)。

對於如何將模組文件轉化為模組記錄,ESM 規範有詳細的說明,但是在構造這個步驟中,要怎麼下載得到這些依賴的模組文件,在 ESM 規範中並沒有對應的說明。因為如何下載文件,在服務端和客戶端都有不同的實現規範。比如,在瀏覽器中,如何下載文件是屬於 HTML 規範(瀏覽器的模組載入都是使用的 script 標籤)。

雖然下載完全不屬於 ESM 的現有規範,但在 import 語句中還有一個引用模組的 url 地址,關於這個地址需要如何轉化,在 Node 和瀏覽器之間有會出現一些差異。簡單來說,在 Node 中可以直接 import 在 node_modules 中的模組,而在瀏覽器中並不能直接這麼做,因為瀏覽器無法正確的找到伺服器上的 node_modules 目錄在哪裡。好在有一個叫做 import-maps 的提案,該提案主要就是用來解決瀏覽器無法直接導入模組標識符的問題。但是,在該提案未被完全實現之前,瀏覽器中依然只能使用 url 進行模組導入。

<script type="importmap">  {    "imports": {    	"jQuery": "/node_modules/jquery/dist/jquery.js"    }  }  </script>  <script type="module">  	import $ from 'jQuery'    $(function () {      $('#app').html ('init')    })  </script>

下載好的模組,都會被轉化為模組記錄然後快取到 module map 中,遇到不同文件獲取的相同依賴,都會直接在 module map 快取中獲取。

//log.js  const log = console.log  export default log  //file.js  export {    readFileSync as read,    writeFileSync as write  } from 'fs'

模組實例

獲取到所有依賴文件並建立好 module map 後,就會找到所有模組記錄,並取出其中的所有導出的變數,然後,將所有變數一一對應到記憶體中,將對應關係存儲到『模組環境記錄』(module environment record)中。當然當前記憶體中的變數並沒有值,只是初始化了對應關係。初始化導出變數和記憶體的對應關係後,緊接著會設置模組導入和記憶體的對應關係,確保相同變數的導入和導出都指向了同一個記憶體區域,並保證所有的導入都能找到對應的導出。

由於導入和導出指向同一記憶體區域,所以導出值一旦發生變化,導入值也會變化,不同於 CommonJS,CommonJS 的所有值都是基於拷貝的。連接到導入導出變數後,我們就需要將對應的值放入到記憶體中,下面就要進入到求值的步驟了。

模組求值

求值步驟相對簡單,只要運行程式碼把計算出來的值填入之前記錄的記憶體地址就可以了。到這裡就已經能夠愉快的使用 ESM 模組化了。

ESM 的進展

因為 ESM 出現較晚,服務端已有 CommonJS 方案,客戶端又有 webpack 打包工具,所以 ESM 的推廣不得不說還是十分艱難的。

客戶端

我們先看看客戶端的支援情況,這裡推薦大家到 Can I Use 直接查看,下圖是 2019/11 的截圖。

目前為止,主流瀏覽器都已經支援 ESM 了,只需在 script 標籤傳入指定的 type="module" 即可。

<script type="module" src="./main.js"></script>

另外,我們知道在 Node.js 中,要使用 ESM 有時候需要用到 .mjs 後綴,但是瀏覽器並不關心文件後綴,只需要 http 響應頭的 MIME 類型正確即可(Content-Type: text/javascript)。同時,當 type="module"時,默認啟用defer 來載入腳本。這裡補充一張 defer、async 差異圖。

我們知道瀏覽器不支援 script 的時候,提供了 noscript 標籤用於降級處理,模組化也提供了類似的標籤。

<script type="module" src="./main.js"></script>  <script nomodule>    alert (' 當前瀏覽器不支援 ESM !!!')  </script>

這樣我們就能針對支援 ESM 的瀏覽器直接使用模組化方案載入文件,不支援的瀏覽器還是使用 webpack 打包的版本。

<script type="module" src="./src/main.js"></script>  <script nomodule src="./dist/app.[hash].js"></script>

預載入

我們知道瀏覽器的 link 標籤可以用作資源的預載入,比如我需要預先載入 main.js 文件:

<link rel="preload" href="./main.js"></link>

如果這個 main.js 文件是一個模組化文件,瀏覽器僅僅預先載入單獨這一個文件是沒有意義的,前面我們也說過,一個模組化文件下載後還需要轉化得到模組記錄,進行模組實例、模組求值這些操作,所以我們得想辦法告訴瀏覽器,這個文件是一個模組化的文件,所以瀏覽器提供了一種新的 rel 類型,專門用於模組化文件的預載入。

<link rel="modulepreload" href="./main.js"></link>

現狀

雖然主流瀏覽器都已經支援了 ESM,但是根據 chrome 的統計,有用到 <script type="module"> 的頁面只有 1%。截圖時間為 2019/11

服務端

瀏覽器能夠通過 script 標籤指定當前腳本是否作為模組處理,但是在 Node.js 中沒有很明確的方式來表示是否需要使用 ESM,而且 Node.js 中本身就已經有了 CommonJS 的標準模組化方案。就算開啟了 ESM,又通過何種方式來判斷當前入口文件導入的模組到底是使用的 ESM 還是 CommonJS 呢?為了解決上述問題,node 社區開始出現了 ESM 的相關草案,具體可以在 github 上查閱。

2017 年發布的 Node.js 8.5.0 開啟了 ESM 的實驗性支援,在啟動程式時,加上 --experimental-modules 來開啟對 ESM 的支援,並將 .mjs 後綴的文件當做 ESM 來解析。早期的期望是在 Node.js 12 達到 LTS 狀態正式發布,然後期望並沒有實現,直到最近的 13.2.0 版本才正式支援 ESM,也就是取消了 --experimental-modules 啟動參數。具體細節可以查看 Node.js 13.2.0 的 官方文檔。

關於 .mjs 後綴社區有兩種完全不同的態度。支援的一方認為通過文件後綴區分類型是最簡單也是最明確的方式,且社區早已有類似案例,例如,.jsx 用於 React 組件、.ts 用於 ts 文件;而支援的一方認為,.js 作為 js 後綴已經存在這麼多年,視覺上很難接受一個 .mjs 也是 js 文件,而且現有的很多工具都是以 .js 後綴來識別 js 文件,如果引入了 .mjs 方案,就有大批量的工具需要修改來有效的適配 ESM。

所以除了 .mjs 後綴指定 ESM 外,還可以使用 pkg.json 文件的 type 屬性。如果 type 屬性為 module,則表示當前模組應使用 ESM 來解析模組,否則使用 CommonJS 解析模組。

{    "type": "module" //module | commonjs (default)  }

當然有些本地文件是沒有 pkg.json 的,但是你又不想使用 .mjs 後綴,這時候只需要在命令行加上一個啟動參數 --input-type=module。同時 input-type 也支援 commonjs 參數來指定使用 CommonJS(-—input-type=commonjs)。

總結一下,Node.js 中,以下三種情況會啟用 ESM 的模組載入方式:

  1. 文件後綴為 .mjs;
  2. pkg.json 中 type 欄位指定為 module
  3. 啟動參數添加 --input-type=module

同樣,也有三種情況會啟用 CommonJS 的模組載入方式:

  1. 文件後綴為 .cjs;
  2. pkg.json 中 type 欄位指定為 commonjs
  3. 啟動參數添加 --input-type=commonjs

雖然 13.2 版本去除了 --experimental-modules 的啟動參數,但是按照文檔的說法,在 Node.js 中使用 ESM 依舊是實驗特性。

Stability: 1 – Experimental

不過,相信等到 Node.js 14 LTS 版本發布時,ESM 的支援應該就能進入穩定階段了,這裡還有一個 Node.js 關於 ESM 的整個 計劃列表 可以查閱。

參考

nodejs/modules

Module specifiers: what』s new with ES modules?

圖說 ES Modules(ES modules: A cartoon deep-dive)