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

参考爪哇教育-波比老师课程;

项目中会使用到的插件:

esno:

可以运行es6的代码,nodejs的node命令只能运行cmj的代码,遇到export、import这些会报错。

chokidar:

参考:www.npmjs.com/package/cho…
作用,监听文件变化。做文件热更新需要。

esbuild:

参考:esbuild.github.io/getting-sta…
作用:快速打包文件。go语言编写,多线程。

撸一个mini-vite可以学到:

1、vite开发服务器原理
2、esbuild相关知识
3、websocket相关知识
4、热更新,chokidar 包监听文件变化

整体思路:

先去官网看看vite这东西的介绍,去窥探一下它的实现原理:

vite是什么?

官网解释:

Vite(法语意为 "快速的",发音 /vit/,发音同 "veet")是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

我们今天写的是vite的开发服务器模块,有个重点 “基于原生ES模块”,需要浏览器是能 在 script 标签上支持原生 ESM原生 ESM 动态导入

扫描二维码关注公众号,回复: 13705497 查看本文章

然后去看官网的为什么选vite里提到

1、Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。 2、在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活[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,参考:www.jianshu.com/p/68e849768…
简单的来说要想浏览器运行你写的react代码,需要三个包:

  • react包——负责react的核心逻辑
  • react-dom包——负责浏览器的dom操作
  • babel包——转义jsx语法。(在script中加 type="text/babel",就可以在script中写jsx)

如我们直接在html文件中引入这三个包后,就可以快乐的使用react了。

<!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 是引入哪个文件,然后才好打包发给浏览器(这个后面写的时候再详细说)。

教程中我们使用koa建立本地服务器拦截文件中的import请求,返回给他经过esbuild转换的文件
注意可以把esbuild转换的文件分为两种:

  • 一种是基本不会变的文件,如react、babel等第三方包
  • 一种是经常会变的,如我们自己写的代码文件

所以,对于不会变的文件我们应该实现编译后,并设置缓存,而对于自己写的代码文件就每次都实时编译,因为esbuild的速度非常快,且每个包都是单独的模块引入,所以会比webpack快很多。

实践:

先建立一个基础文件目录如下:

image.png
文件说明:
dev.command.js 和prett.command.js这两个文件是为了启动它对应的文件的。
index.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>
复制代码

建立koa服务器(也可以用express):

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文件中引入了 <script type="module" src="/target/main.jsx"></script>这个main.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转换后再给浏览器。
使用esbuild中的 transformSync函数,实例代码如下:

require('esbuild').transformSync('<>x</>', {
  jsxFragment: 'Fragment', // 返回空节点
  loader: 'jsx',
})
{
  code: '/* @__PURE__ */ React.createElement(Fragment, null, "x");\n',
  map: '',
  warnings: []
}
复制代码

转换函数:

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;
}
复制代码

把转换后的文件返给浏览器:

// 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 路径:

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文件中对第三方包进行处理,首先我们需要在项目启动的时候把第三方包缓存起来。
缓存第三方包:

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文件夹下出现两个包:
image.png
接下来返回到transformJSX函数里处理第三方包;

        // 以'.'开头当作本地文件
        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文件就可以了。
这时候又发现一个重大的问题:
我们的请求都成功了,如下图:
image.png
但是界面却没有任何显示,看下页面结构,id 为 root 的div元素没有变化,说明react脚本没起作用,没能替换我们的root元素。问题出在哪里呢?我们去看看有没有报错,发现:
image.png
发现这个获取logo.svg的请求报了这个错。意思我想要js的文件你却给我一个svg的文件。等等,也就是说js脚本里的import在浏览器中都是只能引入其他js脚本的。所以我们这里不需要把静态文件直接导过来,而是转换成一个路径,那个在用到的地方自然会被导入。如导入时返回这个:export default "/target/logo.svg"
logo.svg 使用的地方是 <imgsrc={logo}className="App-logo"alt="logo"/>那么在使用的地方会请求这个路径拿到真正的文件。

所以我们需要把 所有导入静态文件的地方都加上一个标识符,然后import发请求请求这个文件时,我们返回的Content-Type写 appliction/javascript ,然后文件里就一句话 export default "文件路径" ;
然后文件被使用时浏览器就会再发一个请求,去请求这个文件,再请求里判断一下静态文件走静态文件的处理。

代码里做两处更改:
transform函数:

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请求里:

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:

    // 把客户端代码塞给浏览器,给 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函数:

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服务:

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中调用:

    // 使用原生的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中添加:

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文件中添加

// 封装一些操作 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中添加:

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:www.npmjs.com/package/cho…
思路就是监听文件变化后,把变化的文件名做处理(就是把文件的绝对地址转换成浏览器里请求的相对地址),通过websocket告诉前端,那个模块变更了,让他重新导入。

监听文件变更:

dev.js中添加

// 监听文件变更
function watch() {
    return chokidar.watch(targetRootPath, {
        ignored: ["**/node_modules/**", "**/.cache/**"],
        ignoreInitial: true,
        ignorePermissionErrors: true,
        disableGlobbing: true,
    });
}
复制代码

dev函数里添加watch:

    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");
    });
复制代码

处理热更新的函数:

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中处理文件变更:

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。
完整项目地址:gitee.com/zyl-ll/vite…

猜你喜欢

转载自juejin.im/post/7076802362618150942