ESModule 系列 (二):構(gòu)建下一代基礎(chǔ)設(shè)施 PDN
借助包的分發(fā)服務(wù),我們甚至能將本地安裝依賴的速度提升10倍
ESM包的分發(fā)
什么是ESM包的分發(fā)?參考一下下面的幾個網(wǎng)站
https://esm.sh/[1] https://cdn.skypack.dev/[2] https://jspm.org/[3]
簡單來講,這些站點都做了一件事情:將 npm 倉庫上的包轉(zhuǎn)化成支持 esmodule 的版本并通過 url 來進(jìn)行分發(fā)。
為什么需要分發(fā)
為了迎合瀏覽器的發(fā)展浪潮。隨著 ECMAScript 2015提出ECMAScript Module 規(guī)范以來,各個瀏覽器都在積極地推進(jìn)著瀏覽器模塊系統(tǒng)的實現(xiàn)?,F(xiàn)今(2021年),各個主流瀏覽器已經(jīng)基本全面實現(xiàn)并內(nèi)置了ESModule系統(tǒng),為了更好的利用以往用CMD或者AMD規(guī)范開發(fā)的眾多 NPM 包,ESM包的分發(fā)網(wǎng)站應(yīng)運而生。ESM可以替換掉之前使用UMD加載組件庫(或其他包)的場景隨著 HTTP 2/3的發(fā)展,5G 網(wǎng)絡(luò)的普及,網(wǎng)絡(luò)延時在 Web 交互中的權(quán)重會不斷的降低,而上一代 Web 開發(fā)范式(即利用 bundle 工具如 webpack 等將源代碼打成一個大的 bundle )會逐漸被瀏覽器原生的模塊加載機(jī)制所取代借助 CDN ,可以對一個特定版本的 NPM 包 轉(zhuǎn)化而來的 ESM 包做永久存儲。因為對于 NPM 的每一個包都會有版本號控制,版本號不變內(nèi)容就不會變。而一個 package@version 一旦轉(zhuǎn)化成 ESM 包后就可以被永久化存儲 可以配合 Esbuild 等新一代構(gòu)建工具提升本地依賴的安裝速度(定一個小目標(biāo):提速20倍)
原理
將一個 NPM 包轉(zhuǎn)化為一個支持 ESM 規(guī)范的包,需要做的其實就是針對模塊語法進(jìn)行升級,將傳統(tǒng)的 ADM/CMD/UMD 語法,通過 AST 的解析,將其轉(zhuǎn)化為 ESModule 語法。
困境
模塊語法的轉(zhuǎn)化,不同于用 babel 將 ES6 轉(zhuǎn)化為 ES5,從 ES6 到 ES5 是語法上的降級,而從 ADM/CMD/UMD 模塊語法到 ESM 語法的轉(zhuǎn)化,是屬于語法的升級,升級過程中勢必會遇到很多語法兼容問題。
CMD模塊語法的動態(tài)導(dǎo)入導(dǎo)出問題
眾所周知,Commonjs 模塊語法是動態(tài)執(zhí)行的,即 require() 執(zhí)行之后拿到的模塊有哪些屬性,只有代碼真正執(zhí)行到 require 函數(shù)調(diào)用的那一行時才能知道,而 ESModule 模塊語法規(guī)范中,模塊的引入和導(dǎo)出在源代碼執(zhí)行之前就已經(jīng)通過靜態(tài)語法解析完成。
// exports.cjs
module.exports = {}
// require.cjs
console.info('start require')
const { keyA } = require('./exports.cjs')
console.info('require done')
// log
start require
require done
[CJS]
// exports.mjs
export default {
KeyA,
keyB,
}
// imports.mjs
console.info('start import')
import { keyA } from './exports.mjs'
console.info('import done')
// log
error, 'keyA' is not exported by './exports.mjs'
[ESM]
可以看到,ESM 模塊語法在代碼執(zhí)行前就會通過靜態(tài)語法檢測,解析出子模塊的具名導(dǎo)出變量和默認(rèn)導(dǎo)出變量,然后會根據(jù)導(dǎo)入語法,在代碼真正執(zhí)行前先進(jìn)行一次校驗,如果引入了錯誤的變量,會直接拋出錯誤;而 CJS 模塊語法不會預(yù)先進(jìn)行語法檢測,而是運行源代碼,運行到 require 函數(shù)被調(diào)用時才會去處理子模塊的導(dǎo)出。而 CJS 和 ESM 的模塊導(dǎo)出機(jī)制也是不同的。在 CJS 中, module.exports 和 exports 對象其實是同一個引用,即,不論用戶用什么語法來導(dǎo)出屬性,最終導(dǎo)出的屬性全是掛在了一個對象的引用上,而其他模塊引用這個模塊時,require 執(zhí)行之后拿到的其實就是這個引用對象。而在 ESM 中,export default 和 export {} 屬于兩種完全不同的導(dǎo)出語法,通過默認(rèn)導(dǎo)出語法 export default 導(dǎo)出的值,只能通過 import A 或者 import { default as A } 來導(dǎo)入,通過具名導(dǎo)出語法 export { A } 導(dǎo)出的值,只能通過 import { A } 導(dǎo)入。這兩種導(dǎo)入導(dǎo)出方式不能混用,若錯誤使用,瀏覽器底層會直接拋出錯誤,而在 CJS 中,由于導(dǎo)出的值一直是一個對象,所以通過 require 引入模塊時,是不會拋出語法錯誤的(除非模塊不存在)。而目前生態(tài)最成熟的 ESM 轉(zhuǎn)化工具比如 Rollup 和 Esbuild,他們對于 CJS 模塊的轉(zhuǎn)化支持也不是很友好。
// react.production.js
module.exports = {
createElement,
...React
}
// react.production.transpiled.mjs
const ReactLib = _commonjs(() => {
return {
createElement,
... React
}
})
export default ReactLib
[React的ESM轉(zhuǎn)化]
可以看到,React 的 cjs 代碼經(jīng)過 Rollup 或者 Esbuild 轉(zhuǎn)化之后,會直接被編譯成只有一個默認(rèn)導(dǎo)出的模塊,通過這樣的轉(zhuǎn)化,在使用 React 時,會與我們常規(guī)的使用習(xí)慣有所沖突。
// Success
import React from 'react.production.traspiled.mjs'
React.createElement(xxx)
// Error: 'createElement' is not exported from 'react.production.traspiled.mjs'
import { createElement } from 'react.production.traspiled.mjs'
循環(huán)引入,動態(tài)引入語法在 ESM 中沒有與 CMD 對等的語法轉(zhuǎn)化
在 CJS 中,由于 require 本身就是動態(tài)的同步函數(shù),所以 CJS 本身是支持動態(tài)引入的,而在 ESM 中,原生不支持同步的動態(tài)引入,想要在 ESM 中使用動態(tài)引入語法,只能通過 import().then() 的異步引入來模擬。但是這兩者其實語法并不能做等價,其中,require 是同步執(zhí)行的語法,返回結(jié)果是引入的對象;而 import() 是異步執(zhí)行的語法,返回結(jié)果是一個 Promise
// cjs
module.exports = {
Module: require('Module')
}
// esm
import Module from 'Module'
export default {
Module
}
[非嚴(yán)格意義上的動態(tài)引入轉(zhuǎn)化]
通過以上方案轉(zhuǎn)化來的動態(tài)引入,原語義是希望在使用的時候再引用,而轉(zhuǎn)化之后的 ESM 語法將其變?yōu)榱?,先引用,再使用,可能?dǎo)致 'Module' 模塊內(nèi)部實例化未完成的情況下就已經(jīng)被使用,導(dǎo)致出現(xiàn) Module.xxx is not defined 的問題。
比如 protobufjs,參考 https://cdn.skypack.dev/-/[email protected]/dist=es2020,mode=imports/optimized/protobufjs.js[4]

