分享一個(gè) Webpack 配置轉(zhuǎn) Vite 配置通用方案
愿景
希望通過(guò)本文,能給讀者提供一個(gè)存/增量項(xiàng)目接入Vite的點(diǎn)子,起拋磚引玉的作用,減少這方面能力的建設(shè)成本
在闡述過(guò)程中同時(shí)也會(huì)逐漸完善webpack-vite-serve[1]這個(gè)工具
讀者可直接fork這個(gè)工具倉(cāng)庫(kù),針對(duì)個(gè)人/公司項(xiàng)目場(chǎng)景進(jìn)行定制化的二次開(kāi)發(fā)
背景
在當(dāng)下的業(yè)務(wù)開(kāi)發(fā)中處處可見(jiàn)webpack[2]的身影,大部分的業(yè)務(wù)項(xiàng)目采用的構(gòu)建工具也都是它。
隨著時(shí)間的推移,存量老項(xiàng)目體積越來(lái)越大,開(kāi)發(fā)啟動(dòng)(dev)/構(gòu)建(build) 需要的時(shí)間越來(lái)越長(zhǎng)。針對(duì)webpack的優(yōu)化手段越來(lái)越有限。
于是乎某些場(chǎng)景出現(xiàn)了用其它語(yǔ)言寫(xiě)的工具,幫助構(gòu)建/開(kāi)發(fā)提效。如SWC(Rust)[3],esbuild(Go)[4]
當(dāng)然上述工具并不是一個(gè)完整的構(gòu)建工具,不能取代webpack直接使用,只是通過(guò)plugin,為webpack工作提效
當(dāng)下另一種火熱的方案是bundleless,利用瀏覽器原生支持ES Module的特性,讓瀏覽器接管"打包"工作,工具只負(fù)責(zé)對(duì)瀏覽器請(qǐng)求的資源進(jìn)行相應(yīng)的轉(zhuǎn)換,從而極大的減少服務(wù)的啟動(dòng)時(shí)間,提升開(kāi)發(fā)體驗(yàn)與開(kāi)發(fā)幸福感
比較出名的兩個(gè)產(chǎn)品就是snowpack[5]與Vite[6]
本文的主角就是Vite:下一代前端開(kāi)發(fā)與構(gòu)建工具
由于Vite的周邊還處于建設(shè)期,要完全替代webpack,還需要一定時(shí)日,為了保證存量線(xiàn)上項(xiàng)目的穩(wěn)定性,Vite作為一個(gè)開(kāi)發(fā)時(shí)可選的能力接入是比較推薦的一個(gè)做法。
#?webpack?devServer
npm?run?dev
#?Vite?devServer
npm?run?vite
目標(biāo)
為webpack項(xiàng)目開(kāi)發(fā)環(huán)境提供最簡(jiǎn)單的Vite接入方案
待接入項(xiàng)目只需要做極小的變動(dòng)就能享受到Vite帶來(lái)的開(kāi)發(fā)樂(lè)趣
方案
做一個(gè)CLI工具,封裝Vite啟動(dòng)項(xiàng)目的能力 將Vite相關(guān)的配置全部收斂于插件內(nèi),自動(dòng)將webpack配置轉(zhuǎn)化為Vite配置 對(duì)外提供一些可選參數(shù),用于手動(dòng)指定配置文件的位置
demo效果
Vue SPA

React SPA

在最簡(jiǎn)單的Demo工程中,Vite的啟動(dòng)/HMR速度也是明顯比webpack快不少的
其它常見(jiàn)項(xiàng)目類(lèi)型的demo也會(huì)逐漸的完善到源碼倉(cāng)庫(kù)中
實(shí)現(xiàn)
1. 初始化工程
“完整的工程結(jié)構(gòu)移步倉(cāng)庫(kù)[7]
注冊(cè)一個(gè)啟動(dòng)方法start
src/bin.ts
#!/usr/bin/env?node
import?{?Command?}?from?'commander';
import?{?startCommand?}?from?'./command';
program.command('start')
??.alias('s')
??.action(startCommand);
program.parse(process.argv);
export?default?function?startCommand()?{
??console.log('hello?vite');
}
package.json中添加指令
其中 wvs為自定義的指令npm run dev:利用typescript依賴(lài)提供的指令,監(jiān)聽(tīng)文件變動(dòng),自動(dòng)將其轉(zhuǎn)換js文件
{
??"bin":?{
????"wvs":?"./dist/bin.js"
??},
??"scripts":?{
????"dev":?"tsc?-w?-p?.",
????"build":?"rimraf?dist?&&?tsc?-p?."
??},
}
項(xiàng)目根目錄執(zhí)行npm link,注冊(cè)指令
npm?link
測(cè)試
wvs?start

