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