願景
希望通過此系列文章,能給讀者提供一個存/增量項目接入Vite的點子,起拋磚引玉的作用,減少這方面能力的建設成本
在闡述過程中同時也會逐漸完善webpack-vite-serve這個工具
讀者可直接fork這個工具倉庫,針對個人/公司項目場景進行定制化的二次開發
前言
在上一篇的文章中,大概介紹了webpack項目接入Vite的處理思路,大體就是以下步驟:
這些內容的處理都是可以通過vite插件實現
webpack-vite-serve介紹
這段時間就在不斷完善這個庫的功能,下面先簡單介紹一下其使用,再闡述一些插件的實現原理
目標:為webpack項目提供一鍵接入Vite的能力
安裝依賴
npm install webpack-vite-serve -D
# or
yarn add webpack-vite-serve -D
# or
pnpm add webpack-vite-serve -D
添加啟動指令
# devServer
wvs start [options]
# build
wvs build [options]
可選參數
-f,--framework <type>
:指定使用的業務框架 (vue,react),自動引入業務框架相關的基礎插件-s,--spa
:按照單頁應用目錄結構處理src/${entryJs}
-m,--mpa
:按照多頁應用目錄結構處理src/pages/${entryName}/${entryJs}
-d,--debug [feat]
:打印debug信息-w,--wp2vite
:使用 wp2vite 自動轉換webpack文件
其它說明
項目遵循常規的 單頁/多頁應用 項目的目錄結構即可
vite配置通過官方的vite.config.[tj]s
配置文件拓展即可
效果
在線體驗demo地址:已創建stackblitz
如由於網絡原因無法訪問,可clone倉庫訪問其中demo體驗
MPA支持
Dev-頁面模板
首先是devServer
環境的頁面模板處理
根據請求路徑獲取entryName
- 使用
/
拆分請求路徑得到paths
- 遍歷尋找第一個
src/pages/${path}
存在的path
,此path即為entryName
function getEntryName(reqUrl:string, cfg?:any) {
const { pathname } = new URL(reqUrl, 'http://localhost');
const paths = pathname.split('/').filter((v) => !!v);
const entryName = paths.find((p) => existsSync(path.join(getCWD(), 'src/pages', p)));
if (!entryName) {
console.log(pathname, 'not match any entry');
}
return entryName || '';
}
尋找模板文件,按照如下順序探尋
src/pages/${entryName}/${entryName}.html
src/pages/${entryName}/index.html
public/${entryName}.html
public/index.html
function loadHtmlContent(reqPath:string) {
// 兜底頁面
const pages = [path.resolve(__dirname, '../../public/index.html')];
// 單頁/多頁默認 public/index.html
pages.unshift(resolved('public/index.html'));
// 多頁應用可以根據請求的 路徑 作進一步的判斷
if (isMPA()) {
const entryName = getEntryName(reqPath);
if (entryName) {
pages.unshift(resolved(`public/${entryName}.html`));
pages.unshift(resolved(`src/pages/${entryName}/index.html`));
pages.unshift(resolved(`src/pages/${entryName}/${entryName}.html`));
}
}
const page = pages.find((v) => existsSync(v));
return readFileSync(page, { encoding: 'utf-8' });
}
Dev-entryJs
多頁應用的entryJs
就按約定讀取src/pages/${entryName}/${main|index}
文件
function getPageEntry(reqUrl) {
if (isMPA()) {
const entryName = getEntryName(reqUrl);
return !!entryName && getEntryFullPath(`src/pages/${entryName}`);
}
// 默認SPA
const SPABase = 'src';
return getEntryFullPath(SPABase);
}
Build
vite構建的入口是html
模板,可以通過build.rollup.input
屬性設置
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
input: {
index: 'src/pages/index/index.html',
second: 'src/pages/second/second.html',
},
},
},
});
按照如上配置,構建產物中的html目錄將會如下
* dist
* src/pages/index/index.html
* src/pages/second/second.html
* assets
不太符合通常的習慣,常規格式如下
* dist
* index.html
* second.html
* assets
所以需要通過插件處理構建入口文件
和調整構建后的產物位置
插件結構
export default function BuildPlugin(): PluginOption {
let userConfig:ResolvedConfig = null;
return {
name: 'wvs-build',
// 只在構建階段生效
apply: 'build',
// 獲取最終配置
configResolved(cfg) {
userConfig = cfg;
},
// 插件配置處理
config() {
},
resolveId(id) {
},
load(id) {
},
// 構建完成后
closeBundle() {
},
};
}
通過configResolved
鈎子獲取最終配置,配置提供給其它鈎子使用
獲取entry
首先獲取src/pages
下所有的entry
const entry = [];
if (isMPA()) {
entry.push(...getMpaEntry());
} else {
// 單頁應用
entry.push({
entryName: 'index',
entryHtml: 'public/index.html',
entryJs: getEntryFullPath('src'),
});
}
entry的定義為
interface Entry{
entryHtml:string
entryName:string
entryJs:string
}
獲取邏輯如下
- 先獲取所有的
EntryName
- 在遍歷獲取每個entry對應的
entryJs
與entryHtml
export function getMpaEntry(baseDir = 'src/pages') {
const entryNameList = readdirSync(resolved(baseDir), { withFileTypes: true })
.filter((v) => v.isDirectory())
.map((v) => v.name);
return entryNameList
.map((entryName) => ({ entryName, entryHtml: '', entryJs: getEntryFullPath(path.join(baseDir, entryName)) }))
.filter((v) => !!v.entryJs)
.map((v) => {
const { entryName } = v;
const entryHtml = [
resolved(`src/pages/${entryName}/${entryName}.html`),
resolved(`src/pages/${entryName}/index.html`),
resolved(`public/${entryName}.html`),
resolved('public/index.html'),
path.resolve(__dirname, '../../public/index.html'),
].find((html) => existsSync(html));
return {
...v,
entryHtml,
};
});
}
生成構建配置
根據得到的entry
生成 build.rollup.input
- 獲取每個
entryHtml
的內容,然后使用map
進行臨時的存儲 - 構建入口模板路徑
htmlEntryPath
取entryJs
的目錄加index.html
實際上htmlEntryPath
這個路徑並不存在任何文件
所以需要通過其它鈎子,利用htmlContentMap
存儲的內容進行進一步的處理
const htmlContentMap = new Map();
// 省略其它無關代碼
{
config() {
const input = entry.reduce((pre, v) => {
const { entryName, entryHtml, entryJs } = v;
const html = getEntryHtml(resolved(entryHtml), path.join('/', entryJs));
const htmlEntryPath = resolved(path.parse(entryJs).dir, tempHtmlName);
// 存儲內容
htmlContentMap.set(htmlEntryPath, html);
pre[entryName] = htmlEntryPath;
return pre;
}, {});
return {
build: {
rollupOptions: {
input,
},
},
};
}
}
構建入口內容生成
其中resolveId
與load
鈎子一起完成入口文件的處理
- 其中
id
即為資源請求的路徑 - 接着直接從
htmlContentMap
去除模板的內容即可
{
load(id) {
if (id.endsWith('.html')) {
return htmlContentMap.get(id);
}
return null;
},
resolveId(id) {
if (id.endsWith('.html')) {
return id;
}
return null;
},
}
產物目錄調整
使用closeBundle
鈎子,在構建完成后,服務關閉前進行文件調整
- 遍歷
entry
將dist/src/pages/entryName/index.html
移動到dist
下 - 移除
dist/src
下的內容
closeBundle() {
const { outDir } = userConfig.build;
// 目錄調整
entry.forEach((e) => {
const { entryName, entryJs } = e;
const outputHtmlPath = resolved(outDir, path.parse(entryJs).dir, tempHtmlName);
writeFileSync(resolved(outDir, `${entryName}.html`), readFileSync(outputHtmlPath));
});
// 移除臨時資源
rmdirSync(resolved(outDir, 'src'), { recursive: true });
}
webpack配置轉換
目前社區有一個CLI工具:wp2vite支持了這個功能,所以筆者不打算從0-1再建設一個
由於是cli工具,沒有提供一些直接調用的方法去獲取轉換前后的配置,所以接入插件中的使用體驗還不是很好,后續准備提PR改造一下這個工具
接入wp2vite的插件實現如下
import wp2vite from 'wp2vite';
// 省略不重要的 import
export default function wp2vitePlugin(): PluginOption {
return {
name: 'wvs-wp2vite',
enforce: 'pre',
async config(_, env) {
const cfgFile = resolved('vite.config.js');
const tplFile = resolved('index.html');
const contentMap = new Map([[cfgFile, ''], [tplFile, '']]);
const files = [cfgFile, tplFile];
console.time('wp2vite');
// 判斷是否存在vite.config.js 、index.html
// 避免 wp2vite 覆蓋
files.forEach((f) => {
if (existsSync(f)) {
contentMap.set(f, readFileSync(f, { encoding: 'utf-8' }));
}
});
// 轉換出配置文件vite.config.js
await wp2vite.start(getCWD(), {
force: false,
// 統一開啟debug
debug: !!process.env.DEBUG,
});
// TODO:提PR優化
// 轉換耗時計算
console.timeEnd('wp2vite');
// 獲取wp2vite轉換出的配置
const cfg = await getUserConfig(env, 'js');
contentMap.forEach((v, k) => {
if (v) {
// 如果修改了內容,還原內容
writeFileSync(k, v);
} else {
// 移除創建的文件
unlinkSync(k);
}
});
if (cfg.config) {
const { config } = cfg || {};
// 留下需要的配置
return {
resolve: config?.resolve,
server: config?.server,
css: config?.css,
};
}
return null;
},
};
}
wp2vite,對外暴露了一個start
方法調用
調用后會根據項目的webpack配置生成2個新文件(vite.config.js
,index.html
),並修改package.json
添加指令與依賴
所以在生成前如果項目中存在這些文件則需要先將這些內容存儲起來
其中獲取用戶配置的getUserConfig
實現如下
import { loadConfigFromFile, ConfigEnv } from 'vite';
export function getUserConfig(configEnv:ConfigEnv, suffix = '') {
const configName = 'vite.config';
const _suffix = ['ts', 'js', 'mjs', 'cjs'];
if (suffix) {
_suffix.unshift(suffix);
}
const configFile = _suffix.map((s) => `${configName}.${s}`).find((s) => existsSync(s));
return loadConfigFromFile(configEnv, configFile);
}
vite提供了loadConfigFromFile
方法,只需要在此方法中做一層簡單的封裝即可直接使用,方法內部使用esbuild自動對ts與es語法進行了轉換
總結
到目前為止,建設的能力已基本能夠滿足常規項目的開發
能力未及之處用戶亦可直接在工程中添加vite
配置文件進行自行的拓展
后續規划
- 目前
wp2vite
在配置轉換這一塊,還不能太滿足使用要求,准備提PR增強一下 - 將內部能力抽成一個個單獨的vite插件