ESModule 系列 (二):構(gòu)建下一代基礎(chǔ)設(shè)施 PDN
借助包的分發(fā)服務(wù),我們甚至能將本地安裝依賴的速度提升10倍
ESM包的分發(fā)
什么是ESM包的分發(fā)?參考一下下面的幾個(gè)網(wǎng)站
https://esm.sh/[1] https://cdn.skypack.dev/[2] https://jspm.org/[3]
簡單來講,這些站點(diǎn)都做了一件事情:將 npm 倉庫上的包轉(zhuǎn)化成支持 esmodule 的版本并通過 url 來進(jìn)行分發(fā)。
為什么需要分發(fā)
為了迎合瀏覽器的發(fā)展浪潮。隨著 ECMAScript 2015提出ECMAScript Module 規(guī)范以來,各個(gè)瀏覽器都在積極地推進(jìn)著瀏覽器模塊系統(tǒng)的實(shí)現(xiàn)。現(xiàn)今(2021年),各個(gè)主流瀏覽器已經(jīng)基本全面實(shí)現(xiàn)并內(nèi)置了ESModule系統(tǒng),為了更好的利用以往用CMD或者AMD規(guī)范開發(fā)的眾多 NPM 包,ESM包的分發(fā)網(wǎng)站應(yīng)運(yùn)而生。ESM可以替換掉之前使用UMD加載組件庫(或其他包)的場景隨著 HTTP 2/3的發(fā)展,5G 網(wǎng)絡(luò)的普及,網(wǎng)絡(luò)延時(shí)在 Web 交互中的權(quán)重會(huì)不斷的降低,而上一代 Web 開發(fā)范式(即利用 bundle 工具如 webpack 等將源代碼打成一個(gè)大的 bundle )會(huì)逐漸被瀏覽器原生的模塊加載機(jī)制所取代借助 CDN ,可以對一個(gè)特定版本的 NPM 包 轉(zhuǎn)化而來的 ESM 包做永久存儲。因?yàn)閷τ?NPM 的每一個(gè)包都會(huì)有版本號控制,版本號不變內(nèi)容就不會(huì)變。而一個(gè) package@version 一旦轉(zhuǎn)化成 ESM 包后就可以被永久化存儲 可以配合 Esbuild 等新一代構(gòu)建工具提升本地依賴的安裝速度(定一個(gè)小目標(biāo):提速20倍)
原理
將一個(gè) NPM 包轉(zhuǎn)化為一個(gè)支持 ESM 規(guī)范的包,需要做的其實(shí)就是針對模塊語法進(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)化,是屬于語法的升級,升級過程中勢必會(huì)遇到很多語法兼容問題。
CMD模塊語法的動(dòng)態(tài)導(dǎo)入導(dǎo)出問題
眾所周知,Commonjs 模塊語法是動(dòng)態(tài)執(zhí)行的,即 require() 執(zhí)行之后拿到的模塊有哪些屬性,只有代碼真正執(zhí)行到 require 函數(shù)調(diào)用的那一行時(shí)才能知道,而 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í)行前就會(huì)通過靜態(tài)語法檢測,解析出子模塊的具名導(dǎo)出變量和默認(rèn)導(dǎo)出變量,然后會(huì)根據(jù)導(dǎo)入語法,在代碼真正執(zhí)行前先進(jìn)行一次校驗(yàn),如果引入了錯(cuò)誤的變量,會(huì)直接拋出錯(cuò)誤;而 CJS 模塊語法不會(huì)預(yù)先進(jìn)行語法檢測,而是運(yùn)行源代碼,運(yùn)行到 require 函數(shù)被調(diào)用時(shí)才會(huì)去處理子模塊的導(dǎo)出。而 CJS 和 ESM 的模塊導(dǎo)出機(jī)制也是不同的。在 CJS 中, module.exports 和 exports 對象其實(shí)是同一個(gè)引用,即,不論用戶用什么語法來導(dǎo)出屬性,最終導(dǎo)出的屬性全是掛在了一個(gè)對象的引用上,而其他模塊引用這個(gè)模塊時(shí),require 執(zhí)行之后拿到的其實(shí)就是這個(gè)引用對象。而在 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)出方式不能混用,若錯(cuò)誤使用,瀏覽器底層會(huì)直接拋出錯(cuò)誤,而在 CJS 中,由于導(dǎo)出的值一直是一個(gè)對象,所以通過 require 引入模塊時(shí),是不會(huì)拋出語法錯(cuò)誤的(除非模塊不存在)。而目前生態(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)化之后,會(huì)直接被編譯成只有一個(gè)默認(rèn)導(dǎo)出的模塊,通過這樣的轉(zhuǎn)化,在使用 React 時(shí),會(huì)與我們常規(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)引入,動(dòng)態(tài)引入語法在 ESM 中沒有與 CMD 對等的語法轉(zhuǎn)化
在 CJS 中,由于 require 本身就是動(dòng)態(tài)的同步函數(shù),所以 CJS 本身是支持動(dòng)態(tài)引入的,而在 ESM 中,原生不支持同步的動(dòng)態(tài)引入,想要在 ESM 中使用動(dòng)態(tài)引入語法,只能通過 import().then() 的異步引入來模擬。但是這兩者其實(shí)語法并不能做等價(jià),其中,require 是同步執(zhí)行的語法,返回結(jié)果是引入的對象;而 import() 是異步執(zhí)行的語法,返回結(jié)果是一個(gè) Promise
// cjs
module.exports = {
Module: require('Module')
}
// esm
import Module from 'Module'
export default {
Module
}
[非嚴(yán)格意義上的動(dòng)態(tài)引入轉(zhuǎn)化]
通過以上方案轉(zhuǎn)化來的動(dòng)態(tài)引入,原語義是希望在使用的時(shí)候再引用,而轉(zhuǎn)化之后的 ESM 語法將其變?yōu)榱耍纫茫偈褂茫赡軐?dǎo)致 'Module' 模塊內(nèi)部實(shí)例化未完成的情況下就已經(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ò)通常會(huì)使用 Rollup 等工具,將依賴包的源代碼全部打包到一起,最后提供一個(gè) ESM 單文件,這樣可以顯著的減少網(wǎng)絡(luò)請求量(比如,請求 antd 包,如果不打包源碼,可能需要遞歸引入 antd/es/** 下的所有文件,這樣網(wǎng)絡(luò)請求數(shù)量可能達(dá)到數(shù)百級別)。
import * as Module from 'antd.mjs'
同樣的,如果引用 ESM 包的不同路徑文件時(shí),比如 [email protected]/es/index.js 和 [email protected]/esm/components/core/update , 若這兩個(gè)路徑的 ESM 單文件中引用了同樣的 Context (比如 React Context),那么最終每個(gè)路徑的文件里面都會(huì)包含一份 Context 的代碼,這就導(dǎo)致最終的運(yùn)行結(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()
可以看到,以上兩個(gè)同 ESM 包的不同路徑,但是打包了兩份一樣的 Context。
其他問題...
解決方案
通過 AST 等方案,直接動(dòng)態(tài)解析出所有
exports.xxx和Object.definedProperty(exports, 'xxx')等語句,手動(dòng)將其編譯成具名導(dǎo)出語法export { xxx }通過在
Node.js中模擬一個(gè)Browser Context,在 Context 中嘗試調(diào)用require('Module'),通過 CJS 加載方式拿到模塊的導(dǎo)出對象,將其手動(dòng)編譯成具名導(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) {}
}
通過動(dòng)態(tài)白名單的方式,針對有動(dòng)態(tài)引入的 NPM 包,在轉(zhuǎn)化成 ESM 包之前,首先用 Webpack 將其 bundle 一次,然后在進(jìn)行 ESM 轉(zhuǎn)化。
通過動(dòng)態(tài)白名單的方式,針對有共享 Context 的 NPM 包,不再打包所有源碼
其他解決方案...
在漫長的踩坑與實(shí)踐中,我們內(nèi)部已經(jīng)基本實(shí)現(xiàn)了 NPM 包轉(zhuǎn)化 ESM 的分發(fā)服務(wù)(相比較市面上的分發(fā)服務(wù),該服務(wù)將轉(zhuǎn)化過程中遇到的問題進(jìn)一步抽象,實(shí)現(xiàn)了一層修復(fù)層,可以支持動(dòng)態(tài)修復(fù))。
下一代開發(fā)工具
前幾期我們已經(jīng)有同學(xué)介紹了如何開發(fā)一個(gè) unbudnled 開發(fā)工具;在這里,「下一代」開發(fā)工具指的就是「unbundle」開發(fā)工具,下面要講的,就是圍繞「unbundle」這個(gè)詞。
原理
目前市面上流行的 unbundle 開發(fā)工具,比如 Vite,Snowpack,它們的底層核心架構(gòu)基本都是一致的,即將源碼與第三方依賴分開單獨(dú)做處理。
在 dev server 啟動(dòng)前,開發(fā)工具首先會(huì)遍歷源碼目錄,解析每個(gè)源碼文件 AST 中所有的 ImportDeclaration,拿到所有的第三方依賴路徑;然后將解析出的第三方依賴路徑作為 entryPoints 傳入傳統(tǒng)的構(gòu)建工具(Webpack,Rollup,Esbuild 等),打出一個(gè)多入口的另類 node_modules,在這個(gè) node_modules 中,除了傳入的 entryPoints 繼續(xù)作為目標(biāo)文件存在外,其他的公共依賴部分都會(huì)被打成一個(gè)大的 chunks。

[原始node_modules]

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

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

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

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

[有鎖文件的情況下,通過上述方案安裝依賴的速度]
目前,新一代依賴管理工具和新一代開發(fā)工具的工作還處于初期,整個(gè)工程還有巨大的優(yōu)化空間,包括安裝速度的進(jìn)一步提升,對本地緩存的進(jìn)一步利用,對 monorepo 的支持等...
后續(xù)的進(jìn)展我們會(huì)持續(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
后臺回復(fù):typescript,獲取我寫的 typescript 系列文章,絕對精品 后臺回復(fù):電子書,自動(dòng)獲取我為大家整理的大量經(jīng)典電子書,省去你篩選以及下載的時(shí)間 后臺回復(fù):不一樣的前端,自動(dòng)獲取精選優(yōu)質(zhì)前端文章。 后臺回復(fù):算法,自動(dòng)獲取精選算法文章。另外也可關(guān)注我的另外一個(gè)專注算法的公眾號力扣加加。 后臺回復(fù):每日一薦,自動(dòng)獲取我為大家總結(jié)的每日一薦月刊,內(nèi)含精品文章,實(shí)用技巧,高效工具等等

