Vite原理与实现

1,452 阅读5分钟

vite介绍

  • vite的2.0版本已经发布了,作为下一代前端开发工具,它究竟有什么魔力?今天我们来研究一下,做个简单的实现
  • vite的特点是快,本地开发可以达到秒启动,极大提高了开发体验
  • 原理:vite是基于浏览器对ESMoudule的支持,script标签加上type="module"就可以解析ES6模块,浏览器解析到ES6的import语法时,会根据路径从服务端获取要引入的文件,服务端会根据import的文件路径和类型进行解析
  • vite只加载当前页面依赖到的文件,加载时通过后端服务进行代码解析,变成了懒加载+服务端解析代码的方式
  • vite主要作用是本地开发使用,项目上线打包内部还是用rollup来进行打包
  • 对比webpack是将整个项目打包后放入内存中,所以每次启动对整个项目进行打包,浏览器加载也是对整个项目进行加载
  • 这个是vite加载文件的流程图,(koa中间件画的不太规范)
  • 接下来我们来一步步实现整个流程

生成vite项目

项目生成与启动

npm init vite-app vite-vue
// 安装对应依赖
yarn add
// 启动项目
yarn vite

加载文件分析

  • 可以看到,启动项目加载了 项目中的 index.html文件,script的type是module说明是ESModule加载的

  • 加载执行main.js文件

在项目中建一个文件夹vite来实现我们自己的vite

mkdir vite && cd vite
npm init -y

实现命令行vite命令

  • 1.在vite下新建bin目录,并新建www.js文件,作为项目的入口文件
  • 2.在package.json中配置新增 bin,当我们执行vite命令的时候,会执行bin对应的文件
"bin": "./bin/www.js"
  • 生成vite命令,将vite命令链接到电脑的可执行文件,这样就可以直接在命令行使用vite命令了
npm link
  • www.js文件
    • #! /usr/bin/env node 声明这个文件是用node来执行
#! /usr/bin/env node

// 执行的入口,执行vite命令的时候会在控制台打印
console.log("vite命令入口");
// 引用项目中的src/server文件,并执行
require("../src/server");

新建src目录,在src文件中实现需要的功能

首先实现静态文件服务

  • 安装koa与静态服务中间件
yarn add koa koa-static
  • 引用koa,通过createServer函数创建一个koa应用,监听在4000端口上
  • 创建一个上下文,给不同的插件共享功能,app-当前应用,root-执行vite命令时的工作目录
  • 引入静态服务中间件serverStaticPlugin,将上下文context,传入中间件中,使用中间件
const Koa = require("koa");
const { serverStaticPlugin } = require("./serverPluginServeStatic");

function createServer() {
  let app = new Koa();
  // 实现静态服务功能,就是访问服务器,返回对应的文件  koa-static
  
  const context = {
    app,
    root: process.cwd(), // 执行命令的工作目录
  };

  const resolvePlugin = [
    serverStaticPlugin, // 1.静态服务插件,实现返回文件功能
  ];

  resolvePlugin.forEach((plugin) => plugin(context));
  return app;
}

createServer().listen(4000, () => {
  console.log("vite start 4000");
});
  • 静态服务中间件serverStaticPlugin,将执行vite命令时的工作目录,和根目录下的public作为静态服务的根目录,在根目录中找不到时会到public目录中去找到资源文件
const static = require("koa-static");
const path = require("path");
function serverStaticPlugin({ app, root }) {
  app.use(static(root));
  app.use(static(path.resolve(root, "public")));
}

exports.serverStaticPlugin = serverStaticPlugin;

我们在项目中执行一下vite命令,看下效果

  • 成功加载了 index.html 和 main.js文件

  • 但是会发现控制台报了个错

  • 这是因为vue是第三方模块,在main.js中直接 import了'vue'文件,但是vue没有相对或者绝对路径,加载vue时识别不了

然后我们来实现ModuleRewriteModuleResolve中间件处理引用第三方模块的问题

  • 逻辑是,当我们发现有第三方模的引用时,在引用前加上特殊标识 /@modules/
  • 当客户端请求带有 /@modules/路径的模块时,我们在加载node_modules中的模块

ModuleRewrite中间件

  • 安装 es-module-lexermagic-string来处理正则和字符串
// 正则  字符串处理
yarn add es-module-lexer  magic-string
  • serverPluginModuleRewrite中间件 重写 import,修改import加载的第三方模块路径
  • 服务端收到请求后,先执行静态服务中间件,将资源的文件流放在ctx.body中
  • 通过readBody将文件流转换为字符串,
  • 再用rewriteImports方法找出其中的import语法,改写第三方模块的引用路径,将 import xx from "vue";修改为import xx from "/@modules/vue";
const { readBody, rewriteImports } = require("./utils");

// 模块重写插件
function moduleRewritePlugin({ app, root }) {
  console.log("中间件逻辑");
  app.use(async (ctx, next) => {
    await next(); // 静态服务
    // 默认会先执行,静态服务中间件  会将结果放到ctx.body
    // 需要将流转换 字符串
    // 只需要处理js中的引用问题
    if (ctx.body && ctx.response.is("js")) {
      // 将文件流转换为字符串
      const r = await readBody(ctx.body);
      console.log(ctx.body);

      const result = rewriteImports(r);
      ctx.body = result;
    }
  });
}

exports.moduleRewritePlugin = moduleRewritePlugin;
  • 两个工具函数实现