緊接著我們用Vue-CLI[8]和Create React App[9]分別創(chuàng)建兩個(gè)webpack的SPA應(yīng)用進(jìn)行接下來(lái)的實(shí)驗(yàn)
vue?create?vue-spa

npx?create-react-app?react-spa
2. 收斂Vite啟動(dòng)
Vite的啟動(dòng)比較簡(jiǎn)單,只需要執(zhí)行vite這個(gè)指令就行s

在我們的CLI工具里使用spawn[10]創(chuàng)建子進(jìn)程啟動(dòng)Vite
其中 cwd用于指定子進(jìn)程的工作目錄stdio[11]:子進(jìn)程的標(biāo)準(zhǔn)輸入輸出配置
import?{?spawn?}?from?'child_process';
export?default?function?startCommand()?{
??const?viteService?=?spawn('vite',?['--host',?'0.0.0.0'],?{
????cwd:?process.cwd(),
????stdio:?'inherit',
??});
??viteService.on('close',?(code)?=>?{
????process.exit(code);
??});
}
這里為了方便調(diào)試,咱們?nèi)职惭b一下Vite
npm?i?-g?vite
在啟動(dòng)模板public/index.html里添加一個(gè)Hello Vite
在demo項(xiàng)目里運(yùn)行wvs start

打開(kāi)對(duì)應(yīng)地址
#?vue
http://localhost:3000/
#?react
http://localhost:3001/
得到了如下的結(jié)果,提示找不到頁(yè)面(意料之中)

通過(guò)文檔得知,Vite會(huì)默認(rèn)尋找index.html作為項(xiàng)目的入口文件

這就帶來(lái)了第一個(gè)要處理的問(wèn)題,多頁(yè)應(yīng)用下可能有多個(gè)模板文件
如何根據(jù)訪(fǎng)問(wèn)路由動(dòng)態(tài)的指定這個(gè)x.html的入口?
在解決問(wèn)題之前,咱們?cè)俸?jiǎn)單完善一下啟動(dòng)指令,為其指定一個(gè)vite.config.js 配置文件
通過(guò)vite --help,可以看到通過(guò)--config參數(shù)指定配置文件位置

export?default?function?startCommand()?{
??const?configPath?=?require.resolve('./../config/vite.js');
??const?viteService?=?spawn('vite',?['--host',?'0.0.0.0',?'--config',?configPath],?{
????cwd:?process.cwd(),
????stdio:?'inherit',
??});
}
這里指向配置文件的絕對(duì)路徑
config/vite.ts
import?{?defineConfig?}?from?'vite';
module.exports?=?defineConfig({
??plugins:?[],
??optimizeDeps:?{},
});
3. html模板處理
拓展Vite的能力就是定制各種的插件,根據(jù)插件文檔[12]
編寫(xiě)一個(gè)簡(jiǎn)單的plugin,利用configServer鉤子,讀取瀏覽器發(fā)起的資源請(qǐng)求
import?type?{?PluginOption?}?from?'vite';
export?default?function?HtmlTemplatePlugin():?PluginOption?{
??return?{
????name:?'wvs-html-tpl',
????apply:?'serve',
????configureServer(server)?{
??????const?{?middlewares:?app?}?=?server;
??????app.use(async?(req,?res,?next)?=>?{
????????const?{?url?}?=?req;
????????console.log(url);
????????next();
??????});
????},
??};
}
在上述的配置文件中引入
import?{?htmlTemplatePlugin?}?from?'../plugins/index';
module.exports?=?defineConfig({
??plugins:?[
????htmlTemplatePlugin(),
??]
});
再次啟動(dòng)服務(wù)觀察
訪(fǎng)問(wèn) http://localhost:3000,終端中輸出/訪(fǎng)問(wèn) http://localhost:3000/path1/path2,終端中輸出/path1/path2訪(fǎng)問(wèn) http://localhost:3000/path1/path2?param1=123,終端中輸出/path1/path2?param1=123
在 devTools面板內(nèi)容中可以看到,第一個(gè)資源請(qǐng)求頭上的Accept字段中帶有text/html,application/xhtml+xml等內(nèi)容,咱們就以這個(gè)字段表明請(qǐng)求的是html文檔

