相信點進來看文章的都對vite有一定的了解,下面我們一起來實現一個簡易版vite,來加深我們對vite的理解!(PS:建議 clone 項目下來對著看)
開始之前我們先來 預覽效果,啟動了vite服務,用官方vite的react示例代碼作為演示,修改 jsx 和 css 都進行了熱更新。
0. 先搭建基礎服務
首先,需要有一個服務讓我們去訪問項目,新建一個 index.js 文件,用 koa 創建一個http服務。
// index.js
import Koa from 'koa';
const app = new Koa();
// 運行
(function createServer() {
app.listen(12345, () => {
console.log(`listening:12345 port!!!`);
});
})();
ok, 服務建好。
下面我們來創建 ws 服務。因為我們需要hmr, 當你的項目文件發生改變后,通過 ws 去通知客戶端:“喂,你的文件發生改變了,該去更新了!”。
// index.js
import { WebSocketServer } from 'ws';
function createWs() {
const Websocket = new WebSocketServer({ port: 5000 });
Websocket.on('connection', function connection(ws) {
console.log('ws is connected!!');
ws.on('message', function incoming(message) {
console.log('received: %s', message);
});
});
}
ok,很簡單是是吧。
1. hmr
開發怎么少得了 hmr 呢?
這里的 hmr 主要做一個通知工作,通過 chokidar 去監聽文件的變化,然后通過 ws 通知客戶端去進行更新操作,所以更新操作是在客戶端進行,下面會講到。
// 發起熱更新
const launchHmr = (ws, config) => {
// 監聽到文件變化,處理文件
chokidar.watch(resolveOnRoot('src')).on('change', (changePath) => {
changePath = changePath.replace(__dirname, '');
console.log(`${changePath} has changed!!`);
const cssObj = {};
if (changePath.endsWith('.css')) {
cssObj.css = fs.readFileSync(resolveOnRoot(changePath)).toString();
}
// 將更新類型、文件路徑、樣式內容傳給客戶端
ws.send(
JSON.stringify({
type: 'change',
changePath,
...cssObj,
}),
);
});
};
2. 中間件
vite源碼里面有14個中間件,用來處理請求,轉換資源,處理配置、日志等。我這個簡易版不會包含這么多,我主要寫了3個,分別是 baseMiddleware 、indexHtmlMiddleware、transformMiddleware,下面進行逐個講解。
- baseMiddleware 處理請求資源的地址,用來去掉請求時帶上的參數,掛載在ctx對象上面
// 處理地址
export const baseMiddleware = (ctx, next) => {
const requestUrl = ctx.request.url.split('?')[0];
ctx.requestUrl = requestUrl;
next();
};
-
indexHtmlMiddleware 處理入口文件
因為我們手寫 vite 運行起來訪問的是 localhost: port, 所以這里默認為請求 / 為 html入口資源。(PS:這里沒有其他摻雜路由,這個簡單的列子單純讓你加深對 vite 的理解)
說一說 下面的代碼,當用戶請求入口資源時候,服務端會找到入口文件,讀取文件內容,在文件中插入處理樣式(client/style.js)、處理websocket (client/ws.js)和react熱更新(react-refresh)這幾個資源,再返回處理后的 html 資源,資源具體干了什么下面會說,莫急。
export const indexHtmlMiddleware = (ctx, next) => {
if (ctx.requestUrl === '/') {
// 根路徑返回模版 HTML 文件
const html = fs.readFileSync(`${__dirname}/index.html`, 'utf-8');
const importHandleStyle =
'<script type="module" src="/@vite/client/style.js"></script>';
const importHandleWs =
'<script type="module" src="/@vite/client/ws.js"></script>';
const preambleCode = `
<script type="module">
import RefreshRuntime from "/@modules/react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>`;
ctx.type = 'text/html';
ctx.body = `${preambleCode}${importHandleStyle}${importHandleWs}${html}`;
}
next();
};
-
transformMiddleware 處理非html資源,既然上面處理了入口html資源,當然也要處理別的資源啦。
可以看到下面代碼是通過判斷資源請求的后綴或前綴,對各種資源進行處理。分別包含 jsx、node_modules資源、css、svg、vite客戶端 資源。為方便大家看, 我這里已經將方法單獨抽了出來,大家可以看看 utils.js 文件。????傳送門??
簡單說說每個處理資源的方法
① handleJsx: 用 esbuild 對jsx進行轉換,之后再通過封裝 react-refresh插件對jsx文件插入熱更新內容(參考 @vite/plugin-react-refresh)
② handleNodeModules: 處理第三方資源,通過 enbuild 對第三方資源進行打包,打包后放到緩存目錄 node_modules/.cvite,下次訪問就直接取 node_modules/.cvite中的資源。
③ handleCss:讀取樣式文件內容,通過標簽方式插入樣式,插入樣式時候記錄。
④ handleSvg:轉base64。
⑤ 最后一個通過判斷/@vite/client,找到客戶端資源的路徑,返回對應的請求資源。
export const transformMiddleware = (ctx, next) => {
const { requestUrl } = ctx;
if (requestUrl === '/') {
} else if (requestUrl.endsWith('.jsx')) {
handleJsx(ctx, requestUrl);
} else if (requestUrl.startsWith('/@modules/')) {
handleNodeModules(ctx, requestUrl);
} else if (requestUrl.endsWith('.css')) {
handleCss(ctx, requestUrl);
} else if (requestUrl.endsWith('.svg')) {
handleSvg(ctx, requestUrl);
} else if (requestUrl.startsWith('/@vite/client')) {
ctx.type = 'application/javascript';
const filePath = requestUrl.replace('/@vite/client', 'vite/client');
ctx.body = fs.readFileSync(resolveOnRoot(filePath), 'utf8');
} else {
}
next();
};
好了,說完中間件了,最終我們啟動方法為
// 運行
(function createServer() {
createWs();
app.listen(12345, () => {
console.log(`listening:${12345} port!!!`);
});
app.use(baseMiddleware);
app.use(indexHtmlMiddleware);
app.use(transformMiddleware);
})();
3. 再說hmr
接著,我們再補充剛才沒有細講的熱更新。
剛才說了,服務端通過 ws 去通知客戶端進行熱更新,客戶端收到指令后會執行 handleFile 方法。
export function handleFile(path, css) {
const { JSX, CSS, SVG, NODE_MODULE } = FILE_TYPE;
if (checkFileType(path) === CSS) {
// 處理樣式
globalUpdateStyle(path, css);
} else if (checkFileType(path) === JSX) {
// 處理jsx
import(`${path}?${timeParam()}`);
}
-
css熱更新:上面說了,樣式是通過 style標簽 插入的,那我們需要維護一個 Map
window.sheetsMap = new Map();
, 在插入樣式同時,我們去設置 Mapwindow.sheetsMap.set(id, style);
文件路徑作為 id,css內容作為 style。
這樣,當客戶端收到更新 css 的指令后,就會調用globalUpdateStyle(path, css);
去更新樣式資源,實現樣式熱更新。而 globalUpdateStyle方法在 /@vite/client/style.js 文件里面,這個我們在執行 indexHtmlMiddleware 中間件已經加載了該資源的。
export const globalUpdateStyle = (id, content) => {
let style = window.sheetsMap.get(id);
if (style) {
style.innerHTML = content;
} else {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = content;
document.head.appendChild(style);
}
window.sheetsMap.set(id, style);
};
-
react熱更新:react熱更新用了
react-fresh
,在看 vite 源碼時候,發現 vite.config.js 文件里面使用了 @vitejs/plugin-react-refresh,而 vite 在處理 react jsx 時候會調用 transform 方法處理文件。
下面簡單說下,transform 做了些什么。
① 用 babel 和 react-refresh/babel 插件對代碼進行轉換
// 轉換前
function Text1() {
return <div>Test Component1</div>;
}
export default Text1;
// 轉換后
function Text1() {
return /* @__PURE__ */React.createElement("div", null, "Test Component1");
}
_c = Text1;
export default Text1;
var _c;
// 幫我們注冊需要熱更新的組件
$RefreshReg$(_c, "Text1");
② 在文件頭部插入提供注冊熱更新的方法。
const header = `
...
window.$RefreshReg$ = (type, id) => {
RefreshRuntime.register(type, ${JSON.stringify(id)} + " " + id)
};
...
`;
③ 在文件尾部插入刷新組件的方法。
const footer = `
...
window.__vite_plugin_react_timeout = setTimeout(() => {
window.__vite_plugin_react_timeout = 0;
RefreshRuntime.performReactRefresh();
}, 30);
...
`;
ok,處理完畢,當客戶端收到更新 jsx 的指令后,會重新 import 對應的 jsx 文件,加上時間戳參數,確保是新的,然后文件執行RefreshRuntime.performReactRefresh();
進行刷新。
4.錦上添花
主線流程就說完了,你可以做些錦上添花的內容,例如實現讀取vite.config.json
文件的配置,嘗試ssr,資源緩存等等讓他看起來更高級。