共享 Context 重復(fù)打包的問題
由 CMD 轉(zhuǎn)化為 ESM 的過程中,分發(fā)網(wǎng)絡(luò)通常會使用 Rollup 等工具,將依賴包的源代碼全部打包到一起,最后提供一個 ESM 單文件,這樣可以顯著的減少網(wǎng)絡(luò)請求量(比如,請求 antd 包,如果不打包源碼,可能需要遞歸引入 antd/es/** 下的所有文件,這樣網(wǎng)絡(luò)請求數(shù)量可能達(dá)到數(shù)百級別)。
import * as Module from 'antd.mjs'
同樣的,如果引用 ESM 包的不同路徑文件時,比如 [email protected]/es/index.js 和 [email protected]/esm/components/core/update , 若這兩個路徑的 ESM 單文件中引用了同樣的 Context (比如 React Context),那么最終每個路徑的文件里面都會包含一份 Context 的代碼,這就導(dǎo)致最終的運行結(jié)果不符合預(yù)期。
// [email protected]/es/index.js
import Context from '/common/Context'
Context.setContext({ ... })
// [email protected]/esm/components/core/update
import Context from '/common/Context'
Context.setContext({ ... })
// ESM 轉(zhuǎn)化結(jié)果
// [email protected]/es/index.js
Context = React.createContext()
Context.setContext()
//[email protected]/esm/components/core/update
Context = React.createContext()
Context.setContext()
可以看到,以上兩個同 ESM 包的不同路徑,但是打包了兩份一樣的 Context。
其他問題...
解決方案
通過 AST 等方案,直接動態(tài)解析出所有
exports.xxx和Object.definedProperty(exports, 'xxx')等語句,手動將其編譯成具名導(dǎo)出語法export { xxx }通過在
Node.js中模擬一個Browser Context,在 Context 中嘗試調(diào)用require('Module'),通過 CJS 加載方式拿到模塊的導(dǎo)出對象,將其手動編譯成具名導(dǎo)出和默認(rèn)導(dǎo)出方案
with (BrowserContext) {
try {
const Module = require(ModuleName)
code += `\n export {`
Object.keys(Module).forEach(namedExport => {
code += `${namedExport}, `
})
code += '}'
} catch (e) {}
}
通過動態(tài)白名單的方式,針對有動態(tài)引入的 NPM 包,在轉(zhuǎn)化成 ESM 包之前,首先用 Webpack 將其 bundle 一次,然后在進(jìn)行 ESM 轉(zhuǎn)化。
通過動態(tài)白名單的方式,針對有共享 Context 的 NPM 包,不再打包所有源碼
其他解決方案...
在漫長的踩坑與實踐中,我們內(nèi)部已經(jīng)基本實現(xiàn)了 NPM 包轉(zhuǎn)化 ESM 的分發(fā)服務(wù)(相比較市面上的分發(fā)服務(wù),該服務(wù)將轉(zhuǎn)化過程中遇到的問題進(jìn)一步抽象,實現(xiàn)了一層修復(fù)層,可以支持動態(tài)修復(fù))。
下一代開發(fā)工具
前幾期我們已經(jīng)有同學(xué)介紹了如何開發(fā)一個 unbudnled 開發(fā)工具;在這里,「下一代」開發(fā)工具指的就是「unbundle」開發(fā)工具,下面要講的,就是圍繞「unbundle」這個詞。
原理
目前市面上流行的 unbundle 開發(fā)工具,比如 Vite,Snowpack,它們的底層核心架構(gòu)基本都是一致的,即將源碼與第三方依賴分開單獨做處理。
在 dev server 啟動前,開發(fā)工具首先會遍歷源碼目錄,解析每個源碼文件 AST 中所有的 ImportDeclaration,拿到所有的第三方依賴路徑;然后將解析出的第三方依賴路徑作為 entryPoints 傳入傳統(tǒng)的構(gòu)建工具(Webpack,Rollup,Esbuild 等),打出一個多入口的另類 node_modules,在這個 node_modules 中,除了傳入的 entryPoints 繼續(xù)作為目標(biāo)文件存在外,其他的公共依賴部分都會被打成一個大的 chunks。

[原始node_modules]

[bundle后的node_modules]
在 node_modules 處理完之后,接下來工具對源碼不會做任何處理,直接啟動 dev server,通常在 unbundle 開發(fā)工具中,默認(rèn)的首頁模板通常會包含下面這樣的代碼
<script type="module" src="/index.js" />
這樣,在用戶訪問首頁時,已經(jīng)實現(xiàn)了 ECMAScript Module 機(jī)制的瀏覽器會自動去請求 /index.js 文件,請求會被 dev server 做攔截,同時代理到源代碼中的 src/index.js 文件上。
優(yōu)勢
基于瀏覽器的 ESModule 加載機(jī)制,開發(fā)工具可以不用在每次啟動 dev server 時都去打包源代碼,基于這個思路,將第三方依賴和源代碼區(qū)分開,對第三方依賴單獨打包,而且由于第三方依賴是持久不變的,可以一次打包,次次使用(不新增新的依賴的情況下)。
在這種架構(gòu)下,當(dāng)?shù)谌揭蕾囈呀?jīng)被預(yù)處理之后的情況下,理論上每次啟動 dev server 的時間可以達(dá)到秒級,對于傳統(tǒng)的構(gòu)建工具(Webpack,Rollup),開發(fā)服務(wù)器的啟動速度可以說是提升了2個數(shù)量級。
思考
與分發(fā)服務(wù)結(jié)合,不安裝依賴,快速開發(fā)
試想一下,在 Snowpack / Vite 的基礎(chǔ)之上。我如果直接在源代碼里面引用一個沒有安裝在本地的依賴,然后 dev server 直接連接到 ESM 分發(fā)服務(wù),直接使用線上的包,同時檢測一下這個依賴的版本,自動更新到 package.json 中,并在后臺自動運行 install 進(jìn)程。
在這個過程中,我沒有安裝新的依賴,但是可以直接在源代碼中使用,所見即所得,無需等待。同時在開發(fā)過程中,這個依賴也會經(jīng)由開發(fā)工具自動檢測并安裝到本地,在后續(xù) dev server 重啟的過程中會自動同步最新的本地依賴狀況。
快速安裝依賴
上一點說到,可以通過將 ESM 包分發(fā)服務(wù)與下一代開發(fā)工具結(jié)合,來實現(xiàn)本地開發(fā)體驗的巨大飛躍。更激進(jìn)一點,能不能通過 ESM 包的服務(wù)直接干掉 node_modules,或者說,換一個更精簡,更快就能安裝下來的 node_modules 呢?答案當(dāng)然是肯定的。
通過分析 Vite 和 Snowpack 的源碼,可以發(fā)現(xiàn),這一類開發(fā)工具底層處理 node_modules 的方案,都是通過 Rollup / Esbuild,傳入 entryPoints 的方式來對 node_moduels 進(jìn)行預(yù)處理,從而構(gòu)建出一個全 ESM 化的 node_modules。
那么我們可以直接在這一步的基礎(chǔ)之上,通過開發(fā) Rollup / Esbuild 插件,將讀取本地文件的過程全部代理到 ESM 包的分發(fā)服務(wù)上去。而由于 ESM 包的分發(fā)服務(wù)對每個包的處理是將包的源碼進(jìn)行打包,因此在文件數(shù)量上會呈現(xiàn)數(shù)十倍的下降;而打包結(jié)果會永久存儲到CDN上,等于一次安裝,永久使用,相較于本地npm安裝依賴時每次都需要下載依賴的整個 zip 包,網(wǎng)絡(luò) I/O 的耗時也會呈現(xiàn)數(shù)倍的下降。
基于這樣一種思路實現(xiàn)的依賴安裝工具,不僅可以完整還原 node_moduels 的目錄結(jié)構(gòu),而且安裝速度相較于 yarn/npm/pnpm ,也會有數(shù)倍的提升,尤其是在有鎖文件的情況下,安裝速度提升十倍也不是不可能。

[沒有鎖文件的情況下,通過 yarn 安裝依賴的速度]

[沒有鎖文件的情況下,通過上述方案安裝依賴的速度]

[有鎖文件的情況下,通過通過 yarn 安裝依賴的速度]

[有鎖文件的情況下,通過上述方案安裝依賴的速度]
目前,新一代依賴管理工具和新一代開發(fā)工具的工作還處于初期,整個工程還有巨大的優(yōu)化空間,包括安裝速度的進(jìn)一步提升,對本地緩存的進(jìn)一步利用,對 monorepo 的支持等...
后續(xù)的進(jìn)展我們會持續(xù)與大家進(jìn)行分享;當(dāng)然,如果屏幕前的你對這些工作有興趣,歡迎掃描下方的二維碼加入我們一起建設(shè)。
參考資料
https://esm.sh/: https://esm.sh/
[2]https://cdn.skypack.dev/: https://cdn.skypack.dev/
[3]https://jspm.org/: https://jspm.org/
[4]https://cdn.skypack.dev/-/[email protected]/dist=es2020,mode=imports/optimized/protobufjs.js: https://cdn.skypack.dev/-/[email protected]/dist=es2020,mode=imports/optimized/protobufjs.js
內(nèi)推社群
我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。

往期推薦