再次修改一下處理資源請(qǐng)求的代碼
import?{?readFileSync?}?from?'fs';
import?path?from?'path';
import?{?URL?}?from?'url';
function?loadHtmlContent(reqPath)?{
??//?單頁(yè)默認(rèn)?public/index.html
??const?tplPath?=?'public/index.html';
??//?可以根據(jù)請(qǐng)求的path:reqPath 作進(jìn)一步的判斷
??return?readFileSync(path.resolve(process.cwd(),?tplPath));
}
//?省略了前面出現(xiàn)過(guò)的代碼
app.use(async?(req,?res,?next)?=>?{
??const?{?pathname?}?=?new?URL(req.url,?`http://${req.headers.host}`);
??const?htmlAccepts?=?['text/html',?'application/xhtml+xml'];
??const?isHtml?=?!!htmlAccepts.find((a)?=>?req.headers.accept.includes(a));
??if?(isHtml)?{
????const?html?=?loadHtmlContent(pathname);
????res.end(html);
????return;
??}
??next();
});
再次在demo中啟動(dòng)服務(wù),訪(fǎng)問(wèn)就能正確看到Hello Vite
在終端中會(huì)發(fā)現(xiàn)一個(gè)報(bào)錯(cuò)
UnhandledPromiseRejectionWarning:?URIError:?URI?malformed
打開(kāi)模板可以發(fā)現(xiàn)是由于有一些其它的內(nèi)容,里面包含一些變量,這部分在webpack中是由 html-webpack-plugin[13]插件處理
<link?rel="manifest"?href="%PUBLIC_URL%/manifest.json"?/>
<link?rel="apple-touch-icon"?href="%PUBLIC_URL%/logo192.png"?/>
<link?rel="icon"?href="<%=?BASE_URL?%>favicon.ico">
<title>
??<%=?htmlWebpackPlugin.options.title?%>
title>
這里編寫(xiě)一個(gè)簡(jiǎn)單的方法對(duì)模板先做一些簡(jiǎn)單處理(這個(gè)方法只處理了當(dāng)前遇到的這種情況)
/**
?*?初始化模板內(nèi)容(替換?<%=?varName?%>?一些內(nèi)容)
?*/
function?initTpl(tplStr:string,?data?=?{},?ops?:{
??backup?:string
??matches?:RegExp[]
})?{
??const?{?backup?=?'',?matches?=?[]?}?=?ops?||?{};
??//?match?%Name%?<%Name%>
??return?[/%=?(.*)%>?/g].concat(matches).reduce((tpl,?r)?=>?tpl.replace(r,?(_,?$1)?=>?{
????const?keys?=?$1.trim().split('.');
????const?v?=?keys.reduce((pre,?k)?=>?(pre?instanceof?Object???pre[k]?:?pre),?data);
????return?(v?===?null?||?v?===?undefined)???backup?:?v;
??}),?tplStr);
}
如果模板中還有復(fù)雜的ejs語(yǔ)法可以使用 ejs 庫(kù)做進(jìn)一步處理
import?ejs?from?'ejs';
/**
?*?ejs渲染
?*/
function?transformEjsTpl(html:string,?data?=?{})?{
??return?ejs.render(html,?data);
}
當(dāng)然如果還有其它未考慮到的case,可根據(jù)特定情況,再對(duì)模板做進(jìn)一步的處理
下面將上述編寫(xiě)的方法集成到插件中
export?default?function?HtmlTemplatePlugin():?PluginOption?{
??return?{
????configureServer(server)?{
??????const?{?middlewares:?app?}?=?server;
??????app.use(async?(req,?res,?next)?=>?{
????????//?省略代碼
????????if?(isHtml)?{
??????????const?originHtml?=?loadHtmlContent(pathname);
??????????//?調(diào)用插件中的transformIndexHtml?鉤子對(duì)模板做進(jìn)一步處理
??????????const?html?=?await?server.transformIndexHtml(req.url,?originHtml,?req.originalUrl);
??????????res.end(html);
??????????return;
????????}
????????next();
??????});
????},
????transformIndexHtml(html)?{
??????//?data可以傳入模板中包含的一些變量
??????//?可以再此處獲取webpack配置,做自動(dòng)轉(zhuǎn)換
??????return?initTpl(html,?{
????????PUBLIC_URL:?'.',
????????BASE_URL:?'./',
????????htmlWebpackPlugin:?{
??????????options:?{
????????????title:?'App',
??????????},
????????},
??????});
????},
??};
}
到此再次在demo中運(yùn)行,頁(yè)面跑起來(lái)了,終端中也無(wú)報(bào)錯(cuò),頁(yè)面的模板到此算是處理完畢
有了初始的模板,就意味著我們已經(jīng)為Vite提供了頁(yè)面的入口,但其中還沒(méi)有處理的js/ts的依賴(lài)即 entry
下面將介紹往模板中插入entry
4. 指定entry入口
入口文件名(entryName)通常為(main|index).js|ts|jsx|tsx
單頁(yè)應(yīng)用(SPA)中entryBase通常為: src多頁(yè)應(yīng)用(MPA)中entryBase通常為: src/pages/${pageName}
利用transformIndexHtml鉤子往模板中插入
export?default?function?pageEntryPlugin():?PluginOption?{
??return?{
????name:?'wvs-page-entry',
????apply:?'serve',
????transformIndexHtml(html,?ctx)?{
??????return?html.replace('