<a name="Zktlm"></a>
# 極簡上手指南—手寫mini-vite開發服務器—學習vite源碼
參考爪哇教育-波比老師課程;
<a name="ZJhCE"></a>
## 項目中會使用到的插件:
<a name="FNtbm"></a>
### esno:
可以運行es6的代碼,nodejs的node命令只能運行cmj的代碼,遇到export、import這些會報錯。
<a name="TNYZr"></a>
### chokidar:
參考:[https://www.npmjs.com/package/chokidar](https://www.npmjs.com/package/chokidar)<br />作用,監聽文件變化。做文件熱更新需要。
<a name="v5QnR"></a>
### esbuild:
參考:[https://esbuild.github.io/getting-started/#install-esbuild](https://esbuild.github.io/getting-started/#install-esbuild)<br />作用:快速打包文件。go語言編寫,多線程。
<a name="V00Ru"></a>
## 擼一個mini-vite可以學到:
1、vite開發服務器原理<br />2、esbuild相關知識<br />3、websocket相關知識<br />4、熱更新,chokidar 包監聽文件變化
<a name="hRqyF"></a>
## 整體思路:
先去官網看看vite這東西的介紹,去窺探一下它的實現原理:
<a name="lSaEJ"></a>
### vite是什么?
[官網](https://vitejs.cn/guide/)解釋:
> Vite(法語意為 "快速的",發音 /vit/,發音同 "veet")是一種新型前端構建工具,能夠顯著提升前端開發體驗。它主要由兩部分組成:
> - 一個開發服務器,它基于 [原生 ES 模塊](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) 提供了 [豐富的內建功能](https://vitejs.cn/guide/features.html),如速度快到驚人的 [模塊熱更新(HMR)](https://vitejs.cn/guide/features.html#hot-module-replacement)。
> - 一套構建指令,它使用 [Rollup](https://rollupjs.org/) 打包你的代碼,并且它是預配置的,可輸出用于生產環境的高度優化過的靜態資源。
我們今天寫的是vite的開發服務器模塊,有個重點 “基于原生ES模塊”,需要瀏覽器是能 [在 script 標簽上支持原生 ESM](https://caniuse.com/es6-module) 和 [原生 ESM 動態導入](https://caniuse.com/es6-module-dynamic-import)。
然后去看官網的[為什么選vite](https://vitejs.cn/guide/why.html)里提到
> 1、Vite 以 [原生 ESM](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) 方式提供源碼。這實際上是讓瀏覽器接管了打包程序的部分工作:Vite 只需要在瀏覽器請求源碼時進行轉換并按需提供源碼。根據情景動態導入代碼,即只在當前屏幕上實際使用時才會被處理。
> 2、在 Vite 中,HMR 是在原生 ESM 上執行的。當編輯一個文件時,Vite 只需要精確地使已編輯的模塊與其最近的 HMR 邊界之間的鏈失活[[1]](https://vitejs.cn/guide/why.html#footnote-1)(大多數時候只是模塊本身),使得無論應用大小如何,HMR 始終能保持快速更新。
<a name="POVRg"></a>
### 大致總結一下:
vite作為一個前端開發工具,<br />和webpack一樣它構建了一個本地服務器,當你訪問時,返回相應代碼。<br />和webpack不同的是:<br />webpack會把你寫的所有文件打包成一個個的js、css、html、靜態文件,然后你才能訪問;
但vite利用瀏覽器的esm功能動態返回相應文件,啟動的時候根本不需要去打包你寫的代碼文件,你訪問的時候只返回一個入口文件index.html,然后這個文件里有個script標簽,script標簽設置type為module,這樣瀏覽器會再發一個請求去拿script標簽src對應的文件。然后vite服務器根據這個請求返回相應文件,因為都是靜態文件,沒有什么邏輯交互,所以速度非常快。
那么還有個問題,我寫的是jsx文件,瀏覽器不認識啊!沒事vite服務器會在請求jsx文件的時候,通過esbuild去把jsx轉換成js文件,返回給瀏覽器。esbuild用go語言寫的,速度快到驚人。
<a name="X3mom"></a>
### react工程為例,寫一個mini-vite服務:
我們以react為例,最終需要在客戶端拿到html文件里,包含react里的相關文件。所以我們要先了解如何創建react-app,參考:[http://www.lxweimin.com/p/68e849768d8e](http://www.lxweimin.com/p/68e849768d8e)<br />簡單的來說要想瀏覽器運行你寫的react代碼,需要三個包:
- react包——負責react的核心邏輯
- react-dom包——負責瀏覽器的dom操作
- babel包——轉義jsx語法。(在script中加 type="text/babel",就可以在script中寫jsx)
如我們直接在html文件中引入這三個包后,就可以快樂的使用react了。
```jsx
<!DOCTYPE html>
<html lang="en">
? <head>
? ? <meta charset="UTF-8" />
? ? <title>React</title>
? ? <!-- 引入react -->
? ? <script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script>
? ? <!-- 引入react-dom -->
? ? <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>
? ? <!-- 引入Babel,使瀏覽器可以識別JSX語法,如果不使用JSX語法,可以不引入 -->
? ? <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
? </head>
? <body>
? ? <div id="app"></div>
? ? <script type="text/babel">
? ? ? // 必須添加type="text/babel",否則不識別JSX語法
? ? ? class App extends React.Component {
? ? ? ? render() {
? ? ? ? ? return (
? ? ? ? ? ? <div>
? ? ? ? ? ? ? <h1>Hello World</h1>
? ? ? ? ? ? </div>
? ? ? ? ? );
? ? ? ? }
? ? ? }
? ? ? ReactDOM.render(<App />, document.getElementById("app"));
? ? </script>
? </body>
</html>
```
而我們建立的項目中一般會在index.html中引入main.js文件,main.js中引入react的兩個包,并替換html中的root節點,這里我們需要清楚怎么main.js中的import react 是引入哪個文件,然后才好打包發給瀏覽器(這個后面寫的時候再詳細說)。<br />教程中我們使用koa建立本地服務器攔截文件中的import請求,返回給他經過esbuild轉換的文件<br />注意可以把esbuild轉換的文件分為兩種:
- 一種是基本不會變的文件,如react、babel等第三方包
- 一種是經常會變的,如我們自己寫的代碼文件
所以,對于不會變的文件我們應該實現編譯后,并設置緩存,而對于自己寫的代碼文件就每次都實時編譯,因為esbuild的速度非常快,且每個包都是單獨的模塊引入,所以會比webpack快很多。
<a name="V7vWL"></a>
## 實踐:
<a name="D2umZ"></a>
### 先建立一個基礎文件目錄如下:
<br />文件說明:<br />dev.command.js 和prett.command.js這兩個文件是為了啟動它對應的文件的。<br />index.html 是我們要返回給瀏覽器的文件,內容如下:
```html
<!DOCTYPE html>
<html lang="en">
? ? <head>
? ? ? ? <meta charset="UTF-8" />
? ? ? ? <meta http-equiv="X-UA-Compatible" content="IE=edge" />
? ? ? ? <meta name="viewport" content="width=device-width, initial-scale=1.0" />
? ? ? ? <title>react</title>
? ? </head>
? ? <body>
? ? ? ? <div id="root"></div>
? ? ? ? <script type="module" src="/target/main.jsx"></script>
? ? </body>
</html>
```
<a name="CO4i6"></a>
### 建立koa服務器(也可以用express):
```javascript
import Koa from "koa";
import koaRouter from "koa-router";
import fs from "fs";
import path from "path";
export async function dev() {
? ? console.log("div....");
? ? const app = new Koa();
? ? const router = new koaRouter();
? ? app.use((ctx, next) => {
? ? ? ? console.log("有請求:", ctx.request.url);
? ? ? ? next();
? ? ? ? console.log("請求over:", ctx.body);
? ? });
? ? // 根目錄請求返回html
? ? router.get("/", (ctx) => {
? ? ? ? let htmlPath = path.join(__dirname, "../target/index.html");
? ? ? ? let html = fs.readFileSync(htmlPath);
? ? ? ? ctx.set("Content-Type", "text/html");
? ? ? ? ctx.body = html;
? ? });
? ? app.use(router.routes());
? ? app.listen(3030, () => {
? ? ? ? console.log("app is listen 3030");
? ? });
}
```
這時候我們可以 使用 `esno src/dev.command.js`啟動一下服務器(因為我們語法中使用了ems模塊,所以不能用node命令去啟動,node只能啟動cmj模塊的文件。并且命令行不支持esno,所以把這句指令寫到package.json文件的script標簽,用npm run 的方式啟動)
<a name="wgtYc"></a>
### esbuild轉換jsx文件:
這時候瀏覽器中訪問發現可以得到對應的html文件,但是html文件中引入了 ` <script type="module" src="/target/main.jsx"></script>`這個main.jsx是我們編寫的文件,內容如下:
```jsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App.jsx'
ReactDOM.render(
? <React.StrictMode>
? ? <App />
? </React.StrictMode>,
? document.getElementById('root')
)
```
但是瀏覽器不能直接使用jsx文件,所以我們把jsx文件用esbuild轉換后再給瀏覽器。<br />使用esbuild中的 transformSync函數,實例代碼如下:
```javascript
require('esbuild').transformSync('<>x</>', {
? jsxFragment: 'Fragment', // 返回空節點
? loader: 'jsx',
})
{
? code: '/* @__PURE__ */ React.createElement(Fragment, null, "x");\n',
? map: '',
? warnings: []
}
```
轉換函數:
```javascript
import esbuild from "esbuild";
function transformCode(tranObj) {
? ? return esbuild.transformSync(tranObj.code, {
? ? ? ? loader: tranObj.loader || "js",
? ? ? ? format: "esm",
? ? ? ? sourcemap: true,
? ? });
}
export function transformJSX(opts) {
? ? let tranObj = { code: opts.code };
? ? tranObj.loader = "jsx";
? ? let res = transformCode(tranObj);
? ? return res;
}
```
把轉換后的文件返給瀏覽器:
```javascript
// html文件中有 /target/main.jsx ,返回我們處理過的main.jsx文件
? ? router.get("/target/main.jsx", (ctx) => {
? ? ? ? let filePath = path.join(__dirname, "../target/main.jsx");
? ? ? ? let fileText = fs.readFileSync(filePath, "utf-8");
? ? ? ? ctx.set("Content-Type", "application/javascript");
? ? ? ? ctx.body = transformJSX({
? ? ? ? ? ? code: fileText,
? ? ? ? }).code;
? ? });
```
<a name="JH0Wu"></a>
### 緩存第三方包:
此時會發現瀏覽器報錯:<br />Uncaught TypeError: Failed to resolve module specifier "react". Relative references must start with either "/", "./", or "../".<br />這個是因為我們返回的js文件中第一行 import 了一個 react但是路徑不對,應該以'/'開頭。所以下一步我們要處理導入的包。所以我們可以簡單的看import里 如果 不是以'/' 或 './'開頭的,那么把它當作第三方包,把第三方包做成緩存。<br />并且jsx等文件中引入第三方包的import 路徑,我們要轉換成我們設置的緩存路徑后再返給瀏覽器。<br />轉換import 路徑:
```javascript
import esbuild from "esbuild";
import path, { dirname } from "path";
import fs from "fs";
function transformCode(tranObj) {
? ? return esbuild.transformSync(tranObj.code, {
? ? ? ? loader: tranObj.loader || "js",
? ? ? ? format: "esm",
? ? ? ? sourcemap: true,
? ? });
}
export function transformJSX(opts) {
? ? let tranObj = { code: opts.code };
? ? tranObj.loader = "jsx";
? ? let res = transformCode(tranObj);
? ? let { code } = res;
? ? // 分析代碼字符串的 import
? ? // 為啥要分析 import 呢?
? ? // import type { XXXX } from 'xxx.ts';
? ? // import React from 'react';
? ? // 下面的正則取出 from 后面的 "react", 然后通過有沒有 "." 判斷是引用的本地文件還是三方庫
? ? // 本地文件就拼路徑
? ? // 三方庫就從我們預先編譯的緩存里面取
? ? code = code.replace(/\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm, (a, b, c) => {
? ? ? ? console.log("正則匹配:", a, "-------", b, "-------", c);
? ? ? ? let fromPath = "";
? ? ? ? // 以'.'開頭當作本地文件
? ? ? ? if (c.charAt(0) === ".") {
? ? ? ? ? ? let filePath = path.join(opts.rootPath, c);
? ? ? ? ? ? console.log("filePath", filePath, path.dirname(opts.path), fromPath);
? ? ? ? ? ? if (fs.existsSync(filePath)) {
? ? ? ? ? ? ? ? fromPath = path.join(path.dirname(opts.path), c);
? ? ? ? ? ? ? ? fromPath = fromPath.replace(/\\/g, "/");
? ? ? ? ? ? ? ? return a.replace(b, `"${fromPath}"`);
? ? ? ? ? ? }
? ? ? ? } else {
? ? ? ? ? // todo 對第三方庫的文件從緩存里拿
? ? ? ? }
? ? ? ? return a;
? ? });
? ? return { ...res, code };
}
```
注意:這個正則表達式 `/\bimport(?!\s+type)(?:[\w*{}\n\r\t, ]+from\s*)?\s*("([^"]+)"|'([^']+)')/gm`的分成三個組a,b,c;a是import那一整行代碼,b是帶引號的包名稱,c是不帶引號的包名稱。
上一步我們改寫了文件的import,讓我們服務器可以根據請求路徑返回相應文件。接下來在pretreatment.js文件中對第三方包進行處理,首先我們需要在項目啟動的時候把第三方包緩存起來。<br />緩存第三方包:
```javascript
import { build } from "esbuild";
import { join } from "path";
const appRoot = join(__dirname, "..");
const cache = join(appRoot, "target", ".cache");
export async function pretreatment(pkgs = ["react", "react-dom"]) {
? ? console.log("pretreatment");
? ? let entrys = pkgs.map((item) => {
? ? ? ? return join(appRoot, "node_modules", item, "cjs", `${item}.development.js`);
? ? });
? ? build({
? ? ? ? entryPoints: entrys,
? ? ? ? bundle: true,
? ? ? ? sourcemap: true,
? ? ? ? treeShaking: true,
? ? ? ? outdir: cache,
? ? ? ? splitting: true,
? ? ? ? logLevel: "error",
? ? ? ? metafile: true,
? ? ? ? format: "esm",
? ? });
}
```
注意:entry的路徑就是你本地安裝包的路徑;<br />運行pretreatment函數就可以得到緩存包了。<br />我們可以在package.json文件的script標簽中 寫成:`"dev": "esno src/prett.command.js && esno src/dev.command.js"`<br />.cache文件夾下出現兩個包:<br />
<br />接下來返回到transformJSX函數里處理第三方包;
```javascript
? ? ? ? // 以'.'開頭當作本地文件
? ? ? ? if (c.charAt(0) === ".") {
? ? ? ? ? ? let filePath = path.join(opts.rootPath, c);
? ? ? ? ? ? console.log("filePath", filePath, path.dirname(opts.path), fromPath, path.dirname("/aa/bb/cc/dd.js"));
? ? ? ? ? ? if (fs.existsSync(filePath)) {
? ? ? ? ? ? ? ? fromPath = path.join(path.dirname(opts.path), c);
? ? ? ? ? ? ? ? fromPath = fromPath.replace(/\\/g, "/");
? ? ? ? ? ? ? ? return a.replace(b, `"${fromPath}"`);
? ? ? ? ? ? }
? ? ? ? } else { // ============== 新增 從緩存里拿文件,避免再次打包
? ? ? ? ? ? let filePath = path.join(opts.rootPath, `.cache/${c}/cjs/${c}.development.js`);
? ? ? ? ? ? if (fs.existsSync(filePath)) {
? ? ? ? ? ? ? ? fromPath = path.join(dirname(opts.path), `.cache/${c}/cjs/${c}.development.js`);
? ? ? ? ? ? ? ? fromPath = fromPath.replace(/\\/g, "/");
? ? ? ? ? ? ? ? return a.replace(b, `"${fromPath}"`);
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? return a;
```
接下來再處理一下css和svg文件就可以了。<br />這時候又發現一個重大的問題:<br />我們的請求都成功了,如下圖:<br />
<br />但是界面卻沒有任何顯示,看下頁面結構,id 為 root 的div元素沒有變化,說明react腳本沒起作用,沒能替換我們的root元素。問題出在哪里呢?我們去看看有沒有報錯,發現:<br />
<br />發現這個獲取logo.svg的請求報了這個錯。意思我想要js的文件你卻給我一個svg的文件。等等,也就是說js腳本里的import在瀏覽器中都是只能引入其他js腳本的。所以我們這里不需要把靜態文件直接導過來,而是轉換成一個路徑,那個在用到的地方自然會被導入。如導入時返回這個:`export default "/target/logo.svg"` 。<br />logo.svg 使用的地方是 ` <imgsrc={logo}className="App-logo"alt="logo"/>`那么在使用的地方會請求這個路徑拿到真正的文件。
所以我們需要把 所有導入靜態文件的地方都加上一個標識符,然后import發請求請求這個文件時,我們返回的Content-Type寫 appliction/javascript ,然后文件里就一句話 export default "文件路徑" ;<br />然后文件被使用時瀏覽器就會再發一個請求,去請求這個文件,再請求里判斷一下靜態文件走靜態文件的處理。
代碼里做兩處更改:<br />transform函數:
```javascript
if (fs.existsSync(filePath)) {
? ? fromPath = path.join(path.dirname(opts.path), c);
? ? fromPath = fromPath.replace(/\\/g, "/");
? ? // svg等靜態文件加個標志 后面拼url參數===新增
? ? if (["svg"].includes(path.extname(fromPath).slice(1))) {
? ? ? ? fromPath += "?import";
? ? }
? ? return a.replace(b, `"${fromPath}"`);
}
```
dev的target請求里:
```javascript
router.get("/target/(.*)", (ctx) => {
? ? ? ? let filePath = path.join(__dirname, "..", ctx.path.slice(1));
? ? ? ? let rootPath = path.join(__dirname, "../target");
? ? ? ? console.log("target 請求 file path:", filePath);
? ? ? ? // query 中有import標識的就是靜態資源====新增
? ? ? ? if ("import" in ctx.query) {
? ? ? ? ? ? ctx.set("Content-Type", "application/javascript");
? ? ? ? ? ? ctx.body = `export default "${ctx.path}"`;
? ? ? ? ? ? return;
? ? ? ? }
```
這時候我們終于可以看到頁面了。
<a name="jkqNZ"></a>
### 建立websocket鏈接:
這時候發現頁面樣式css文件是沒起作用的,我們先放一放,先建一個websocket鏈接方便后續處理css文件和熱更新
建立websocket分兩步一個是客戶端,一個是服務器。<br />客戶端使用原生WebScoket類,服務器使用ws庫創建WebSocket服務。<br />客戶端的webScoket文件我們命名為client.js,在index.html中加入 script標簽,src為"@/vite/client",去請求 拿到。
<a name="k9AfN"></a>
#### 請求@/vite/client:
```javascript
? ? // 把客戶端代碼塞給瀏覽器,給 html
? ? router.get("/@vite/client", (ctx) => {
? ? ? ? console.log("get vite client");
? ? ? ? ctx.set("Content-Type", "application/javascript");
? ? ? ? ctx.body = transformCode({
? ? ? ? ? ? code: fs.readFileSync(path.join(__dirname, "client.js"), "utf-8"),
? ? ? ? }).code;
? ? ? ? // 這里返回的才是真正的內置的客戶端代碼
? ? });
```
<a name="s0mSQ"></a>
#### client.js函數:
```javascript
let host = location.host;
console.log("vite client:", host);
// Create WebSocket connection.
const socket = new WebSocket(`ws://${host}`, "vite-hmr");
// Connection opened
socket.addEventListener("open", function (event) {
? ? socket.send("Hello Server!");
});
// Listen for messages
socket.addEventListener("message", function (event) {
? ? handleServerMessage(event.data);
});
function handleServerMessage(payLoad) {
? ? let msg = JSON.parse(payLoad);
? ? console.log("Message from server ", payLoad, "====", msg);
? ? switch (msg.type) {
? ? ? ? case "connected": {
? ? ? ? ? ? console.log("vite websocket connected");
? ? ? ? ? ? setInterval(() => {
? ? ? ? ? ? ? ? socket.send("ping");
? ? ? ? ? ? }, 20000);
? ? ? ? ? ? break;
? ? ? ? }
? ? ? ? case "update": {
? ? ? ? ? ? console.log("Message update ", msg, msg.updates);
? ? ? ? ? ? msg.updates.forEach(async (update) => {
? ? ? ? ? ? ? ? if (update.type === "js-update") {
? ? ? ? ? ? ? ? ? ? console.log("[vite] js update....");
? ? ? ? ? ? ? ? ? ? await import(`/target/${update.path}?t=`);
? ? ? ? ? ? ? ? ? ? // 在這里應該是要只更新變成模塊的,不應該全部重新加載。
? ? ? ? ? ? ? ? ? ? // vite源碼這里是調用了一個queueUpdate函數
? ? ? ? ? ? ? ? ? ? location.reload();
? ? ? ? ? ? ? ? }
? ? ? ? ? ? });
? ? ? ? ? ? break;
? ? ? ? }
? ? }
? ? if (msg.type == "update") {
? ? }
}
// 封裝一些操作 css 的工具方法,因為 client 是放 html 里的,可以導出來給其它模塊使用
const sheetsMap = new Map();
// id 是css文件的絕對路徑, content是css文件的內容
export function updateStyle(id, content) {
? ? let style = sheetsMap.get(id);
? ? if (!style) {
? ? ? ? style = document.createElement("style");
? ? ? ? style.setAttribute("type", "text/css");
? ? ? ? style.innerHTML = content;
? ? ? ? document.head.appendChild(style);
? ? } else {
? ? ? ? style.innerHTML = content;
? ? }
? ? sheetsMap.set(id, style);
}
```
<a name="hfONj"></a>
#### dev.js中加入websocket服務:
```javascript
function createWebSocketServer(httpServer) {
? ? console.log("create web server:");
? ? const wss = new WebSocketServer({ noServer: true });
? ? wss.on("connection", (socket) => {
? ? ? ? console.log("connected ===");
? ? ? ? socket.send(JSON.stringify({ type: "connected" }));
? ? ? ? socket.on("message", handleSocketMsg);
? ? });
? ? wss.on("error", (socket) => {
? ? ? ? console.error("ws connect error", socket);
? ? });
? ? httpServer.on("upgrade", function upgrade(req, socket, head) {
? ? ? ? if (req.headers["sec-websocket-protocol"] == "vite-hmr") {
? ? ? ? ? ? console.log("upgrade", Object.keys(req.headers));
? ? ? ? ? ? wss.handleUpgrade(req, socket, head, (ws) => {
? ? ? ? ? ? ? ? wss.emit("connection", ws, req);
? ? ? ? ? ? });
? ? ? ? }
? ? });
? ? return {
? ? ? ? send(payLoad) {
? ? ? ? ? ? let sendMsg = JSON.stringify(payLoad);
? ? ? ? ? ? wss.clients.forEach((client) => {
? ? ? ? ? ? ? ? client.send(sendMsg);
? ? ? ? ? ? });
? ? ? ? },
? ? ? ? close() {
? ? ? ? ? ? console.log("close websocket");
? ? ? ? ? ? wss.close();
? ? ? ? },
? ? };
}
function handleSocketMsg(data) {
? ? console.log("received: %s", data);
}
```
運行createWebSocketServer函數可以得到 一個 websocket的服務實例。<br />我們在dev.js中調用:
```javascript
? ? // 使用原生的http.createServer 獲取http.Server實例
? ? // 因為ws庫,是基于這個實例去升級http協議成wesocket服務的
? ? // 因為我們使用的是koa,所以用app.callback函數獲取一個使用于httpServer 處理請求的函數
? ? let httpServer = http.createServer(app.callback());
? ? // eslint-disable-next-line no-unused-vars
? ? const ws = createWebSocketServer(httpServer);
? ? httpServer.listen(3030, () => {
? ? ? ? console.log("app is listen 3030");
? ? });
```
這樣我們就可以獲得一個websocket的鏈接,我們可以在client里寫一些公共函數用來進行一些 前端的操作。比如收到請求后 更新某個css文件。
<a name="xnKrW"></a>
### 處理css文件:
處理css文件,想想看html文件中是怎么加載css的,有兩種方法:<br />1、是link標簽指定href為文件地址,ref為stylesheet;<br />2、使用style標簽,里面直接寫css文件內容。<br />兩種方法我們都需要去創建一個標簽。那么問題來了,我們怎么在發送過去的html里面創建標簽呢? <br />因為html文件是第一個發送給瀏覽器的,后續的文件都是html標簽里的遞歸請求過去的。
所以為了解決這個問題,我們可以使用websocket,發給瀏覽器的html文件里加個script標簽,在里面起一個websocket的客戶端,然后這里面可以通過document來新建style標簽,加入css樣式。<br />websocket客戶端,上一步講過了,這一步將怎么更新css;
transform中添加:
```javascript
export function transformCss(opts) {
? ? // let filePath = path.join(opts.rootPath, "..", opts.path);
? ? // console.log("css path:", path.join(opts.rootPath, "..", opts.path));
? ? // css文件使用 在 client.js 中的updateStyle函數來創建style標簽 加入css的內容
? ? return `
? ? ? ? import { updateStyle } from '/@vite/client'
? ? ? ? const id = "${opts.path}";
? ? ? ? const css = "${opts.code.replace(/"/g, "'").replace(/\n/g, "")}";
? ? ? ? updateStyle(id, css);
? ? ? ? export default css;
? ? `.trim();
}
```
client.js文件中添加
```javascript
// 封裝一些操作 css 的工具方法,因為 client 是放 html 里的,可以導出來給其它模塊使用
const sheetsMap = new Map();
// id 是css文件的絕對路徑, content是css文件的內容
export function updateStyle(id, content) {
? ? let style = sheetsMap.get(id);
? ? if (!style) {
? ? ? ? style = document.createElement("style");
? ? ? ? style.setAttribute("type", "text/css");
? ? ? ? style.innerHTML = content;
? ? ? ? document.head.appendChild(style);
? ? } else {
? ? ? ? style.innerHTML = content;
? ? }
? ? sheetsMap.set(id, style);
}
```
dev中添加:
```javascript
switch (path.extname(ctx.url)) {
case ".svg":
? ? ctx.set("Content-Type", "image/svg+xml");
? ? ctx.body = fs.readFileSync(filePath, "utf-8");
? ? break;
case ".css": //====== 新增
? ? ctx.set("Content-Type", "application/javascript");
? ? ctx.body = transformCss({
? ? ? ? code: fs.readFileSync(filePath, "utf-8"),
? ? ? ? path: ctx.path,
? ? ? ? rootPath,
? ? });
? ? break;
```
簡單總結一下,這一步我們的操作使得有css文件引入的地方,就會發請求給服務器,服務器返回一個js文件,這個js文件里引入了client.js中的updateStyle函數,同時把css文件的內容和文件位置傳給這個函數。然后updateStyle函數里,根據這個位置判斷有沒有對應style標簽,有則更新,無則生成。
<a name="tIRLl"></a>
### 熱更新:
熱更新需要監聽文件變化,這個我們使用 chokidar<br />npm:[https://www.npmjs.com/package/chokidar](https://www.npmjs.com/package/chokidar)<br />思路就是監聽文件變化后,把變化的文件名做處理(就是把文件的絕對地址轉換成瀏覽器里請求的相對地址),通過websocket告訴前端,那個模塊變更了,讓他重新導入。
<a name="W5Ovh"></a>
#### 監聽文件變更:
dev.js中添加
```javascript
// 監聽文件變更
function watch() {
? ? return chokidar.watch(targetRootPath, {
? ? ? ? ignored: ["**/node_modules/**", "**/.cache/**"],
? ? ? ? ignoreInitial: true,
? ? ? ? ignorePermissionErrors: true,
? ? ? ? disableGlobbing: true,
? ? });
}
```
dev函數里添加watch:
```javascript
? ? let httpServer = http.createServer(app.callback());
? ? // eslint-disable-next-line no-unused-vars
? ? const ws = createWebSocketServer(httpServer);
? ? // 監聽文件變更 ======== 新增
? ? watch().on("change", (filePath) => {
? ? ? ? console.log("file is change", filePath, targetRootPath);
? ? ? ? handleHMRUpdate(ws, filePath);
? ? });
? ? httpServer.listen(3030, () => {
? ? ? ? console.log("app is listen 3030");
? ? });
```
處理熱更新的函數:
```javascript
function getShortName(filePath, root) {
? ? return `${filePath.replace(root, "").replace(/\\/g, "/")}`;
? ? // return path.extname(filePath);
}
// 處理文件更新
function handleHMRUpdate(ws, filePath) {
? ? // let file = fs.readFileSync(filePath);
? ? const shortFile = getShortName(filePath, targetRootPath);
? ? console.log("short file:", shortFile);
? ? let updates = [
? ? ? ? {
? ? ? ? ? ? type: "js-update",
? ? ? ? ? ? path: `/${shortFile}`,
? ? ? ? },
? ? ];
? ? let sendMsg = {
? ? ? ? type: "update",
? ? ? ? updates,
? ? };
? ? ws.send(sendMsg);
}
```
<a name="VxFKl"></a>
#### client.js中處理文件變更:
```javascript
function handleServerMessage(payLoad) {
? ? let msg = JSON.parse(payLoad);
? ? console.log("Message from server ", payLoad, "====", msg);
? ? switch (msg.type) {
? ? ? ? case "connected": {
? ? ? ? ? ? console.log("vite websocket connected");
? ? ? ? ? ? setInterval(() => {
? ? ? ? ? ? ? ? socket.send("ping");
? ? ? ? ? ? }, 20000);
? ? ? ? ? ? break;
? ? ? ? }
? ? ? ? case "update": { // ============= 新增,消息類型是要更新文件
? ? ? ? ? ? console.log("Message update ", msg, msg.updates);
? ? ? ? ? ? msg.updates.forEach(async (update) => {
? ? ? ? ? ? ? ? if (update.type === "js-update") {
? ? ? ? ? ? ? ? ? ? console.log("[vite] js update....");
? ? ? ? ? ? ? ? ? ? await import(`/target/${update.path}?t=`);
? ? ? ? ? ? ? ? ? ? // 在這里應該是要只更新變成模塊的,不應該全部重新加載。
? ? ? ? ? ? ? ? ? ? // vite源碼這里是調用了一個queueUpdate函數
? ? ? ? ? ? ? ? ? ? location.reload();
? ? ? ? ? ? ? ? }
? ? ? ? ? ? });
? ? ? ? ? ? break;
? ? ? ? }
? ? }
}
```
<a name="T3MBO"></a>
## 總結:
至此,我們就實現了mini-vite,大致思路和源碼是差不多的,但是一些細節處理的地方還沒有做,比如每個模塊的單獨渲染、簡單的用是不是"."開頭來判斷是不是第三方包,對是否靜態文件判斷不夠全面。<br />項目整體上理解一下vite設計的思路,還是可以的,還能學一下esbuild和webpack。
完整項目地址:[https://gitee.com/zyl-ll/vite_source_code.git](https://gitee.com/zyl-ll/vite_source_code.git)