# 极简上手指南—手写mini-vite开发服务器—学习vite源码
参考爪哇教育-波比老师课程;
## 项目中会使用到的插件:
### esno:
可以运行es6的代码,nodejs的node命令只能运行cmj的代码,遇到export、import这些会报错。
### chokidar:
参考:[https://www.npmjs.com/package/chokidar](https://www.npmjs.com/package/chokidar)
作用,监听文件变化。做文件热更新需要。
### esbuild:
参考:[https://esbuild.github.io/getting-started/#install-esbuild](https://esbuild.github.io/getting-started/#install-esbuild)
作用:快速打包文件。go语言编写,多线程。
## 撸一个mini-vite可以学到:
1、vite开发服务器原理
2、esbuild相关知识
3、websocket相关知识
4、热更新,chokidar 包监听文件变化
## 整体思路:
先去官网看看vite这东西的介绍,去窥探一下它的实现原理:
### 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 始终能保持快速更新。
### 大致总结一下:
vite作为一个前端开发工具,
和webpack一样它构建了一个本地服务器,当你访问时,返回相应代码。
和webpack不同的是:
webpack会把你写的所有文件打包成一个个的js、css、html、静态文件,然后你才能访问;
但vite利用浏览器的esm功能动态返回相应文件,启动的时候根本不需要去打包你写的代码文件,你访问的时候只返回一个入口文件index.html,然后这个文件里有个script标签,script标签设置type为module,这样浏览器会再发一个请求去拿script标签src对应的文件。然后vite服务器根据这个请求返回相应文件,因为都是静态文件,没有什么逻辑交互,所以速度非常快。
那么还有个问题,我写的是jsx文件,浏览器不认识啊!没事vite服务器会在请求jsx文件的时候,通过esbuild去把jsx转换成js文件,返回给浏览器。esbuild用go语言写的,速度快到惊人。
### react工程为例,写一个mini-vite服务:
我们以react为例,最终需要在客户端拿到html文件里,包含react里的相关文件。所以我们要先了解如何创建react-app,参考:[https://www.jianshu.com/p/68e849768d8e](https://www.jianshu.com/p/68e849768d8e)
简单的来说要想浏览器运行你写的react代码,需要三个包:
- react包——负责react的核心逻辑
- react-dom包——负责浏览器的dom操作
- babel包——转义jsx语法。(在script中加 type="text/babel",就可以在script中写jsx)
如我们直接在html文件中引入这三个包后,就可以快乐的使用react了。
```jsx
// 必须添加type="text/babel",否则不识别JSX语法
class App extends React.Component {
render() {
return (
Hello World
);
}
}
ReactDOM.render(
```
而我们建立的项目中一般会在index.html中引入main.js文件,main.js中引入react的两个包,并替换html中的root节点,这里我们需要清楚怎么main.js中的import react 是引入哪个文件,然后才好打包发给浏览器(这个后面写的时候再详细说)。
教程中我们使用koa建立本地服务器拦截文件中的import请求,返回给他经过esbuild转换的文件
注意可以把esbuild转换的文件分为两种:
- 一种是基本不会变的文件,如react、babel等第三方包
- 一种是经常会变的,如我们自己写的代码文件
所以,对于不会变的文件我们应该实现编译后,并设置缓存,而对于自己写的代码文件就每次都实时编译,因为esbuild的速度非常快,且每个包都是单独的模块引入,所以会比webpack快很多。
## 实践:
### 先建立一个基础文件目录如下:
文件说明:
dev.command.js 和prett.command.js这两个文件是为了启动它对应的文件的。
index.html 是我们要返回给浏览器的文件,内容如下:
```html
```
### 建立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 的方式启动)
### esbuild转换jsx文件:
这时候浏览器中访问发现可以得到对应的html文件,但是html文件中引入了 ` `这个main.jsx是我们编写的文件,内容如下:
```jsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App.jsx'
ReactDOM.render(
,
document.getElementById('root')
)
```
但是浏览器不能直接使用jsx文件,所以我们把jsx文件用esbuild转换后再给浏览器。
使用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;
});
```
### 缓存第三方包:
此时会发现浏览器报错:
Uncaught TypeError: Failed to resolve module specifier "react". Relative references must start with either "/", "./", or "../".
这个是因为我们返回的js文件中第一行 import 了一个 react但是路径不对,应该以'/'开头。所以下一步我们要处理导入的包。所以我们可以简单的看import里 如果 不是以'/' 或 './'开头的,那么把它当作第三方包,把第三方包做成缓存。
并且jsx等文件中引入第三方包的import 路径,我们要转换成我们设置的缓存路径后再返给浏览器。
转换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文件中对第三方包进行处理,首先我们需要在项目启动的时候把第三方包缓存起来。
缓存第三方包:
```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的路径就是你本地安装包的路径;
运行pretreatment函数就可以得到缓存包了。
我们可以在package.json文件的script标签中 写成:`"dev": "esno src/prett.command.js && esno src/dev.command.js"`
.cache文件夹下出现两个包:
接下来返回到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文件就可以了。
这时候又发现一个重大的问题:
我们的请求都成功了,如下图:
但是界面却没有任何显示,看下页面结构,id 为 root 的div元素没有变化,说明react脚本没起作用,没能替换我们的root元素。问题出在哪里呢?我们去看看有没有报错,发现:
发现这个获取logo.svg的请求报了这个错。意思我想要js的文件你却给我一个svg的文件。等等,也就是说js脚本里的import在浏览器中都是只能引入其他js脚本的。所以我们这里不需要把静态文件直接导过来,而是转换成一个路径,那个在用到的地方自然会被导入。如导入时返回这个:`export default "/target/logo.svg"` 。
logo.svg 使用的地方是 `
所以我们需要把 所有导入静态文件的地方都加上一个标识符,然后import发请求请求这个文件时,我们返回的Content-Type写 appliction/javascript ,然后文件里就一句话 export default "文件路径" ;
然后文件被使用时浏览器就会再发一个请求,去请求这个文件,再请求里判断一下静态文件走静态文件的处理。
代码里做两处更改:
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;
}
```
这时候我们终于可以看到页面了。
### 建立websocket链接:
这时候发现页面样式css文件是没起作用的,我们先放一放,先建一个websocket链接方便后续处理css文件和热更新
建立websocket分两步一个是客户端,一个是服务器。
客户端使用原生WebScoket类,服务器使用ws库创建WebSocket服务。
客户端的webScoket文件我们命名为client.js,在index.html中加入 script标签,src为"@/vite/client",去请求 拿到。
#### 请求@/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;
// 这里返回的才是真正的内置的客户端代码
});
```
#### 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);
}
```
#### 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的服务实例。
我们在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文件。
### 处理css文件:
处理css文件,想想看html文件中是怎么加载css的,有两种方法:
1、是link标签指定href为文件地址,ref为stylesheet;
2、使用style标签,里面直接写css文件内容。
两种方法我们都需要去创建一个标签。那么问题来了,我们怎么在发送过去的html里面创建标签呢?
因为html文件是第一个发送给浏览器的,后续的文件都是html标签里的递归请求过去的。
所以为了解决这个问题,我们可以使用websocket,发给浏览器的html文件里加个script标签,在里面起一个websocket的客户端,然后这里面可以通过document来新建style标签,加入css样式。
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标签,有则更新,无则生成。
### 热更新:
热更新需要监听文件变化,这个我们使用 chokidar
npm:[https://www.npmjs.com/package/chokidar](https://www.npmjs.com/package/chokidar)
思路就是监听文件变化后,把变化的文件名做处理(就是把文件的绝对地址转换成浏览器里请求的相对地址),通过websocket告诉前端,那个模块变更了,让他重新导入。
#### 监听文件变更:
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);
}
```
#### 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;
}
}
}
```
## 总结:
至此,我们就实现了mini-vite,大致思路和源码是差不多的,但是一些细节处理的地方还没有做,比如每个模块的单独渲染、简单的用是不是"."开头来判断是不是第三方包,对是否静态文件判断不够全面。
项目整体上理解一下vite设计的思路,还是可以的,还能学一下esbuild和webpack。
完整项目地址:[https://gitee.com/zyl-ll/vite_source_code.git](https://gitee.com/zyl-ll/vite_source_code.git)