我們從 UmiJS 遷移到了 Vite
我們從 UmiJS 遷移到 Vite 已經(jīng)上線半年多了。遷移過(guò)程中也遇到了不少問(wèn)題,好在 Vite 足夠優(yōu)秀,繼承自 Rollup 的插件系統(tǒng),使我們有了自由發(fā)揮空間。目前很多人對(duì) Vite 躍躍欲試,Vite 開發(fā)體驗(yàn)到底怎么樣,今天來(lái)敘敘遷移到 Vite 的親身經(jīng)歷。
先說(shuō)結(jié)論,Vite 已經(jīng)很成熟,強(qiáng)烈建議有條件的可以從 webpack 遷移過(guò)來(lái)。
為什么要放棄 UmiJS
2019 年底,在 Webpack 橫行霸道,各種腳手架琳瑯滿目的時(shí)代選擇了 UmiJS。它配置少、功能多、文檔齊全、持續(xù)更新。一整套的解決方案,非常適合一個(gè)大部分非 React 技術(shù)棧的團(tuán)隊(duì)。經(jīng)過(guò)不斷地磨合,團(tuán)隊(duì)很快適應(yīng)了這種 React 開發(fā)模式,開發(fā)效率也是水漲船高。
凡事總有個(gè)原因,為什么要遷移。2021 年初,為適應(yīng)公司的發(fā)展,前端架構(gòu)也需要做調(diào)整與升級(jí)。在項(xiàng)目日益增長(zhǎng)的情況下,一次項(xiàng)目啟動(dòng)需要耗費(fèi)一分多鐘,熱更新也慢得基本無(wú)法使用。差點(diǎn)的機(jī)器配置啟動(dòng)項(xiàng)目要么好幾分鐘、要么內(nèi)存溢出。這種模式極大地降低了開發(fā)效率。無(wú)論是自定義修改內(nèi)部 webpack 插件、從各種角度如多核編譯、緩存等方式優(yōu)化,依然是杯水車薪。雖然 UmiJS 提供了 webpack5 插件,不過(guò)在當(dāng)時(shí)處于不可用的狀態(tài)。
我們主要的矛盾是:
- 啟動(dòng)時(shí)間長(zhǎng)
- 熱更新慢
- 太臃腫
- 框架 BUG 修復(fù)不及時(shí)
- 過(guò)度封裝,自定義插件難度大
- 約定式功能太單一
適應(yīng)業(yè)務(wù)的要求,我們也需要上微前端。UmiJS 也提供了微前端插件 “乾坤”。但依然解決不了根本開發(fā)體驗(yàn)問(wèn)題。因此,在基礎(chǔ)腳手架上,我們尋求更多的是可控性及透明性。(盡管UmiJS 現(xiàn)在已經(jīng)支持Module Federation 的打包提速方案)
為什么是 Vite
市面上的腳手架很多,陣營(yíng)卻很少,大部分是基于 webpack 的上層封裝。webpack 的缺點(diǎn)很明顯,當(dāng)冷啟動(dòng)開發(fā)服務(wù)器時(shí),基于打包器的方式啟動(dòng)必須優(yōu)先抓取并構(gòu)建你的整個(gè)應(yīng)用,然后才能提供服務(wù)。
在瀏覽器 ESM 支持得很普遍得今天,Vite 這種可以稱得上是下一代前端開發(fā)與構(gòu)建工具。在 Vite 中,HMR 是在原生 ESM 上執(zhí)行的。當(dāng)編輯一個(gè)文件時(shí),無(wú)論應(yīng)用大小如何,HMR 始終能保持快速更新。
Vite 這種方式在我們習(xí)慣 webpack 的陰影下顯得尤為驚艷,可以說(shuō) Vite 完美地解決了我們所有的痛點(diǎn)。不過(guò) Vite 也是剛發(fā)布 2.0 不久,踩過(guò)坑的人也是相當(dāng)少。我們便試試 Vite。
前期調(diào)研
遷移的必要條件是在原有的功能下找到替代方案,我們便統(tǒng)計(jì)用到了 UmiJS 中的 API 及特性
UmiJS 配置
- alias - 配置別名(對(duì)應(yīng) resolve.alias)
- base - 設(shè)置路由前綴(對(duì)應(yīng) base)
- define - 用于提供給代碼中可用的變量(對(duì)應(yīng) define)
- outputPath - 指定輸出路徑(對(duì)應(yīng) build.outDir)
- hash - 配置是否讓生成的文件包含 hash 后綴 (Vite 自帶)
- antd - 整合 antd 組件庫(kù) (無(wú)需框架提供,Vite 中可自己引用)
- dva - 整合 dva 數(shù)據(jù)流(此庫(kù)已經(jīng)很久沒有更新了,在 hooks 時(shí)代使用顯得格格不入。我們沒有大量使用,重寫一個(gè)文件很輕松)
- locale - 國(guó)際化插件,用于解決 i18n 問(wèn)題(需要自己實(shí)現(xiàn)國(guó)際化邏輯,都是基于 react-intl 封裝,在 Vite 中實(shí)現(xiàn)無(wú)壓力)
- fastRefresh - 快速刷新(對(duì)應(yīng) @vitejs/plugin-react-refresh 插件)
- dynamicImport - 是否啟用按需加載(路由級(jí)的按需加載,在 Vite 中用 React.lazy 封裝)
- targets - 配置需要兼容的瀏覽器最低版本(對(duì)應(yīng) @vitejs/plugin-legacy 插件)
- theme - 配置 less 變量(對(duì)應(yīng) css.preprocessorOptions.less.modifyVars 配置)
- lessLoader - 設(shè)置 less-loader 配置項(xiàng)(與 theme 配置相同)
- ignoreMomentLocale - 忽略 moment 的 locale 文件(可以通過(guò) alias 設(shè)置別名方式解決)
- proxy - 配置代理能力(對(duì)應(yīng) server.proxy)
- externals - 設(shè)置哪些模塊可以不被打包(對(duì)應(yīng) build.rollupOptions.external)
- copy - 設(shè)置要復(fù)制到輸出目錄的文件或文件夾(對(duì)應(yīng) rollup-plugin-copy)
- mock - 配置 mock 屬性(對(duì)應(yīng) vite-plugin-mock)
- extraBabelPlugins - 配置額外的 babel 插件(對(duì)應(yīng) @rollup/plugin-babel)
通過(guò)配置分析,基本上所有的 UmiJS 配置都可以在 Vite 中找到替代方案。除了配置還有一些約定
UmiJS 中 @/* 路徑,代替方式
- defineConfig({
- resolve: {
- alias: {
- '@/': `${path.resolve(process.cwd(), 'src')}/`,
- },
- },
- });
遷移
Review 現(xiàn)有的代碼,找出可能出問(wèn)題的點(diǎn)并統(tǒng)計(jì)。做前期準(zhǔn)備。跑起來(lái)優(yōu)先:
從頭 Vite 官方模板中創(chuàng)建一個(gè)項(xiàng)目,安裝所需依賴包。UmiJS 內(nèi)置封裝了 react-router、antd react-intl,這里我們需要手動(dòng)加上 BrowserRouter、ConfigProvider、LocaleProvider
- // App.tsx
- exportdefaultfunction App() {
- return (
- <AppProvider>
- <BrowserRouter>
- <ConfigProvider locale={currentLocale}>
- <LocaleProvider>
- <BasicLayout>
- <Routes />
- </BasicLayout>
- </LocaleProvider>
- </ConfigProvider>
- </BrowserRouter>
- </AppProvider>
- );
- }
根據(jù)之前約定式路由,添加相應(yīng)的路由配置
- exportconst basicRoutes = [
- {
- path: '/',
- exact: true,
- trunk: () =>import('@/pages/index'),
- },
- {
- path: '/login',
- exact: true,
- trunk: () =>import('@/pages/login'),
- },
- {
- path: '/my-app',
- trunk: () =>import('@/pages/my-app'),
- },
- // ...
- ];
路由渲染組件,通過(guò) React.lazy 實(shí)現(xiàn) UmiJS 中的 dynamicImport
- const routes = basicRoutes.map(({ trunk, ...config }) => {
- const Trunk = React.lazy(() => trunk());
- return {
- ...config,
- component: (
- <React.Suspense fallback={<Spinner />}>
- <Trunk />
- </React.Suspense>
- ),
- };
- });
- exportdefaultfunction Routes() {
- return (
- <Switch>
- {routes.map((route) => (
- <Route key={route.key || route.path} path={route.path} exact={route.exact} render={() => route.component} />
- ))}
- </Switch>
- );
- }
從原先的約定式路由遷移完成,項(xiàng)目中主要不兼容的地方就是從 umi 導(dǎo)入的成員
- import { useIntl, history, useLocation, useSelector } from'umi';
我們需要將所有 umi 中導(dǎo)入的變量,通過(guò)編輯器的正則替換批量修改替換。
- 國(guó)際化的 useIntl 通過(guò)將語(yǔ)言文件和 react-intl 封裝,導(dǎo)出一個(gè)全局的 formatMessage 方法
- 路由相關(guān)的 API 用 react-router-dom 導(dǎo)出替換
- Redux 相關(guān)的,用 react-redux 導(dǎo)出替換
- 查找項(xiàng)目中使用 require 的地方,替換為動(dòng)態(tài) import
- 查找項(xiàng)目中使用 process.env.NODE_ENV,替換為 import.meta.env.DEV,因?yàn)樵?Vite 中不再有 node.js 相關(guān)的 API
將 antd 添加進(jìn)項(xiàng)目后,發(fā)現(xiàn) babel-plugin-import 對(duì)應(yīng)的 Vite 插件似乎有問(wèn)題,某些樣式在 dev 模式下缺失,打包后正常。排查發(fā)現(xiàn)是組件包里面引用了 antd,在 dev 模式下包名被“依賴預(yù)構(gòu)建” 混淆,導(dǎo)致插件無(wú)法正確插入 antd 的樣式。為此,我們自己寫了個(gè)插件,在 dev 模式下全量引入樣式,prod 才走插件。
很輕松,第一個(gè)頁(yè)面成功運(yùn)行。
由于遷移之后需要使用微前端,因此我們將公共配置通過(guò)外置插件統(tǒng)一管理。
- exportdefault defineConfig({
- server: {
- // 每個(gè)項(xiàng)目配置不同的端口號(hào)
- port: 3001,
- },
- plugins: [
- reactRefresh(),
- // 公共配置插件
- baseConfigPlugin(),
- // AntD 插件
- antdPlugin(),
- ],
- });
遷移后發(fā)現(xiàn) Vite 需要配置的其實(shí)很少,抽取的公共配置,封裝成 Vite 插件。
- import path from'path';
- import LessPluginImportNodeModules from'less-plugin-import-node-modules';
- exportdefaultfunction vitePluginBaseConfig(config: CustomConfig): Plugin {
- return {
- enforce: 'post',
- name: 'base-config',
- config() {
- return {
- cacheDir: '.vite',
- resolve: {
- alias: {
- '@/': `${path.resolve(process.cwd(), 'src')}/`,
- lodash: 'lodash-es',
- 'lodash.debounce': 'lodash-es/debounce',
- 'lodash.throttle': 'lodash-es/throttle',
- },
- },
- server: {
- host: '0.0.0.0',
- },
- css: {
- preprocessorOptions: {
- less: {
- modifyVars: {
- '@primary-color': '#f99b0b',
- ...config.theme,
- // 自定義 ant 前綴
- '@ant-prefix': config.antPrefix || 'ant',
- },
- plugins: [new LessPluginImportNodeModules()],
- javascriptEnabled: true,
- },
- },
- },
- };
- },
- };
- }
遷移的整個(gè)過(guò)程沒有想象中那么繁雜,反而相對(duì)容易。幾乎常用的功能 Vite 都有方案支持,這也許是 Vite 的厲害之處吧。其實(shí)本質(zhì)上的復(fù)雜度在于業(yè)務(wù),項(xiàng)目的復(fù)雜度就是代碼量的體現(xiàn),通過(guò) IDE 的搜索替換,很快便完成了遷移并成功的運(yùn)行。
現(xiàn)在,我們所有的項(xiàng)目都基于 Vite,完全沒有了等待而摸魚的煩惱。
問(wèn)題/解決
轉(zhuǎn)換 less 文件 @import '~antd/es/style/themes/default.less' 中的 ~ 別名報(bào)錯(cuò)
配置 less 插件less-plugin-import-node-modules
SyntaxError: The requested module 'xxx' does not provide an export named 'default'
我們將公共組件作為獨(dú)立的 npm 包之后使用時(shí)遇到的錯(cuò)誤。本想著公共組件包自己不編譯,統(tǒng)一交給使用方編譯。所以導(dǎo)出了 TS 源文件。而這種情況常規(guī)下沒有問(wèn)題,Vite 一旦遇到 CommonJS 或 UMD 的包才導(dǎo)致無(wú)法解析。雖然可以將無(wú)法解析的包放入 optimizeDeps.include 。但是架不住包的數(shù)量多啊,還是將它 tsc 轉(zhuǎn)譯為 JS 文件再發(fā)布。
打包提速
首次打包發(fā)現(xiàn)需要 70 多秒,我們來(lái)優(yōu)化打包結(jié)構(gòu)
- 通過(guò) build.minify 改為 esbuild(最新版 Vite 已經(jīng)默認(rèn) esbuild) 。Esbuild 比 terser 快 20-40 倍,壓縮率只差 1%-2%。開啟后降低到 30 多秒
- babel-plugin-import 的類似 babel 插件嚴(yán)重拖后腿,總共不到 40 秒的時(shí)間,它就要占 10 秒。我們通過(guò)正則的方式做了個(gè)插件,完美解決
- 通過(guò)分析 rollup 對(duì) @ant-design/icons 、lodash 包的 transform 數(shù)量非常多。我們將這些包也加入到剛剛做的插件中
通過(guò)一頓操作下來(lái),提速到 16 秒,先這樣吧。
為什么將 cacheDir 放在根目錄
cacheDir 作為存儲(chǔ)緩存文件的目錄。此目錄下會(huì)存儲(chǔ)預(yù)打包的依賴項(xiàng)或 vite 生成的某些緩存文件,使用緩存可以提高性能。在某些情況下需要聯(lián)調(diào) node_modules 里包,從而導(dǎo)致修改后未生效。這時(shí)需要使用 --force 命令行選項(xiàng)或手動(dòng)刪除目錄,放在根目錄便于刪除。
兼容性問(wèn)題
Vite 的兼容性可以通過(guò)官方的插件 @vitejs/plugin-legacy 解決。我們已經(jīng)放棄支持 IE 11,無(wú)限制在生產(chǎn)使用 ESM,羨慕嗎?
結(jié)語(yǔ)
如果你是新的項(xiàng)目,完全不必考慮 Webpack 了,Vite 及 rollup 的完全生態(tài)足夠支撐上生產(chǎn)。如果你是 Webpack 生態(tài)老項(xiàng)目,不忍體驗(yàn)上的折磨,滿足遷移條件的話,不妨試試 Vite,肯定會(huì)帶給你驚喜。
后面我會(huì)分享 Vite 和自己實(shí)現(xiàn)的微前端搭配組合,以及Vite 相關(guān)的插件,請(qǐng)持續(xù)關(guān)注。