极简上手指南—手写mini-vite开发服务器—学习vite源码

# 极简上手指南—手写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

 

   

    React

   

   

   

   

   

   

 


 

   

   

 

```

而我们建立的项目中一般会在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

   

       

       

       

        react

   

   

       

       

   

```

### 建立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)

你可能感兴趣的:(极简上手指南—手写mini-vite开发服务器—学习vite源码)