• Vite入门从手写一个乞丐版的Vite开始(上)


    Vite是什么就不用笔者多说了,用过Vue的朋友肯定都知道,本文会通过手写一个非常简单的乞丐版Vite来了解一下Vite的基本实现原理,参考的是Vite最早的版本(vite-1.0.0-rc.5版本,Vue版本为3.0.0-rc.10)实现的,现在已经是3.x的版本了,为什么不直接参考最新的版本呢,因为一上来就看这种比较完善的工具源码比较难看懂,反正笔者不行,所以我们可以先从最早的版本来窥探一下原理,能力强的朋友可以忽略~

    本文会分为上下两篇,上篇主要讨论如何成功运行项目,下篇主要讨论热更新

    前端测试项目

    前端测试项目结构如下:

    Vue组件使用的是Options Api ,不涉及到css预处理语言、tsjs语言,所以是一个非常简单的项目,我们的目标很简单,就是要写一个Vite服务让这个项目能运行起来!

    搭建基本服务

    vite服务的基本结构如下:

    首先让我们来起个服务,HTTP应用框架我们使用connect

    // app.js
    const connect = require("connect");
    const http = require("http");
    
    const app = connect();
    
    app.use(function (req, res) {
      res.end("Hello from Connect!\n");
    });
    
    http.createServer(app).listen(3000);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    接下来我们需要做的就是拦截各种类型的请求来进行不同的处理。

    拦截html

    项目访问的入口地址是http://localhost:3000/index.html,所以接到的第一个请求就是html文件的请求,我们暂时直接返回html文件的内容即可:

    // app.js
    const path = require("path");
    const fs = require("fs");
    
    const basePath = path.join("../test/");
    const typeAlias = {
      js: "application/javascript",
      css: "text/css",
      html: "text/html",
      json: "application/json",
    };
    
    app.use(function (req, res) {
      // 提供html页面
      if (req.url === "/index.html") {
        let html = fs.readFileSync(path.join(basePath, "index.html"), "utf-8");
        res.setHeader("Content-Type", typeAlias.html);
        res.statusCode = 200;
        res.end(html);
      } else {
        res.end('')
      }
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    现在访问页面肯定还是一片空白,因为页面发起的main.js的请求我们还没有处理,main.js的内容如下:

    拦截js请求

    main.js请求需要做一点处理,因为浏览器是不支持裸导入的,所以我们要转换一下裸导入的语句,将import xxx from 'xxx'转换为import xxx from '/@module/xxx',然后再拦截/@module请求,从node_modules里获取要导入的模块进行返回。

    解析导入语句我们使用es-module-lexer

    // app.js
    const { init, parse: parseEsModule } = require("es-module-lexer");
    
    app.use(async function (req, res) {
        if (/\.js\??[^.]*$/.test(req.url)) {
            // js请求
            let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
            await init;
            let parseResult = parseEsModule(js);
            // ...
        }
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    解析的结果为:

    解析结果为一个数组,第一项也是个数组代表导入的数据,第二项代表导出,main.js没有,所以是空的。se代表导入来源的起止位置,ssse代表整个导入语句的起止位置。

    接下来我们检查当导入来源不是./开头的就转换为/@module/xxx的形式:

    // app.js
    const MagicString = require("magic-string");
    
    app.use(async function (req, res) {
        if (/\.js\??[^.]*$/.test(req.url)) {
            // js请求
            let js = fs.readFileSync(path.join(basePath, req.url), "utf-8");
            await init;
            let parseResult = parseEsModule(js);
            let s = new MagicString(js);
            // 遍历导入语句
            parseResult[0].forEach((item) => {
                // 不是裸导入则替换
                if (item.n[0] !== "." && item.n[0] !== "/") {
                    s.overwrite(item.s, item.e, `/@module/${item.n}`);
                }
            });
            res.setHeader("Content-Type", typeAlias.js);
            res.statusCode = 200;
            res.end(s.toString());
        }
    });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    修改js字符串我们使用了magic-string,从这个简单的示例上你应该能发现它的魔法之处,就是即使字符串已经变了,但使用原始字符串计算出来的索引修改它也还是正确的,因为索引还是相对于原始字符串。

    可以看到vue已经成功被修改成/@module/vue了。

    紧接着我们需要拦截一下/@module请求:

    // app.js
    const { buildSync } = require("esbuild");
    
    app.use(async function (req, res) {
        if (/^\/@module\//.test(req.url)) {
            // 拦截/@module请求
            let pkg = req.url.slice(9);
            // 获取该模块的package.json
            let pkgJson = JSON.parse(
                fs.readFileSync(
                    path.join(basePath, "node_modules", pkg, "package.json"),
                    "utf8"
                )
            );
            // 找出该模块的入口文件
            let entry = pkgJson.module || pkgJson.main;
            // 使用esbuild编译
            let outfile = path.join(`./esbuild/${pkg}.js`);
            buildSync({
                entryPoints: [path.join(basePath, "node_modules", pkg, entry)],
                format: "esm",
                bundle: true,
                outfile,
            });
            let js = fs.readFileSync(outfile, "utf8");
            res.setHeader("Content-Type", typeAlias.js);
            res.statusCode = 200;
            res.end(js);
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    我们先获取了包的package.json文件,目的是找出它的入口文件,然后读取并使用esbuild进行转换,当然Vue是有ES模块的产物的,但是可能有的包没有,所以直接就统一处理了。

    拦截css请求

    css请求有两种,一种来源于link标签,一种来源于import方式,link标签的css请求我们直接返回css即可,但是importcss直接返回是不行的,ES模块只支持js,所以我们需要转成js类型,主要逻辑就是手动把css插入页面,所以这两种请求我们需要分开处理。

    为了能区分import请求,我们修改一下前面拦截js的代码,把每个导入来源都加上?import查询参数:

    // ...
    	// 遍历导入语句
        parseResult[0].forEach((item) => {
          // 不是裸导入则替换
          if (item.n[0] !== "." && item.n[0] !== "/") {
            s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
          } else {
            s.overwrite(item.s, item.e, `${item.n}?import`);
          }
        });
    //...
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    拦截/@module的地方也别忘了修改:

    // ...
    let pkg = removeQuery(req.url.slice(9));// 从/@module/vue?import中解析出vue
    // ...
    
    // 去除url的查询参数
    const removeQuery = (url) => {
      return url.split("?")[0];
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这样import的请求就都会带上一个标志:

    然后根据这个标志来分别处理css请求:

    // app.js
    
    app.use(async function (req, res) {
        if (/\.css\??[^.]*$/.test(req.url)) {
            // 拦截css请求
            let cssRes = fs.readFileSync(
                path.join(basePath, req.url.split("?")[0]),
                "utf-8"
            );
            if (checkQueryExist(req.url, "import")) {
                // import请求,返回js文件
                cssRes = `
                    const insertStyle = (css) => {
                        let el = document.createElement('style')
                        el.setAttribute('type', 'text/css')
                        el.innerHTML = css
                        document.head.appendChild(el)
                    }
                    insertStyle(\`${cssRes}\`)
                    export default insertStyle
                `;
                res.setHeader("Content-Type", typeAlias.js);
            } else {
                // link请求,返回css文件
                res.setHeader("Content-Type", typeAlias.css);
            }
            res.statusCode = 200;
            res.end(cssRes);
        }
    })
    
    // 判断url的某个query名是否存在
    const checkQueryExist = (url, key) => {
      return new URL(path.resolve(basePath, url)).searchParams.has(key);
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    如果是import导入的css那么就把它转换为js类型的响应,然后提供一个创建style标签并插入到页面的方法,并且立即执行,那么这个css就会被插入到页面中,一般这个方法会被提前注入页面。

    如果是link标签的css请求直接返回css即可。

    拦截vue请求

    最后,就是处理Vue单文件的请求了,这个会稍微复杂一点,处理Vue单文件我们使用@vue/compiler-sfc3.0.0-rc.10版本,首先需要把Vue单文件的templatejsstyle三部分解析出来:

    // app.js
    const { parse: parseVue } = require("@vue/compiler-sfc");
    
    app.use(async function (req, res) {
        if (/\.vue\??[^.]*$/.test(req.url)) {
        // Vue单文件
        let vue = fs.readFileSync(
          path.join(basePath, removeQuery(req.url)),
          "utf-8"
        );
        let { descriptor } = parseVue(vue);
      }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    然后再分别解析三部分,templatecss部分会转换成一个import请求。

    处理js部分

    // ...
    const { compileScript, rewriteDefault } = require("@vue/compiler-sfc");
    
    let code = "";
    // 处理js部分
    let script = compileScript(descriptor);
    if (script) {
        code += rewriteDefault(script.content, "__script");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    rewriteDefault方法用于将export default转换为一个新的变量定义,这样我们可以注入更多数据,比如:

    // 转换前
    let js = `
        export default {
            data() {
                return {}
            }
        }
    `
    
    // 转换后
    let js = `
        const __script = {
            data() {
                return {}
            }
        }
    `
    
    //然后可以给__script添加更多属性,最后再手动添加到导出即可
    js += `\n__script.xxx = xxx`
    js += `\nexport default __script`
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    处理template部分

    // ...
    // 处理模板
    if (descriptor.template) {
        let templateRequest = removeQuery(req.url) + `?type=template`;
        code += `\nimport { render as __render } from ${JSON.stringify(
            templateRequest
        )}`;
        code += `\n__script.render = __render`;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    将模板转换成了一个import语句,然后获取导入的render函数挂载到__script上,后面我们会拦截这个type=template的请求,返回模板的编译结果。

    处理style部分

    // ...
    // 处理样式
    if (descriptor.styles) {
        descriptor.styles.forEach((s, i) => {
            const styleRequest = removeQuery(req.url) + `?type=style&index=${i}`;
            code += `\nimport ${JSON.stringify(styleRequest)}`
        })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    和模板一样,样式也转换成了一个单独的请求。

    最后导出__script并返回数据:

    // ...
    // 导出
    code += `\nexport default __script`;
    res.setHeader("Content-Type", typeAlias.js);
    res.statusCode = 200;
    res.end(code);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    可以看到__script其实就是一个Vue的组件选项对象,模板部分编译的结果就是组件的渲染函数render,相当于把js和模板部分组合成一个完整的组件选项对象。

    处理模板请求

    Vue单文件的请求url存在type=template参数,我们就编译一下模板然后返回:

    // app.js
    const { compileTemplate } = require("@vue/compiler-sfc");
    
    app.use(async function (req, res) {
        if (/\.vue\??[^.]*$/.test(req.url)) {
            // vue单文件
            // 处理模板请求
            if (getQuery(req.url, "type") === "template") {
                // 编译模板为渲染函数
                code = compileTemplate({
                    source: descriptor.template.content,
                }).code;
                res.setHeader("Content-Type", typeAlias.js);
                res.statusCode = 200;
                res.end(code);
                return;
            }
            // ...
        }
    })
    
    // 获取url的某个query值
    const getQuery = (url, key) => {
      return new URL(path.resolve(basePath, url)).searchParams.get(key);
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    处理样式请求

    样式和前面我们拦截样式请求一样,也需要转换成js然后手动插入到页面:

    // app.js
    const { compileTemplate } = require("@vue/compiler-sfc");
    
    app.use(async function (req, res) {
        if (/\.vue\??[^.]*$/.test(req.url)) {
            // vue单文件
        }
        // 处理样式请求
        if (getQuery(req.url, "type") === "style") {
            // 获取样式块索引
            let index = getQuery(req.url, "index");
            let styleContent = descriptor.styles[index].content;
            code = `
                const insertStyle = (css) => {
                    let el = document.createElement('style')
                    el.setAttribute('type', 'text/css')
                    el.innerHTML = css
                    document.head.appendChild(el)
                }
                insertStyle(\`${styleContent}\`)
                export default insertStyle
            `;
            res.setHeader("Content-Type", typeAlias.js);
            res.statusCode = 200;
            res.end(code);
            return;
        }
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    样式转换为js的这个逻辑因为有两个地方用到了,所以我们可以提取成一个函数:

    // app.js
    // css to js
    const cssToJs = (css) => {
      return `
        const insertStyle = (css) => {
            let el = document.createElement('style')
            el.setAttribute('type', 'text/css')
            el.innerHTML = css
            document.head.appendChild(el)
        }
        insertStyle(\`${css}\`)
        export default insertStyle
      `;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    修复单文件的裸导入问题

    单文件内的js部分也可以导入模块,所以也会存在裸导入的问题,前面介绍了裸导入的处理方法,那就是先替换导入来源,所以单文件的js部分解析出来以后我们也需要进行一个替换操作,我们先把替换的逻辑提取成一个公共方法:

    // 处理裸导入
    const parseBareImport = async (js) => {
      await init;
      let parseResult = parseEsModule(js);
      let s = new MagicString(js);
      // 遍历导入语句
      parseResult[0].forEach((item) => {
        // 不是裸导入则替换
        if (item.n[0] !== "." && item.n[0] !== "/") {
          s.overwrite(item.s, item.e, `/@module/${item.n}?import`);
        } else {
          s.overwrite(item.s, item.e, `${item.n}?import`);
        }
      });
      return s.toString();
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    然后编译完js部分后立即处理一下:

    // 处理js部分
    let script = compileScript(descriptor);
    if (script) {
        let scriptContent = await parseBareImport(script.content);// ++
        code += rewriteDefault(scriptContent, "__script");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    另外,编译后的模板部分代码也会存在一个裸导入Vue,也需要处理一下:

    // 处理模板请求
    if (
        new URL(path.resolve(basePath, req.url)).searchParams.get("type") ===
        "template"
    ) {
        code = compileTemplate({
            source: descriptor.template.content,
        }).code;
        code = await parseBareImport(code);// ++
        res.setHeader("Content-Type", typeAlias.js);
        res.statusCode = 200;
        res.end(code);
        return;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    处理静态文件

    App.vue里面引入了两张图片:

    编译后的结果为:

    ES模块只能导入js文件,所以静态文件的导入,响应结果也需要是js

    // vite/app.js
    app.use(async function (req, res) {
        if (isStaticAsset(req.url) && checkQueryExist(req.url, "import")) {
            // import导入的静态文件
            res.setHeader("Content-Type", typeAlias.js);
            res.statusCode = 200;
            res.end(`export default ${JSON.stringify(removeQuery(req.url))}`);
        }
    })
    
    // 检查是否是静态文件
    const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/;
    const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/;
    const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i;
    const isStaticAsset = (file) => {
      return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file);
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    import导入的静态文件处理很简单,直接把静态文件的url字符串作为默认导出即可。

    这样我们又会收到两个静态文件的请求:

    简单起见,没有匹配到以上任何规则的我们都认为是静态文件,使用serve-static来提供静态文件服务即可:

    // vite/app.js
    const serveStatic = require("serve-static");
    
    app.use(async function (req, res, next) {
        if (xxx) {
            // xxx
        } else if (xxx) {
            // xxx
            // ...
        } else {
            next();// ++
        }
    })
    
    // 静态文件服务
    app.use(serveStatic(path.join(basePath, "public")));
    app.use(serveStatic(path.join(basePath)));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    静态文件服务的中间件放到最后,这样没有匹配到的路由就会走到这里,到这一步效果如下:

    可以看到页面已经被加载出来。

    下一篇我们会介绍一下热更新的实现,See you later~

  • 相关阅读:
    javascript代码重构: 如何写好函数的9个技巧
    ComText让机器人有了情节记忆
    《软件方法》2023版第1章(10)应用UML的建模工作流-大图
    Java 线程池将数据从主线程传到子线程
    索引介绍及索引的分类
    HarmonyOS 鸿蒙隔离层设计
    深入解析Java HashMap的Resize源码
    队列的简单实现
    IDEA中使用Git
    【JavaSE】类与对象(上)类是什么?对象是什么?
  • 原文地址:https://blog.csdn.net/sinat_33488770/article/details/126843551