前端模塊化


前端模塊化


背景


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

ESM 的出現不同於其他的規範,因為這是 JavaScript 官方推出的模塊化方案,相比於 CommonJS 和 AMD 方案,ESM 採用了完全靜態化的方式進行模塊的加載。

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

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

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

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

模塊的導入使用 ,並配合 關鍵詞。

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

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

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

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

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

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

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

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

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

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

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

CommonJS

ESM

另外,CommonJS 的模塊實現,實際是給每個模塊文件做了一層函數包裹,從而使得每個模塊獲取 、 變量。那上面的 來舉例,實際執行過程中 運行代碼如下:

而 ESM 的模塊是通過 關鍵詞來實現,沒有對應的函數包裹,所以在 ESM 模塊中,需要使用 變量來獲取 。 是 ECMAScript 實現的一個包含模塊元數據的特定對象,主要用於存放模塊的 ,而 node 中只支持加載本地模塊,所以 url 都是使用 協議。

步驟:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

雖然主流瀏覽器都已經支持了 ESM,但是根據 chrome 的統計,有用到 的頁面只有 1%。截圖時間為 。

瀏覽器能夠通過>

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

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

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

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

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

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

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

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

眾所周知,早期 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)

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/__dirname。import.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'

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


前端模塊化


模塊構造

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

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

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

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

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

{

"imports": {

"jQuery": "/node_modules/jquery/dist/jquery.js"

}

}

import $ from 'jQuery'

$(function () {

$('#app').html ('init')

})

下載好的模塊,都會被轉化為模塊記錄然後緩存到 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 了,只需在>

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


前端模塊化


我們知道瀏覽器不支持>

alert (' 當前瀏覽器不支持 ESM !!!')

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

預加載

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

<link>

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

<link>

現狀

雖然主流瀏覽器都已經支持了 ESM,但是根據 chrome 的統計,有用到 <script> 的頁面只有 1%。截圖時間為 2019/11。</p><p><br/></p><div class="pgc-img"><img class="lazy" data-original="http://p1.pstatp.com/large/pgc-image/ab085972051141aabf8e572c234ad341" img_width="1080" img_height="1038" alt="前端模塊化" inline="0"><p class="pgc-img-caption"></p></div><p><br/></p><p>服務端</p><p>瀏覽器能夠通過>


分享到:


相關文章: