帶你了解前端模塊化的今生
背景
眾所周知,早期 JavaScript 原生并不支持模塊化,直到 2015 年,TC39 發(fā)布 ES6,其中有一個(gè)規(guī)范就是 ES modules(為了方便表述,后面統(tǒng)一簡(jiǎn)稱 ESM)。但是在 ES6 規(guī)范提出前,就已經(jīng)存在了一些模塊化方案,比如 CommonJS(in Node.js)、AMD。ESM 與這些規(guī)范的共同點(diǎn)就是都支持導(dǎo)入(import)和導(dǎo)出(export)語(yǔ)法,只是其行為的關(guān)鍵詞也一些差異。
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)
關(guān)于 JavaScript 模塊化出現(xiàn)的背景在上一章(《前端模塊化的前世》))已經(jīng)有所介紹,這里不再贅述。但是 ESM 的出現(xiàn)不同于其他的規(guī)范,因?yàn)檫@是 JavaScript 官方推出的模塊化方案,相比于 CommonJS 和 AMD 方案,ESM采用了完全靜態(tài)化的方式進(jìn)行模塊的加載。
ESM規(guī)范
模塊導(dǎo)出
模塊導(dǎo)出只有一個(gè)關(guān)鍵詞:export,最簡(jiǎn)單的方法就是在聲明的變量前面直接加上 export 關(guān)鍵詞。
- export const name = 'Shenfq'
可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。
- export function getName() {
- return name
- }
- export class Logger {
- log(...args) {
- console.log(...args)
- }
- }
上面的導(dǎo)出方法也可以使用大括號(hào)的方式進(jìn)行簡(jiǎn)寫。
- const name = 'Shenfq'
- function getName() {
- return name
- }
- class Logger {
- log(...args) {
- console.log(...args)
- }
- }
- export { name, getName, Logger }
最后一種語(yǔ)法,也是我們經(jīng)常使用的,導(dǎo)出默認(rèn)模塊。
- const name = 'Shenfq'
- export default name
模塊導(dǎo)入
模塊的導(dǎo)入使用import,并配合 from 關(guān)鍵詞。
- // main.js
- import name from './module.js'
- // module.js
- const name = 'Shenfq'
- export default name
這樣直接導(dǎo)入的方式,module.js 中必須使用 export default,也就是說(shuō) import 語(yǔ)法,默認(rèn)導(dǎo)入的是default模塊。如果想要導(dǎo)入其他模塊,就必須使用對(duì)象展開的語(yǔ)法。
- // main.js
- import { name, getName } from './module.js'
- // module.js
- export const name = 'Shenfq'
- export const getName = () => name
如果模塊文件同時(shí)導(dǎo)出了默認(rèn)模塊,和其他模塊,在導(dǎo)入時(shí),也可以同時(shí)將兩者導(dǎo)入。
- // main.js
- import name, { getName } from './module.js'
- //module.js
- const name = 'Shenfq'
- export const getName = () => name
- export default name
當(dāng)然,ESM 也提供了重命名的語(yǔ)法,將導(dǎo)入的模塊進(jìn)行重新命名。
- // 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
上述寫法就相當(dāng)于于將模塊導(dǎo)出的對(duì)象進(jìn)行重新賦值:
- // main.js
- import { name, getName } from './module.js'
- const mod = { name, getName }
同時(shí)也可以對(duì)單獨(dú)的變量進(jìn)行重命名:
- // main.js
- import { name, getName as getModName }
導(dǎo)入同時(shí)進(jìn)行導(dǎo)出
如果有兩個(gè)模塊 a 和 b ,同時(shí)引入了模塊 c,但是這兩個(gè)模塊還需要導(dǎo)入模塊 d,如果模塊 a、b 在導(dǎo)入 c 之后,再導(dǎo)入 d 也是可以的,但是有些繁瑣,我們可以直接在模塊 c 里面導(dǎo)入模塊 d,再把模塊 d 暴露出去。
- // module_c.js
- import { name, getName } from './module_d.js'
- export { name, getName }
這么寫看起來(lái)還是有些麻煩,這里 ESM 提供了一種將 import 和 export 進(jìn)行結(jié)合的語(yǔ)法。
- export { name, getName } from './module_d.js'
上面是 ESM 規(guī)范的一些基本語(yǔ)法,如果想了解更多,可以翻閱阮老師的 《ES6 入門》。
ESM 與 CommonJS 的差異
首先肯定是語(yǔ)法上的差異,前面也已經(jīng)簡(jiǎn)單介紹過(guò)了,一個(gè)使用 import/export 語(yǔ)法,一個(gè)使用 require/module 語(yǔ)法。
另一個(gè) ESM 與 CommonJS 顯著的差異在于,ESM 導(dǎo)入模塊的變量都是強(qiáng)綁定,導(dǎo)出模塊的變量一旦發(fā)生變化,對(duì)應(yīng)導(dǎo)入模塊的變量也會(huì)跟隨變化,而 CommonJS 中導(dǎo)入的模塊都是值傳遞與引用傳遞,類似于函數(shù)傳參(基本類型進(jìn)行值傳遞,相當(dāng)于拷貝變量,非基礎(chǔ)類型【對(duì)象、數(shù)組】,進(jìn)行引用傳遞)。
下面我們看下詳細(xì)的案例:
CommonJS
- // a.js
- const mod = require('./b')
- setTimeout(() => {
- console.log(mod)
- }, 1000)
- // b.js
- let mod = 'first value'
- setTimeout(() => {
- mod = 'second value'
- }, 500)
- modmodule.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 的模塊實(shí)現(xiàn),實(shí)際是給每個(gè)模塊文件做了一層函數(shù)包裹,從而使得每個(gè)模塊獲取 require/module、__filename/__dirname 變量。那上面的 a.js 來(lái)舉例,實(shí)際執(zhí)行過(guò)程中 a.js 運(yùn)行代碼如下:
- // a.js
- (function(exports, require, module, __filename, __dirname) {
- const mod = require('./b')
- setTimeout(() => {
- console.log(mod)
- }, 1000)
- });
而 ESM 的模塊是通過(guò) import/export 關(guān)鍵詞來(lái)實(shí)現(xiàn),沒(méi)有對(duì)應(yīng)的函數(shù)包裹,所以在 ESM 模塊中,需要使用 import.meta 變量來(lái)獲取 __filename/__dirname。import.meta 是 ECMAScript 實(shí)現(xiàn)的一個(gè)包含模塊元數(shù)據(jù)的特定對(duì)象,主要用于存放模塊的 url,而 node 中只支持加載本地模塊,所以 url 都是使用 file: 協(xié)議。
- 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)
加載的原理
步驟:
- Construction(構(gòu)造):下載所有的文件并且解析為module records。
- Instantiation(實(shí)例):把所有導(dǎo)出的變量入內(nèi)存指定位置(但是暫時(shí)還不求值)。然后,讓導(dǎo)出和導(dǎo)入都指向內(nèi)存指定位置。這叫做『linking(鏈接)』。
- Evaluation(求值):執(zhí)行代碼,得到變量的值然后放到內(nèi)存對(duì)應(yīng)位置。
模塊記錄
所有的模塊化開發(fā),都是從一個(gè)入口文件開始,無(wú)論是 Node.js 還是瀏覽器,都會(huì)根據(jù)這個(gè)入口文件進(jìn)行檢索,一步一步找到其他所有的依賴文件。
- // Node.js: main.mjs
- import Log from './log.mjs'
- <!-- chrome、firefox -->
- <script type="module" src="./log.js"></script>
值得注意的是,剛開始拿到入口文件,我們并不知道它依賴了哪些模塊,所以必須先通過(guò) js 引擎靜態(tài)分析,得到一個(gè)模塊記錄,該記錄包含了該文件的依賴項(xiàng)。所以,一開始拿到的 js 文件并不會(huì)執(zhí)行,只是會(huì)將文件轉(zhuǎn)換得到一個(gè)模塊記錄(module records)。所有的 import 模塊都在模塊記錄的 importEntries 字段中記錄,更多模塊記錄相關(guān)的字段可以查閱tc39.es。
模塊構(gòu)造
得到模塊記錄后,會(huì)下載所有依賴,并再次將依賴文件轉(zhuǎn)換為模塊記錄,一直持續(xù)到?jīng)]有依賴文件為止,這個(gè)過(guò)程被稱為『構(gòu)造』(construction)。
模塊構(gòu)造包括如下三個(gè)步驟:
- 模塊識(shí)別(解析依賴模塊 url,找到真實(shí)的下載路徑);
- 文件下載(從指定的 url 進(jìn)行下載,或從文件系統(tǒng)進(jìn)行加載);
- 轉(zhuǎn)化為模塊記錄(module records)。
對(duì)于如何將模塊文件轉(zhuǎn)化為模塊記錄,ESM 規(guī)范有詳細(xì)的說(shuō)明,但是在構(gòu)造這個(gè)步驟中,要怎么下載得到這些依賴的模塊文件,在 ESM 規(guī)范中并沒(méi)有對(duì)應(yīng)的說(shuō)明。因?yàn)槿绾蜗螺d文件,在服務(wù)端和客戶端都有不同的實(shí)現(xiàn)規(guī)范。比如,在瀏覽器中,如何下載文件是屬于 HTML 規(guī)范(瀏覽器的模塊加載都是使用的 <script> 標(biāo)簽)。
雖然下載完全不屬于 ESM 的現(xiàn)有規(guī)范,但在 import 語(yǔ)句中還有一個(gè)引用模塊的 url 地址,關(guān)于這個(gè)地址需要如何轉(zhuǎn)化,在 Node 和瀏覽器之間有會(huì)出現(xiàn)一些差異。簡(jiǎn)單來(lái)說(shuō),在 Node 中可以直接 import 在 node_modules 中的模塊,而在瀏覽器中并不能直接這么做,因?yàn)闉g覽器無(wú)法正確的找到服務(wù)器上的 node_modules 目錄在哪里。好在有一個(gè)叫做 import-maps 的提案,該提案主要就是用來(lái)解決瀏覽器無(wú)法直接導(dǎo)入模塊標(biāo)識(shí)符的問(wèn)題。但是,在該提案未被完全實(shí)現(xiàn)之前,瀏覽器中依然只能使用 url 進(jìn)行模塊導(dǎo)入。
- <script type="importmap">
- {
- "imports": {
- "jQuery": "/node_modules/jquery/dist/jquery.js"
- }
- }
- </script>
- <script type="module">
- import $ from 'jQuery'
- $(function () {
- $('#app').html('init')
- })
- </script>
下載好的模塊,都會(huì)被轉(zhuǎn)化為模塊記錄然后緩存到 module map 中,遇到不同文件獲取的相同依賴,都會(huì)直接在 module map 緩存中獲取。
- // log.js
- const log = console.log
- export default log
- // file.js
- export {
- readFileSync as read,
- writeFileSync as write
- } from 'fs'
模塊實(shí)例
獲取到所有依賴文件并建立好 module map 后,就會(huì)找到所有模塊記錄,并取出其中的所有導(dǎo)出的變量,然后,將所有變量一一對(duì)應(yīng)到內(nèi)存中,將對(duì)應(yīng)關(guān)系存儲(chǔ)到『模塊環(huán)境記錄』(module environment record)中。當(dāng)然當(dāng)前內(nèi)存中的變量并沒(méi)有值,只是初始化了對(duì)應(yīng)關(guān)系。初始化導(dǎo)出變量和內(nèi)存的對(duì)應(yīng)關(guān)系后,緊接著會(huì)設(shè)置模塊導(dǎo)入和內(nèi)存的對(duì)應(yīng)關(guān)系,確保相同變量的導(dǎo)入和導(dǎo)出都指向了同一個(gè)內(nèi)存區(qū)域,并保證所有的導(dǎo)入都能找到對(duì)應(yīng)的導(dǎo)出。
模塊連接
由于導(dǎo)入和導(dǎo)出指向同一內(nèi)存區(qū)域,所以導(dǎo)出值一旦發(fā)生變化,導(dǎo)入值也會(huì)變化,不同于 CommonJS,CommonJS 的所有值都是基于拷貝的。連接到導(dǎo)入導(dǎo)出變量后,我們就需要將對(duì)應(yīng)的值放入到內(nèi)存中,下面就要進(jìn)入到求值的步驟了。
模塊求值
求值步驟相對(duì)簡(jiǎn)單,只要運(yùn)行代碼把計(jì)算出來(lái)的值填入之前記錄的內(nèi)存地址就可以了。到這里就已經(jīng)能夠愉快的使用 ESM 模塊化了。
ESM的進(jìn)展
因?yàn)?ESM 出現(xiàn)較晚,服務(wù)端已有 CommonJS 方案,客戶端又有 webpack 打包工具,所以 ESM 的推廣不得不說(shuō)還是十分艱難的。
客戶端
我們先看看客戶端的支持情況,這里推薦大家到 Can I Use 直接查看,下圖是 2019/11的截圖。
目前為止,主流瀏覽器都已經(jīng)支持 ESM 了,只需在 script 標(biāo)簽傳入指定的 type="module" 即可。
- <script type="module" src="./main.js"></script>
另外,我們知道在 Node.js 中,要使用 ESM 有時(shí)候需要用到 .mjs 后綴,但是瀏覽器并不關(guān)心文件后綴,只需要 http 響應(yīng)頭的MIME類型正確即可(Content-Type: text/javascript)。同時(shí),當(dāng) type="module" 時(shí),默認(rèn)啟用 defer 來(lái)加載腳本。這里補(bǔ)充一張 defer、async 差異圖。
我們知道瀏覽器不支持 script 的時(shí)候,提供了 noscript 標(biāo)簽用于降級(jí)處理,模塊化也提供了類似的標(biāo)簽。
- <script type="module" src="./main.js"></script>
- <script nomodule>
- alert('當(dāng)前瀏覽器不支持 ESM ?。?!')
- </script>
這樣我們就能針對(duì)支持 ESM 的瀏覽器直接使用模塊化方案加載文件,不支持的瀏覽器還是使用 webpack 打包的版本。
- <script type="module" src="./src/main.js"></script>
- <script nomodule src="./dist/app.[hash].js"></script>
預(yù)加載
我們知道瀏覽器的 link 標(biāo)簽可以用作資源的預(yù)加載,比如我需要預(yù)先加載 main.js 文件:
- <link rel="preload" href="./main.js"></link>
如果這個(gè) main.js 文件是一個(gè)模塊化文件,瀏覽器僅僅預(yù)先加載單獨(dú)這一個(gè)文件是沒(méi)有意義的,前面我們也說(shuō)過(guò),一個(gè)模塊化文件下載后還需要轉(zhuǎn)化得到模塊記錄,進(jìn)行模塊實(shí)例、模塊求值這些操作,所以我們得想辦法告訴瀏覽器,這個(gè)文件是一個(gè)模塊化的文件,所以瀏覽器提供了一種新的 rel 類型,專門用于模塊化文件的預(yù)加載。
- <link rel="modulepreload" href="./main.js"></link>
現(xiàn)狀
雖然主流瀏覽器都已經(jīng)支持了 ESM,但是根據(jù) chrome 的統(tǒng)計(jì),有用到 <script type="module"> 的頁(yè)面只有 1%。截圖時(shí)間為 2019/11。
服務(wù)端
瀏覽器能夠通過(guò) script 標(biāo)簽指定當(dāng)前腳本是否作為模塊處理,但是在 Node.js 中沒(méi)有很明確的方式來(lái)表示是否需要使用 ESM,而且 Node.js 中本身就已經(jīng)有了 CommonJS 的標(biāo)準(zhǔn)模塊化方案。就算開啟了 ESM,又通過(guò)何種方式來(lái)判斷當(dāng)前入口文件導(dǎo)入的模塊到底是使用的 ESM 還是 CommonJS 呢?為了解決上述問(wèn)題,node 社區(qū)開始出現(xiàn)了 ESM 的相關(guān)草案,具體可以在 github 上查閱。
2017年發(fā)布的 Node.js 8.5.0 開啟了 ESM 的實(shí)驗(yàn)性支持,在啟動(dòng)程序時(shí),加上 --experimental-modules 來(lái)開啟對(duì) ESM 的支持,并將 .mjs 后綴的文件當(dāng)做 ESM 來(lái)解析。早期的期望是在 Node.js 12 達(dá)到 LTS 狀態(tài)正式發(fā)布,然后期望并沒(méi)有實(shí)現(xiàn),直到最近的 13.2.0 版本才正式支持 ESM,也就是取消了 --experimental-modules 啟動(dòng)參數(shù)。具體細(xì)節(jié)可以查看 Node.js 13.2.0 的官方文檔。
關(guān)于 .mjs 后綴社區(qū)有兩種完全不同的態(tài)度。支持的一方認(rèn)為通過(guò)文件后綴區(qū)分類型是最簡(jiǎn)單也是最明確的方式,且社區(qū)早已有類似案例,例如,.jsx 用于 React 組件、.ts 用于 ts 文件;而支持的一方認(rèn)為,.js 作為 js 后綴已經(jīng)存在這么多年,視覺(jué)上很難接受一個(gè) .mjs 也是 js 文件,而且現(xiàn)有的很多工具都是以 .js 后綴來(lái)識(shí)別 js 文件,如果引入了 .mjs 方案,就有大批量的工具需要修改來(lái)有效的適配 ESM。
所以除了 .mjs 后綴指定 ESM 外,還可以使用 pkg.json 文件的 type 屬性。如果 type 屬性為 module,則表示當(dāng)前模塊應(yīng)使用 ESM 來(lái)解析模塊,否則使用 CommonJS 解析模塊。
- {
- "type": "module" // module | commonjs(default)
- }
當(dāng)然有些本地文件是沒(méi)有 pkg.json 的,但是你又不想使用 .mjs 后綴,這時(shí)候只需要在命令行加上一個(gè)啟動(dòng)參數(shù) --input-type=module。同時(shí) input-type 也支持 commonjs 參數(shù)來(lái)指定使用 CommonJS(-—input-type=commonjs)。
總結(jié)一下,Node.js 中,以下三種情況會(huì)啟用 ESM 的模塊加載方式:
- 文件后綴為.mjs;
- pkg.json 中 type 字段指定為 module;
- 啟動(dòng)參數(shù)添加 --input-type=module。
同樣,也有三種情況會(huì)啟用 CommonJS 的模塊加載方式:
- 文件后綴為.cjs;
- pkg.json 中 type 字段指定為 commonjs;
- 啟動(dòng)參數(shù)添加 --input-type=commonjs。
雖然 13.2 版本去除了 --experimental-modules 的啟動(dòng)參數(shù),但是按照文檔的說(shuō)法,在 Node.js 中使用 ESM 依舊是實(shí)驗(yàn)特性。
- Stability: 1 - Experimental
不過(guò),相信等到 Node.js 14 LTS 版本發(fā)布時(shí),ESM 的支持應(yīng)該就能進(jìn)入穩(wěn)定階段了,這里還有一個(gè) Node.js 關(guān)于 ESM 的整個(gè)計(jì)劃列表可以查閱。