const { Readable } = require("stream");
const { parse } = require("es-module-lexer");
const MagicString = require("magic-string");

// 将流转换为字符串
async function readBody(stream) {
  if (stream instanceof Readable) {
    return new Promise((resolve, reject) => {
      let res = "";
      stream.on("data", function (chunk) {
        res += chunk;
      });

      stream.on("end", function () {
        resolve(res);
      });

      stream.on("error", reject);
    });
  } else {
    return stream;
  }
}

// 重写第三方库的import方法
function rewriteImports(source) {
  let imports = parse(source)[0];
  let ms = new MagicString(source);
  console.log("ms", ms);
  // 所有import的语法
  console.log(imports);
  if (imports.length > 0) {
    for (let i = 0; i < imports.length; i++) {
      let { s, e } = imports[i];
      let id = source.slice(s, e); // 应用 的标识
      // 不是./ 或者 /
      if (/^[^\/\.@]/.test(id)) {
        id = `/@modules/${id}`;
        ms.overwrite(s, e, id);
      }
    }
  }
  return ms.toString();
}

module.exports = {
  readBody,
  rewriteImports,
};
  • 执行vite命令,可以看到返回的main.js文件被重写

ModuleResolve 模块加载中间件

  • 主要用来解析我们上一步,重写的路径的第三方模块
  • 匹配请求路径,如果发现是/@modules/前缀,就在node_modules中找对应模块返回给浏览器(这一步先写死,只写了vue对应要真实加载的文件)
const reg = /^\/@modules\//;
const path = require("path");
const fs = require("fs").promises;
function moduleResolvePlugin({ app, root }) {
  app.use(async (ctx, next) => {
    // 如果没有匹配到 /@modules, 就往下执行
    if (!reg.test(ctx.path)) {
      return next();
    }

    const id = ctx.path.replace(reg, "");
    ctx.type = "js"; // 返回的是js

    // 找到真实的vue文件,找node_modules里面的库
    let mapping = {
      vue: path.resolve(
        root,
        "node_modules",
        "@vue/runtime-dom/dist/runtime-dom.esm-browser.js"
      ),
    };

    const content = await fs.readFile(mapping[id], "utf8");
    ctx.body = content;
  });
}

exports.moduleResolvePlugin = moduleResolvePlugin;

  • 执行命令,可以看到四个文件都成功加载了,但是我们的浏览器是不识别.vue文件的

解析vue文件

  • vue文件中包含了templatescript两个部分,vite中将vue文件进行了拆分,script部分放在App.vue文件中,template部分放在了App.vue?type=template请求中
  • 在App.vue中插入了 import App.vue?type=template代码,来加载template模板

serverPluginVue中间件

  • 新建serverPluginVue中间件,来解析vue文件
  • 引用vue中的compiler-sfc,获取compileTemplate-编译template模板, parse-解析vue文件
const path = require("path");
const fs = require("fs").promises;
function vuePlugin({ app, root }) {
  app.use(async (ctx, next) => {
  	// 不是vue文件直接返回
    if (!ctx.path.endsWith(".vue")) {
      return next();
    }

    // 获取.vue文件内容
    const filePath = path.join(root, ctx.path);
    const content = await fs.readFile(filePath, "utf8");

    // compileTemplate - 解析vue模板
    // parse - 解析vue文件方法
    const { compileTemplate, parse } = require(path.resolve(
      root,
      "node_modules",
      "@vue/compiler-sfc/dist/compiler-sfc.cjs"
    ));
	// 解析vue文件
    const { descriptor } = parse(content);
    // 请求的query参数中不带 ?type=template说明是原始的vue文件
    if (!ctx.query.type) {
      let code = "";

      if (descriptor.script) {
        let content = descriptor.script.content;
		// 将script中的 export default替换为const __script=
        code += content.replace(
          /((?:^|\n|;)\s*)export default/,
          "$1const __script="
        );
      }

      // vue文件中有template模板代码
      if (descriptor.template) {
      	// 加入引入 import 'App.vue?type=template',来引入template内容
        const requestPath = ctx.path + `?type=template`;
        code += `\nimport {render as __render} from "${requestPath}"`;
        code += `\n__script.render = __render`;
      }

      code += `\nexport default __script`;

      ctx.type = "js";
      ctx.body = code;
    }
	
    // 请求的query参数带 ?type=template说明是加载 编译后的template内容
    if (ctx.query.type === "template") {
      ctx.type = "js";
      let content = descriptor.template.content;
      const { code } = compileTemplate({ source: content }); // 将app.vue中的template模板转换成render函数
      ctx.body = code;
    }
  });
}

exports.vuePlugin = vuePlugin;

引入中间件使用

  • 注意中间件的使用顺序
function createServer() {
  let app = new Koa();
  // 实现静态服务功能,就是访问服务器,返回对应的文件  koa-static
  // 创建一个上下文,给不同的插件共享功能
  const context = {
    app,
    root: process.cwd(), // 执行命令的工作目录
  };

  // 解析.vue文件
  const resolvePlugin = [
    moduleRewritePlugin, // 2.重写我们的请求路径
    moduleResolvePlugin, // vue内部可能还有引入其他模块,都要修改为 /@modules
    vuePlugin, // 解析.vue文件
    serverStaticPlugin, // 1.静态服务插件,实现返回文件功能
  ];

  resolvePlugin.forEach((plugin) => plugin(context));
  return app;
}

代码奉上

这样我们实现了一个简化版的vite
漏洞和不足之处,还请大家指正