前端歷史項(xiàng)目的 Vite 遷移實(shí)踐總結(jié)
當(dāng)前,前端社區(qū)用 Vite 替代 Webpack 的呼聲正日趨高漲。但對(duì)于長(zhǎng)期維護(hù)的業(yè)務(wù)項(xiàng)目,很多同學(xué)可能仍然對(duì)上車存有疑慮——Vite 真的足夠支撐非玩具級(jí)的項(xiàng)目嗎?為此本文會(huì)分享一個(gè)實(shí)際案例,介紹我們是如何(比較輕松地)在公司核心業(yè)務(wù)中落地 Vite 的。
稿定 Web 端業(yè)務(wù)中的平面編輯器已經(jīng)有五年以上的歷史。作為一個(gè)歷經(jīng)多人主導(dǎo)維護(hù)的前端項(xiàng)目,它有這么一些復(fù)雜度:
- 編輯器使用基于 Yarn workspace 和 Lerna 的宏倉(cāng)庫(kù)來管理源碼,其中有近 20 個(gè) package,初始化時(shí)會(huì)加載超過 400 個(gè)模塊,并有 2GB 以上的 node_modules 依賴。
- 編輯器模塊最早使用 Vue 0.8 和 AMD 模塊語(yǔ)法 ,歷經(jīng) Vue 1.x 和 2.x 時(shí)代維護(hù)至今。Webpack 也是從無到有,再?gòu)?1.x 一路升級(jí)到了現(xiàn)在的 4.x 版本。
- 編輯器內(nèi)的部分高級(jí)渲染功能,用到了 Worker 和 WASM 的能力。
- 編輯器整體作為單個(gè) NPM 包發(fā)布到公司私有倉(cāng)庫(kù)上供業(yè)務(wù)接入,有獨(dú)立的打包和發(fā)版流程。
編輯器在 2016 年的第一次提交,基于 Vue 0.8 和 AMD 語(yǔ)法
我們不敢說這就是所謂的「大型企業(yè)級(jí)」項(xiàng)目,但這至少肯定不是個(gè)玩具項(xiàng)目。然而超乎預(yù)期的是,「Vite 的遷移成本甚至比升級(jí) Webpack 和 Babel 大版本還要低」。只花了一個(gè)下午的時(shí)間,基于 Vite 的編輯器最小可用 MVP 就跑起來了。下面分幾點(diǎn)介紹相關(guān)的實(shí)踐經(jīng)驗(yàn):
- 如何規(guī)劃基本的遷移思路,以及一些基礎(chǔ)的知識(shí)儲(chǔ)備。
- 如何通過編寫插件來解決一些 Webpack loader 的問題。
- 如何遷移常見的 Webpack 配置。
- 如何處理上游依賴問題。
知識(shí)背景與思路
我們知道,以 Webpack 為代表的主流前端 bundler 之所以慢,根源在于它們冷啟動(dòng)時(shí)必須遞歸打包出整個(gè)項(xiàng)目的依賴樹,并受限于 JavaScript 的天性(解釋執(zhí)行與單線程模型)而存在吞吐量上的瓶頸。為了解決這兩個(gè)痛點(diǎn),Vite 另起爐灶切換了路線:
- 對(duì)于項(xiàng)目中的業(yè)務(wù)模塊,Vite 利用現(xiàn)代瀏覽器內(nèi)置的 ES Module 支持,由瀏覽器直接向 dev server 逐個(gè)請(qǐng)求加載這些模塊——因此你往往可以看到本地環(huán)境下大量的 HTTP 請(qǐng)求刷屏,這也是 Vite 最鮮明的特征。
- 對(duì)于項(xiàng)目中的 node_modules 依賴,Vite 借助 esbuild 這類由原生語(yǔ)言開發(fā)的高性能 bundler,將這些庫(kù)中非 ESM 標(biāo)準(zhǔn)(CommonJS 或 UMD)的模塊整體打包為 ESM,即所謂的 Dependency Pre-Bundling。這個(gè)過程的打包結(jié)果具備緩存,并且冷啟動(dòng)重建緩存的效率也極高。
Vite 的這個(gè)設(shè)計(jì)與 webpack-dev-server 之間的區(qū)別,在其文檔中也已經(jīng)展示得很清楚,一圖勝千言:
Webpack 式的經(jīng)典 bundler 示意圖
Vite 式的 No-bundler 示意圖
基于這個(gè)差異我們就可以知道,要讓 Vite 支持原有的 Webpack 項(xiàng)目,需要保證的無非兩件事:
- 確保業(yè)務(wù)模塊源碼均符合 ESM 規(guī)范。
- 確保依賴均可正確被 esbuild 處理。
當(dāng)然這只是最簡(jiǎn)單的思維模型。實(shí)際的前端項(xiàng)目中往往還會(huì)引入一些奇怪的東西,比如 CSS、JSON、Worker、WASM、HTML 模板……雖然 Vite 對(duì)這些需求已經(jīng)內(nèi)建了良好的支持,但確實(shí)誰(shuí)也不敢保證能一鍵開箱即用——這并不是 Vite 或 Webpack 的問題,而是移植代碼構(gòu)建環(huán)境時(shí)的共通難點(diǎn)。對(duì)這類任務(wù),「最難的地方總在于從零到一的「點(diǎn)亮」」。因此這里對(duì)此的建議是這樣的:「充分熟悉從項(xiàng)目入口到各組件渲染完成之間所經(jīng)歷的代碼(子)樹,確保這一個(gè)最小的子集能夠在新環(huán)境下正常運(yùn)作」。其他代碼都可以大刀闊斧地暫時(shí)移除掉。
對(duì)于架構(gòu)設(shè)計(jì)合理的軟件項(xiàng)目,一般都可以容易地實(shí)現(xiàn)模塊的精簡(jiǎn)和擴(kuò)展。例如在這個(gè)編輯器中,我們就支持了可配置并按需加載的元素類型。對(duì)于現(xiàn)有的 20 余種業(yè)務(wù)元素,它們對(duì)應(yīng)的模塊都已經(jīng)支持了按需加載,只會(huì)在遇到相應(yīng)數(shù)據(jù)時(shí) import() 導(dǎo)入。因此在遷移時(shí),只需保留若干基礎(chǔ)元素模塊實(shí)現(xiàn)用于測(cè)試即可。類似地,在業(yè)務(wù)項(xiàng)目中也可以通過精簡(jiǎn)路由配置等方式,定制出一個(gè)用于走通主流程的最小可用版本。
自定義插件實(shí)現(xiàn)
上述的代碼精簡(jiǎn)過程,其實(shí)不外乎是建立一個(gè)干凈的 example 頁(yè)面來導(dǎo)入項(xiàng)目,注釋掉部分代碼然后反復(fù)執(zhí)行 vite 命令測(cè)試,這里不再贅述。對(duì)于 Vite 遷移,很多同學(xué)最擔(dān)憂的可能還是 Webpack 插件兼容性方面的問題。我們恰好也遇到了類似的問題,這里簡(jiǎn)單分享一下。
在前面 2016 年的編輯器上古版本代碼截圖中有一個(gè)細(xì)節(jié),那就是其中引入了 editor.html 作為組件的 HTML 模板。這個(gè)行為歷經(jīng)多年一直保留到了現(xiàn)在——也就是說這里沒有使用 SFC 單文件組件,而是對(duì) text-element.js 等組件配套放一個(gè) text-element.html 作為其模板,像這樣:
// 導(dǎo)入 HTML 源碼 --code秘密花園
import TextElementTpl from './text-element.html'
// Vue 2.0 的經(jīng)典配置 --code秘密花園
export default {
template: TextElementTpl,
methods: {
// ...
},
created() {
// ...
}
}
在 Webpack 配置中,我們一般會(huì)用 HTML loader 來支持它,那么 Vite 呢?這類需求似乎并沒有內(nèi)置,而現(xiàn)在社區(qū)的 vite-plugin-html 是為 EJS 模板設(shè)計(jì)的,star 數(shù)量好像也不多……但真的就要等社區(qū)做現(xiàn)成的給你嗎?
其實(shí),Vite 的插件系統(tǒng)是直接依賴 rollup 的。對(duì)于這個(gè)需求,只要這樣在 vite.config.js 里寫個(gè)幾行的插件就夠了:
// 使用 rollup 附帶的 plugin utils --ConardLi
const { createFilter, dataToEsm } = require('@rollup/pluginutils');
function createMyHTMLPlugin() {
// 建立一個(gè)用于篩選模塊的 filter
const filter = createFilter(['**/*.html']);
return {
name: 'vite-plugin-my-html', // 起個(gè)名字 --ConardLi
// 根據(jù) id 來篩選模塊,并在遇到匹配的模塊時(shí)變換其 source
transform(source, id) {
if (!filter(id)) return;
// 這樣 HTML 字符串就能被 export default 給其他 JS 模塊了
return dataToEsm(source);
},
};
}
// 這樣就可以按照 Vite 的標(biāo)準(zhǔn) API 來使用插件了
module.exports = {
plugins: [createMyHTMLPlugin()],
}
這個(gè) createMyHTMLPlugin 不就是個(gè)非常簡(jiǎn)單的函數(shù)而已嗎?但它卻切實(shí)地解決了一個(gè)實(shí)際問題。個(gè)人認(rèn)為對(duì)用戶友好的構(gòu)建系統(tǒng)應(yīng)該做到在大多數(shù)時(shí)候能開箱即用,并能通過簡(jiǎn)單的邏輯自行擴(kuò)展。在這一點(diǎn)上,可以說 Vite 還是做得相當(dāng)出色的。另外 Vite 相比 Snowpack 的一個(gè)主要區(qū)別,就是它的插件系統(tǒng)與 Rollup 有更深的集成,由此實(shí)現(xiàn)了在 dev 和 build 兩種模式下通用的插件 API。因此在業(yè)務(wù)中,也有機(jī)會(huì)自行「套殼」一些成熟的 Rollup 插件來實(shí)現(xiàn)需求。
常見 Webpack 配置遷移
在這次實(shí)踐中用到的 Vite 配置相當(dāng)少,值得一提的主要是這么幾條:
- 通過 resolve.alias 配置,可以覆寫(或者說劫持)掉模塊路徑。注意最好盡量讓這個(gè)配置少一點(diǎn),濫用它容易降低代碼模塊結(jié)構(gòu)對(duì)工具鏈的友好性。
- 通過 define 配置,可以支持 process.env.__DEV__ 這樣的環(huán)境變量注入。注意 Vite 會(huì)把字符串直接注入成產(chǎn)物代碼中的 raw expression,所以如果只想傳遞 true 這種簡(jiǎn)單常量,要額外 JSON.stringify 包一層。
- 通過 vite-plugin-vue2 可以支持 Vue 2.0 的 SFC。這里的理由在于雖然編輯器內(nèi)的主要組件沒有使用 SFC,但測(cè)試頁(yè)面的 demo 入口是個(gè) app.vue。通過這個(gè)插件,可以讓它們良好地共存。
- Less 和 CSS 依賴了 Vite 的內(nèi)置支持,沒有引入額外的配置。當(dāng)然另一種變通方案是先執(zhí)行獨(dú)立打包 CSS 的命令,然后 import "./dist.css" 即可。
- 通過 import Worker from "worker.js?worker" 的語(yǔ)法,可以支持 Web Worker。另外也可以進(jìn)一步將其配合 resolve.alias 配置,來繼續(xù)兼容 Webpack。
- 對(duì)于 WASM,除了形如 import init from "./a.wasm" 的內(nèi)置支持以外,還有一種實(shí)踐是讓 WASM 的 JS 適配層支持傳入可配置的 WASM 路徑,這方面比較典型的例子可以參考 CanvasKit 等包。
上游依賴問題處理
基于上面介紹的這些實(shí)踐,應(yīng)當(dāng)已經(jīng)足夠解決 Vite 對(duì)各類業(yè)務(wù)模塊的加載問題了。但最后還有一個(gè)比較頭疼的地方:如果 node_modules 中的依賴不能被 esbuild 正確打包,又該怎么辦呢?
在這次遷移中,這樣的問題我們有遇到兩處,各自的原因有所不同:
- 圖片重采樣庫(kù) Pica 依賴了一個(gè)簡(jiǎn)易的 Web Worker 轉(zhuǎn)換庫(kù),它會(huì)直接在模塊代碼頂層讀取 arguments 數(shù)據(jù),導(dǎo)致 esbuild 報(bào)錯(cuò)。
- 字體解析庫(kù) OpenType.js 為了同時(shí)兼容瀏覽器端和 Node,在 ESM 源碼中封裝了若干 require('fs') 的函數(shù)。這也會(huì)導(dǎo)致報(bào)錯(cuò)。
對(duì)于這兩個(gè)問題,其實(shí)都有一種通用的 workaround 手法:「建立一個(gè) third_party 目錄,把存在問題的上游模塊拷貝一份進(jìn)去,在這里修復(fù)問題并調(diào)整模塊依賴即可」。如 Pica 庫(kù)內(nèi) require('./a.js') 的代碼,就可以復(fù)制到 third_party 目錄后,將模塊導(dǎo)入路徑改為 require('pica/src/a.js'),這樣并不需全量復(fù)制整個(gè)上游依賴。而對(duì)于這里遇到的兩個(gè) CommonJS 問題,具體的修復(fù)也都很容易,例如把對(duì) arguments 的讀取放到 export default 的函數(shù)體內(nèi),并直接移除在瀏覽器環(huán)境下用不到的 Node 文件讀取邏輯等。這樣的 third_party 模式實(shí)際上倒也不算什么 hack,在很多語(yǔ)言的工程中有很廣泛的使用,但也有些地方值得注意:
- 建議在改動(dòng)位置添加 // FIXME 之類的注釋,方便接受者確認(rèn)修改之處。
- 如果需要集成很大的上游依賴,那么不建議直接放到代碼庫(kù)里,可以使用 git submodule 或 CDN 等形式。
- 理想情況下應(yīng)當(dāng)向上游反饋 patch,解決問題后移除相應(yīng)的本地版本。
以上就是全部值得列出的問題了,最后放一張基于 Vite 啟動(dòng)本地環(huán)境成功時(shí)的截圖:
上圖的日志有個(gè)問題,即加載了兩個(gè)不同的 Vue 版本。這是因?yàn)?SFC 部分和依賴 HTML 模板的代碼誤用了不同的 Vue 依賴。這個(gè)問題后來通過 alias 配置將 vue 全部重寫到 vue/dist/vue 而解決了。
由于編輯器 SDK 原本就使用 Babel 獨(dú)立發(fā)版,因此原有的 NPM 發(fā)布過程不受影響,Vite 整體的侵入性也并不高。至于最終效果上也沒有什么別的,就是油門踩到底加速了一下:
- Webpack 40 秒以上的 dev server 冷啟動(dòng)時(shí)間縮短到了 1.5 秒內(nèi),在建立 .vite 目錄緩存后,啟動(dòng) vite 命令的時(shí)間僅需約 300 毫秒。
- 修改單個(gè)文件后 2 秒左右的增量編譯時(shí)間被完全優(yōu)化掉了,同時(shí)瀏覽器中加載頁(yè)面的效率并沒有明顯差異。
這樣一來,這個(gè)歷史項(xiàng)目就重新獲得了即時(shí)反饋級(jí)別的開發(fā)體驗(yàn),同時(shí)也讓更高效的 CI 集成成為了可能。這里的想象空間還很大,我們很期待讓 Vite 在未來發(fā)揮出更大的作用。
總結(jié)
- Vite 做到了以低接入代價(jià)換取開發(fā)體驗(yàn)上的大幅提升,有望引領(lǐng)前端構(gòu)建工具領(lǐng)域的下一波 paradigm shift 浪潮。按 ROI 的話說,「其落地的潛在收益遠(yuǎn)大于成本」。
- 實(shí)際業(yè)務(wù)中的代碼應(yīng)當(dāng)盡量貼合標(biāo)準(zhǔn),少使用需依賴工具鏈黑魔法的特性,以換取更好的后向兼容性。
- 對(duì)于代碼移植,實(shí)踐中其實(shí)還有很多(未必上得了臺(tái)面的)奇技淫巧,比如正則替換、編寫 codemod 和為下游業(yè)務(wù)提供 deprecated API 檢測(cè)腳本等等——捫心自問,把抄來代碼里的 var 全部查找替換成 let 這種事你干過沒有?這些手段并沒有什么高下之分,能簡(jiǎn)單方便地解決問題就好。
- JavaScript 本身哪怕作為編譯后的產(chǎn)物,仍然是易讀、易修改,且易向上游 backport 反饋的。主流的編譯型語(yǔ)言都不容易做到這一點(diǎn)——類似于你把 DLL 里函數(shù)符號(hào)的機(jī)器碼或 Java class 文件里的字節(jié)碼改完,馬上就能照著 diff 直接去給上游庫(kù)提 PR。這是黑魔法的源頭,可能也是種前端的「道路自信」吧。
實(shí)際上作為本文的作者,之前個(gè)人還嘗試過一些類似的代碼移植。這類工作就像是一個(gè)破解密室逃脫游戲的過程,非常有趣。個(gè)人感覺像這次的 Vite 遷移,在實(shí)踐手段上其實(shí)和之前的經(jīng)歷都是相當(dāng)共通的:
- 將 1995 年世界上最早的 JS 引擎源碼編譯回JavaScript
- 將 Dart VM 從 Flutter 中抽離出來,單獨(dú)在 iOS 原生項(xiàng)目中使用
- 為國(guó)產(chǎn)掌機(jī)搭建嵌入式 Linux 工具鏈,把 QuickJS 引擎移植上去
所以最后,非常鼓勵(lì)大家多做興趣驅(qū)動(dòng)的技術(shù)嘗試。沒準(zhǔn)未來的哪天,折騰它們的經(jīng)驗(yàn)就能幫助你找到抓手,賦能業(yè)務(wù),形成閉環(huán),打出一套組合拳呢