願景
希望通過本文,能給讀者提供一個存/增量專案接入Vite的點子,起拋磚引玉的作用,減少這方面能力的建設成本
在闡述過程中同時也會逐漸完善webpack-vite-serve這個工具
讀者可直接fork這個工具倉庫,針對個人/公司專案場景進行定製化的二次開發
背景
在當下的業務開發中處處可見webpack的身影,大部分的業務專案採用的構建工具也都是它。
隨著時間的推移,存量老專案體積越來越大,開發啟動(dev)/構建(build) 需要的時間越來越長。針對webpack的優化手段越來越有限。
於是乎某些場景出現了用其它語言寫的工具,幫助構建/開發提效。如SWC(Rust),esbuild(Go)
當然上述工具並不是一個完整的構建工具,不能取代webpack直接使用,只是通過plugin,為webpack工作提效
當下另一種火熱的方案是bundleless
,利用瀏覽器原生支援ES Module
的特性,讓瀏覽器接管"打包"工作,工具只負責對瀏覽器請求的資源進行相應的轉換,從而極大的減少服務的啟動時間,提升開發體驗與開發幸福感
本文的主角就是Vite
:下一代前端開發與構建工具
由於Vite
的周邊還處於建設期,要完全替代webpack,還需要一定時日,為了保證存量線上專案的穩定性,Vite
作為一個開發時可選的能力接入是比較推薦的一個做法。
# webpack devServer
npm run dev
# Vite devServer
npm run vite
目標
為webpack專案開發環境提供最簡單的Vite接入方案
待接入專案只需要做極小的變動就能享受到Vite
帶來的開發樂趣
方案
- 做一個CLI工具,封裝Vite啟動專案的能力
- 將Vite相關的配置全部收斂於外掛內,自動將webpack配置轉化為Vite配置
- 對外提供一些可選引數,用於手動指定配置檔案的位置
demo效果
Vue SPA
React SPA
在最簡單的Demo工程中,Vite的啟動/HMR速度也是明顯比webpack快不少的
其它常見專案型別的demo也會逐漸的完善到原始碼倉庫中
實現
1. 初始化工程
完整的工程結構移步倉庫
註冊一個啟動方法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
依賴提供的指令,監聽檔案變動,自動將其轉換js
檔案
{
"bin": {
"wvs": "./dist/bin.js"
},
"scripts": {
"dev": "tsc -w -p .",
"build": "rimraf dist && tsc -p ."
},
}
專案根目錄執行npm link
,註冊指令
npm link
測試
wvs start
緊接著我們用Vue-CLI和Create React App分別建立兩個webpack的SPA應用進行接下來的實驗
vue create vue-spa
npx create-react-app react-spa
2. 收斂Vite啟動
Vite
的啟動比較簡單,只需要執行vite
這個指令就行s
在我們的CLI工具裡使用spawn建立子程式啟動Vite
- 其中
cwd
用於指定子程式的工作目錄 - stdio:子程式的標準輸入輸出配置
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);
});
}
這裡為了方便除錯,我們們全域性安裝一下Vite
npm i -g vite
在啟動模板public/index.html
裡新增一個<h1>Hello Vite</h1>
在demo專案裡執行wvs start
開啟對應地址
# vue
http://localhost:3000/
# react
http://localhost:3001/
得到了如下的結果,提示找不到頁面(意料之中)
通過文件得知,Vite會預設尋找index.html
作為專案的入口檔案
這就帶來了第一個要處理的問題,多頁應用下可能有多個模板檔案
如何根據訪問路由動態的指定這個x.html
的入口?
在解決問題之前,我們們再簡單完善一下啟動指令,為其指定一個vite.config.js 配置檔案
通過vite --help
,可以看到通過--config
引數指定配置檔案位置
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',
});
}
這裡指向配置檔案的絕對路徑
config/vite.ts
import { defineConfig } from 'vite';
module.exports = defineConfig({
plugins: [],
optimizeDeps: {},
});
3. html模板處理
擴充Vite的能力就是定製各種的外掛,根據外掛文件
編寫一個簡單的plugin
,利用configServer
鉤子,讀取瀏覽器發起的資源請求
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(),
]
});
再次啟動服務觀察
- 訪問
http://localhost:3000
,終端中輸出/
- 訪問
http://localhost:3000/path1/path2
,終端中輸出/path1/path2
- 訪問
http://localhost:3000/path1/path2?param1=123
,終端中輸出/path1/path2?param1=123
在 devTools皮膚內容中可以看到,第一個資源請求頭上的Accept
欄位中帶有text/html,application/xhtml+xml
等內容,我們們就以這個欄位表明請求的是html
文件
再次修改一下處理資源請求的程式碼
import { readFileSync } from 'fs';
import path from 'path';
import { URL } from 'url';
function loadHtmlContent(reqPath) {
// 單頁預設 public/index.html
const tplPath = 'public/index.html';
// 可以根據請求的path:reqPath 作進一步的判斷
return readFileSync(path.resolve(process.cwd(), tplPath));
}
// 省略了前面出現過的程式碼
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中啟動服務,訪問就能正確看到Hello Vite
在終端中會發現一個報錯
UnhandledPromiseRejectionWarning: URIError: URI malformed
開啟模板可以發現是由於有一些其它的內容,裡面包含一些變數,這部分在webpack中是由 html-webpack-plugin外掛處理
<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>
這裡編寫一個簡單的方法對模板先做一些簡單處理(這個方法只處理了當前遇到的這種情況)
/**
* 初始化模板內容(替換 <%= varName %> 一些內容)
*/
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);
}
如果模板中還有複雜的ejs語法可以使用 ejs
庫做進一步處理
import ejs from 'ejs';
/**
* ejs渲染
*/
function transformEjsTpl(html:string, data = {}) {
return ejs.render(html, data);
}
當然如果還有其它未考慮到的case,可根據特定情況,再對模板做進一步的處理
下面將上述編寫的方法整合到外掛中
export default function HtmlTemplatePlugin(): PluginOption {
return {
configureServer(server) {
const { middlewares: app } = server;
app.use(async (req, res, next) => {
// 省略程式碼
if (isHtml) {
const originHtml = loadHtmlContent(pathname);
// 呼叫外掛中的transformIndexHtml 鉤子對模板做進一步處理
const html = await server.transformIndexHtml(req.url, originHtml, req.originalUrl);
res.end(html);
return;
}
next();
});
},
transformIndexHtml(html) {
// data可以傳入模板中包含的一些變數
// 可以再此處獲取webpack配置,做自動轉換
return initTpl(html, {
PUBLIC_URL: '.',
BASE_URL: './',
htmlWebpackPlugin: {
options: {
title: 'App',
},
},
});
},
};
}
到此再次在demo中執行,頁面跑起來了,終端中也無報錯,頁面的模板到此算是處理完畢
有了初始的模板,就意味著我們已經為Vite
提供了頁面的入口,但其中還沒有處理的js/ts
的依賴即 entry
下面將介紹往模板中插入entry
4. 指定entry入口
入口檔名(entryName)通常為(main|index).js|ts|jsx|tsx
- 單頁應用(SPA)中entryBase通常為:
src
- 多頁應用(MPA)中entryBase通常為:
src/pages/${pageName}
利用transformIndexHtml
鉤子往模板中插入<script type="module" src="entryFile"></script>
export default function pageEntryPlugin(): PluginOption {
return {
name: 'wvs-page-entry',
apply: 'serve',
transformIndexHtml(html, ctx) {
return html.replace('</body>', `<script type="module" src="${getPageEntry(ctx.originalUrl)}"></script>
</body>
`);
},
};
}
這裡以SPA為例
function getPageEntry(reqUrl) {
// SPA
const SPABase = 'src';
return getEntryFullPath(SPABase);
}
getEntryFullPath
實現如下
- 先判斷目錄是否存在
- 讀取目錄,遍歷檔案利用正則
/(index|main)\.[jt]sx?$/
判斷檔案是否為目標檔案
const resolved = (...p) => path.resolve(getCWD(), ...p);
const getEntryFullPath = (dirPath) => {
if (!existsSync(resolved(dirPath))) {
return false;
}
// main|index.js|ts|jsx|tsx
const entryName = /(index|main)\.[jt]sx?$/;
const entryNames = readdirSync(resolved(dirPath), { withFileTypes: true })
.filter((v) => {
entryName.lastIndex = 0;
return v.isFile() && entryName.test(v.name);
});
return entryNames.length > 0 ? path.join(dirPath, entryNames[0].name) : false;
};
將這個外掛加入到配置裡
import { pageEntryPlugin } from '../plugins/index';
module.exports = defineConfig({
plugins: [
pageEntryPlugin(),
]
});
啟動demo檢視效果,丟擲了一堆錯誤
wvs start
下面是針對框架特定的處理
React
- React: the content contains invalid JS syntax
React中將帶有jsx語法的js檔案字尾改為jsx,關於直接在js中使用jsx語法的處理方案,見文章:解決Vite-React專案中.js使用jsx語法報錯的問題
- Uncaught ReferenceError: React is not defined
在 react元件頂部引入React
,或引入@vitejs/plugin-react
外掛,同下3處理方案
import React from 'react';
- HMR支援
import react from '@vitejs/plugin-react'
module.exports = defineConfig({
plugins: [
react(),
]
});
Vue
需要新增外掛處理.vue檔案
import vue from '@vitejs/plugin-vue'
module.exports = defineConfig({
plugins: [
vue(),
]
});
同時 @vitejs/plugin-vue
需要 vue
(>=3.2.13)
由於前面採用的是npm link
建立軟連線進行的除錯,配置檔案中會在開發目錄下去查詢Vue依賴,不會在指令執行目錄下查詢,會不斷的丟擲上述問題
這裡在demo專案裡本地安裝我們的依賴,然後在package.json新增相關指令
yarn add file:webpack-vite-service-workspace-path
{
"scripts": {
"vite": "wvs start -f vue"
},
}
Vue
專案中並沒有React
相關依賴,所以在Vue專案中不能引入@vitejs/plugin-react
外掛
可以在指令入口新增框架相關引數判斷處理一下,只引入對應框架的外掛
// src/bin.ts
program.command('start')
.option('-f, --framework <type>', 'set project type [vue/react]')
.action(startCommand);
// src/command/start.ts
export default function startCommand(options:{[key:string]:string}) {
const { framework = '' } = options;
process.env.framework = framework.toUpperCase();
}
// src/config/vite.ts
import react from '@vitejs/plugin-react';
import vue from '@vitejs/plugin-vue';
const extraPlugins: any[] = [
process.env.framework === 'REACT' ? [react()] : [],
process.env.framework === 'VUE' ? [vue()] : [],
];
module.exports = defineConfig({
plugins: [
htmlTemplatePlugin(),
pageEntryPlugin(),
...extraPlugins,
],
});
到此最關鍵的兩個步驟就算完成了
5. 其它工程能力
目前針對webpack常見的能力,社群已經有了許多外掛和方案,下面只做簡單介紹
這些外掛當然也有些場景可能處理不了,還是期望廣大開發者,勇於實驗,然後向外掛作者提交PR/issues
- Sass/Less:在依賴中安裝
Sass/Less
即可 - 元件庫按需引入:vite-plugin-style-import
- process.env:vite-plugin-env-compatible
- window.xx/xx undefined:使用
transformIndexHtml
鉤子開發外掛,在模板中提前引入這個方法的polyfill
或者兜底處理 - ...
總結
企業:大部分是擁有自己的研發框架,在研發框架中只需要加入一個Vite啟動的CLI指令,這樣對接入方的影響與使用成本是最小的
個人:喜歡折騰/不想改動原來的程式碼,可以按上述流程自己接一下,新專案可以直接使用Vite官方模板開發
總之:開發中使用Vite
還是很香的
由於篇幅與時間都有限,文中部分地方只介紹了實現思路,並沒貼上完整程式碼,完整程式碼可在原始碼倉庫中檢視,也可fork
直接進行二次開發
webpack
向vite
配置的轉換這部分的內容將放在下期做介紹