Vite

是什么

Webpack编译过程是静态的,会把所有可能用到的代码全部进行打包构建,组装各模块,这样打包出来的代码是十分庞大的,很多时候其实我们在开发过程中并不需要全部代码的功能,而是一小部分,这个时候大量的构建时间都是多余的,我们需要一个能够真正意义上实现懒加载的开发工具。

Vite 是一个由原生 ESM 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生ES Module 开发,在生产环境下基于Rollup打包。

浏览器原生 ESM: 浏览器会识别所有添加了type='module'script标签,对于该标签中的import关键字,浏览器会发起http请求获取模块内容。

特点:

  1. 快速的冷启动: No Bundle + esbuild 预构建
  2. 即时的模块热更新: 基于 ESM 的 HMR,同时利用浏览器缓存策略提升速度
  3. 真正的按需加载: 利用浏览器 ESM 支持,实现真正的按需加载

为什么说 vite 比 webpack 更快

vite 的冷启动、热启动、热更新都会快

使用 webpack 时,从 yarn start 命令启动,到最后页面展示,需要经历的过程:

  1. 以 entry 配置项为起点,做一个全量的打包,并生成一个入口文件 index.html 文件;
  2. 启动一个 node 服务;
  3. 打开浏览器,去访问入 index.html,然后去加载已经打包好的 js、css 文件;

在整个过程,最重要的就是第一步中的全量打包,中间涉及到构建 module graph (涉及到大量度文件操作、文件内容解析、文件内容转换)、chunk 构建,这个需要消耗大量的时间。尽管在二次启动、热更新过程中,在构建 module graph 中可以充分利用缓存,但随着项目的规模越来越大,整个开发体验也越来越差。

使用 vite 时, 从 vite 命令启动,到最后的页面展示,需要经历的过程:

  1. 使用  esbuild  预构建依赖,提前将项目的第三方依赖格式化为 ESM 模块;
  2. 启动一个 node 服务;
  3. 打开浏览器,去访问 index.html;
  4. 基于浏览器已经支持原生的 ESM 模块, 逐步去加载入口文件以及入口文件的依赖模块。浏览器发起请求以后,dev server  端会通过  middlewares  对请求做拦截,然后对源文件做  resolve、load、transform、parse  操作,然后再将转换以后的内容发送给浏览器。

在第四步中,vite 需要逐步去加载入口文件以及入口文件的依赖模块,但在实际应用中,这个过程中涉及的模块的数量级并不大,需要的时间也较短。而且在分析模块的依赖关系时, vite 采用的是 esbuild,esbuild 使用 Go 编写,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍(webpack 就是采用 js )

综上,开发模式下 vite 比 webpack 快的原因:

  1. vite 不需要做全量的打包,这是比 webpack 要快的最主要的原因;
  2. vite 在解析模块依赖关系时,利用了 esbuild,更快(esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍);
  3. 按需编译:当浏览器请求某个模块时,再根据需要对模块内容进行编译,这种按需动态编译的方式,极大的缩减了编译时间。而不是像 webpack 那样进行打包合并。
  4. webpack 是先打包再启动开发服务器,vite 是直接启动开发服务器,然后按需编译依赖文件。由于 vite 在启动的时候不需要打包,也就意味着不需要分析模块的依赖、不需要编译,因此启动速度非常快。
  5. 充分利用缓存;Vite 利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据  304 Not Modified  进行协商缓存,而依赖模块请求则会通过  Cache-Control: max-age=31536000,immutable  进行强缓存,因此一旦被缓存它们将不需要再次请求。
  6. 在 HMR 方面,当改动了一个模块后,仅需让浏览器重新请求该模块即可,不像 webpack 那样需要把该模块的相关依赖模块全部编译一次,效率更高。

vite 对比 webpack ,优缺点在哪

优点

  1. 更快的冷启动Vite  借助了浏览器对  ESM  规范的支持,采取了与  Webpack  完全不同的  unbundle  机制
  2. 更快的热更新Vite  采用  unbundle  机制,所以  dev server  在监听到文件发生变化以后,只需要通过  ws  连接通知浏览器去重新加载变化的文件,剩下的工作就交给浏览器去做了。

缺点

  1. 开发环境下首屏加载变慢:由于  unbundle  机制,Vite  首屏期间需要额外做其它工作。不过首屏性能差只发生在  dev server  启动以后第一次加载页面时发生。之后再  reload  页面时,首屏性能会好很多。原因是  dev server  会将之前已经完成转换的内容缓存起来
  2. 开发环境下懒加载变慢:跟首屏加载变慢的原因一样。Vite  在懒加载方面的性能也比  Webpack  差。由于  unbundle  机制,动态加载的文件,需要做  resolveloadtransformparse  操作,并且还有大量的  http  请求,导致懒加载性能也受到影响。
  3. webpack 支持的更广。由于 Vite 基于 ES Module,所以代码中不可以使用 CommonJs;webpack 更多的关注兼容性, 而Vite 关注浏览器端的开发体验。Vite目前生态还不如  Webpack

原理分析

Vite 在第一次启动时会有一个优化依赖的过程,也就是说第一次启动可能相对而言会慢一点,但是你再次启动时你会发现它的速度基本时毫秒级,完全没有Webpack启动项目那般的沉重感。

我们打开Network,然后刷新页面,我们可以发现,它的请求是不是有点不太一样,不再是webpack长长的bundle,而是一个个小的请求。点开main.js,这个时候你会发现,和我们写的实际代码几乎没有区别,唯一改变的就是部分导入的模块路径被修改了。

image-20200909223433589

不仅如此,从其他请求中我们也可以看出每一个.vue文件都被拆分成了多个请求,并通过type来标识是template还是style

image-20200909223250307

所以 vite在这里做了两件事,第一是修改了模块请求路径,第二就是将.vue文件进行解析拆分。

1. 创建服务

使用koa来启动一个简单服务。现在的版本应该已经不是使用 Koa 了。

2. 托管静态资源

将目标项目的内容进行托管,专门用于处理静态资源的方法。

const KoaStatic = require("koa-static");
const path = require("path");

module.exports = function (context) {
  const { app, root } = context;
  // koa-static 中间件来托管静态资源
  app.use(KoaStatic(root));
  // 对于目标项目的 `public`目录也进行托管
  app.use(KoaStatic(path.resolve(root, "public")));
};

3. 重写模块路径

因为我们在使用import方式导入模块的时候,浏览器只能识别./..//这种开头的相对路径,对于直接使用模块名比如:import vue from 'vue',浏览器就会报错,因为它无法识别这种路径,这就是我们需要进行处理的地方了。

拦截响应的文件,并判断是否是js文件,因为只有js文件才可能使用import方法导入模块,接着读取响应的内容,然后调用重写模块路径的方法rewriteImports并返回给客户端。

rewriteImports这个方法:

// rewriteModulePlugin.js
const { parse } = require("es-module-lexer");
const MagicString = require("magic-string");

function rewriteImports(source) {
  imports = parse(source)[0];
  magicString = new MagicString(source);
  if (imports.length) {
    imports.forEach((item) => {
      const { s, e } = item;
      let id = source.substring(s, e);
      const reg = /^[^\/\.]/;
      if (reg.test(id)) {
        id = `/@modules/${id}`;
        magicString.overwrite(s, e, id);
      }
    });
  }
  return magicString.toString();
}

这里用到了两个第三方包:

es-module-lexer

主要用于解析目标字符串中的import语句,并将import语句后的模块路径的信息解析出来。它是一个数组,因为一般来说import不会只有一个,所以我们可以遍历这个列表来找出不符合要求的模块路径并进行重写。

数组中的每个元素都包含两个属性:s(模块路径在字符串中的起始位置)、e(模块路径在字符串中结束位置)。比如如下代码,我们以字符串的方式读取出来,传给es-module-lexerparse方法,那么返回的结果就是:[{s: 17, e: 21, ...}]

import vue from "vue";

s其实就是代表后面那个v的位置,e就代表上面e这个字符的后一位。

magic-string

这个包主要是用于修改源代码的,也就是用来替换相关的模块路径的工具。

介绍完上面两个包之后其他的代码就比较好理解了,首先我们对parse解析完的结果进行遍历,截取模块路径,并进行正则匹配,如果不是以./..//开头的,我们就对它进行重写,在对应的模块路径前加上/@modules前缀,以便于我们后续进行处理,然后将处理完内容返回给客户端。

重写完请求路径之后,我们就需要在服务端拦截/@modules开头的所有请求,并读取相应数据给客户端了。

4. 解析模块路径

在处理完所有模块路径之后,我们就需要在服务端来解析模块真实位置。首先新建一个文件plugins/server/moduleResolvePlugin.js,在index.js中导入:

const moduleResolvePlugin = require('./plugins/server/moduleResolvePlugin');
...
const resolvePlugins = [
    // 重写模块路径
    rewriteModulePlugin
    // 解析模块路径
    moduleResolvePlugin,
    // 配置静态资源服务
    serveStaticPlugin,
]

这里需要注意中间件的顺序问题,我们在读取完第三方模块给客户端时,也需要去解析该模块中会引入其他模块,那么它的路径也是需要处理的。

// moduleResolvePlugin.js
const fs = require("fs").promises;
const moduleReg = /^\/@modules\//;
module.exports = function ({ app, root }) {
  const vueResolved = resolveVue(root); // 根据vite运行路径解析出所有vue相关模块
  app.use(async (ctx, next) => {
    if (!moduleReg.test(ctx.path)) {
      return next();
    }
    // 去除/@modules/,拿到相关模块
    const id = ctx.path.replace(moduleReg, "");
    ctx.type = "js"; // 设置响应类型
    const content = await fs.readFile(vueResolved[id], "utf8");
    ctx.body = content;
  });
};

首先正则匹配请求路径,如果是/@modules开头就进行后续处理,否则就跳过,并设置响应类型为js,读取真实模块路径内容,返回给客户端。

这里重点应该在于怎么去获取模块真实路径,也就是代码中resolveVue需要做的事情,它会解析出一个真实路径与模块名的映射关系,我们就能通过模块名直接拿到真实路径。

// moduleResolvePlugin.js
const path = require("path");
function resolveVue(root) {
  // 首先明确一点,Vue@3 几个比较核心的包有:runtime-core runtime-dom reactivity shared
  // 其次我们还需要用到 compiler-sfc 进行后端编译 .vue 文件
  // 如果需要进行后端编译,我们就需要拿到 commonjs 规范的模块
  const compilerPkgPath = path.join(
    root,
    "node_modules",
    "@vue/compiler-sfc/package.json"
  );
  const compilerPkg = require(compilerPkgPath);
  // 通过package.json的main能够拿到相关模块的路径
  const compilerPath = path.join(
    path.dirname(compilerPkgPath),
    compilerPkg.main
  );
  // 用于解析其他模块路径
  const resolvePath = (name) =>
    path.join(root, "node_modules", `@vue/${name}/dist/${name}.esm-bundler.js`);
  const runtimeCorePath = resolvePath("runtime-core");
  const runtimeDomPath = resolvePath("runtime-dom");
  const reactivityPath = resolvePath("reactivity");
  const sharedPath = resolvePath("shared");
  return {
    compiler: compilerPath,
    "@vue/runtime-dom": runtimeDomPath,
    "@vue/runtime-core": runtimeCorePath,
    "@vue/reactivity": reactivityPath,
    "@vue/shared": sharedPath,
    vue: runtimeDomPath,
  };
}

正如注释中写道,Vue3几个比较核心的包有:runtime-coreruntime-domreactivityshared,以及编译.vue需要用到的compiler-sfc,因为对vue单文件解析将由服务端进行处理。

这里主要是处理Vue3相关的几个核心模块,暂时没有处理其他第三方模块,需要后续对第三方模块也进行解析,其实也比较简单,找出他们在node_modules中的入口文件,一般来说都是有规律的,之后只要接收到相关模块的请求就能进行统一读取返回了。

然后我们来看解析过程,由于compiler-sfc模块位置与其他几个不一样,所以先单独处理,首先拿到它对应的描述文件package.json,通过main字段就能知道它的入口文件是哪个:node_modules/@vue/compiler-sfc/package.json,然后拼接一下package.json所在目录,就能拿到该模块的真实路径了。

对于其他几个vue核心模块,由于他们的es模块查找规律是一样的,所以抽离一个解析函数resolvePath,就能做到统一处理了,这里解释一下为什么要写这么长的路径,因为模块默认导出都是commonjs的方式,而这对于浏览器来说是不识别的,所以需要找到对应的es模块。

最后返回一个模块与处理好的路径的映射对象,这样我们需要用到的几个模块就能顺利读取了。

为什么需要对vue的这些模块单独处理一下呢,因为我们在导入vue的时候,它的内部会去导这几个核心包,如果不预先进行解析,就无法找到这几个模块的位置,导致项目运行错误。

image-20200910210115834

点开图中vue这个模块返回的内容我们可以看到,这几个核心模块都是被包含了的。

5. 客户端注入

接下来我们还需要关注一个问题,对于一般的项目来说,我们经常会去使用process.env去判断环境,而如果你采用脚手架工具进行开发时webpack会来帮我们做这件事,所以在vite中我们也需要对它进行一个处理,如果没有这项处理你在运行项目时就会看到这样的报错:

image-20200910210543937

它会告诉我们process这个变量并没有被定义,所以说我们需要在客户端注入相关的代码。

新建一个文件plugins/server/htmlRewritePlugin.js,并在index.js中写入:

// index.js
const resolvePlugins = [
  // 重写html,插入需要的代码
  htmlRewritePlugin,
  // 重写模块路径
  rewriteModulePlugin,
  // 解析模块路径
  moduleResolvePlugin,
  // 配置静态资源服务
  serveStaticPlugin,
];

同样也需要注意中间件的顺序问题,这个中间件必须处于serveStaticPlugin之前,因为需要保证它能够捕捉到html相关文件的 请求,这里把它放到第一位。

const { readBody } = require("./utils");

// 用于处理项目获取环境变量报错问题
module.exports = function ({ root, app }) {
  const inject = `
        <script type='text/javasript'>
            window.process = {
                env: {
                    NODE_ENV: 'development'
                }
            };
        </script>
    `;
  app.use(async (ctx, next) => {
    await next();
    if (ctx.response.is("html")) {
      let html = await readBody(ctx.body);
      ctx.body = html.replace(/<head>/, `$&${inject}`);
    }
  });
};

创建一个script标签,并在window上手动挂载这个全局变量,并把模式置为开发模式,然后将其插入到head标签中,这样客户端在解析html文件的时候就能将这段代码执行了。

6. 解析.vue文件

准备

接下来就到我们十分有意思的地方了,深入探究vite如何将单文件编译成多个请求的。

首先还是先创建一个文件到plugins/server下,并在index.js中引入:

// index.js
const resolvePlugins = [
  // 重写html,插入需要的代码
  htmlRewritePlugin,
  // 重写模块路径
  rewriteModulePlugin,
  // 解析.vue文件
  vueServerPlugin,
  // 解析模块路径
  moduleResolvePlugin,
  // 配置静态资源服务
  serveStaticPlugin,
];

在详细研究内部实现之前,我们先需要明确一下需要把它处理成什么样子,这里我们同样打开我们的Vue3项目地址,找到它对App.vue的返回结果:

image-20200910212716080

这里将一个单文件组件分为了几个部分,一个是script部分,用一个对象保存,并在下方给该对象添加render方法,最后导出这个对象,而这个render方法是从导入的,其实它本质上就是获取在服务端解析好的用于渲染单文件组件中template标签下的内容的渲染函数。

然后就是将多个style标签也在服务端解析出来并在客户端以请求的方式获取。

分类型解析

接下来我们来看代码怎么打造出这样的结构,并处理这几个请求。

// plugins/server/vueServerPlugin.js
function getCompilerPath(root) {
  const compilerPkgPath = path.join(
    root,
    "node_modules",
    "@vue/compiler-sfc/package.json"
  );
  const compilerPkg = require(compilerPkgPath);
  // 通过package.json的main能够拿到相关模块的路径
  return path.join(path.dirname(compilerPkgPath), compilerPkg.main);
}
module.exports = function ({ app, root }) {
  app.use(async (ctx, next) => {
    const filepath = path.join(root, ctx.path);
    if (!ctx.path.endsWith(".vue")) {
      return next();
    }
    // 拿到文件内容
    const content = await readFile(filepath, "utf8");
    const { parse, compileTemplate } = require(getCompilerPath(root));
    const { descriptor } = parse(content); // 解析文件内容
  });
};

这里先截取一小部分进行解析,同样注册一个中间件,并判断当前请求的文件是不是.vue结尾,因为这个中间件只对单文件组件进行处理,对于非.vue文件就直接跳过就行了。

如果是vue文件,我们就使用compiler-sfc这个模块对该文件进行解析,这里我们暂时只用到了它的两个方法,一个是parse,用于解析组件为几个不同部分,第二个就是用来编译template内容的方法。

先调用parse方法拿到descriptor这个对象,它包含了我们所需要的 scripttemplatestyle相关数据,下面来看怎么一一解析并返回给客户端。

// plugins/server/vueServerPlugin.js
const defaultExportRE = /((?:^|\n|;)\s*)export default/;
if (!ctx.query.type) {
  let code = "";
  if (descriptor.script) {
    let content = descriptor.script.content;
    let replaced = content.replace(defaultExportRE, "$1const __script = ");
    code += replaced;
  }
  if (descriptor.styles.length) {
    descriptor.styles.forEach((item, index) => {
      code += `\nimport "${ctx.path}?type=style&index=${index}"\n`;
    });
  }
  if (descriptor.template) {
    const templateRequest = ctx.path + "?type=template";
    code += `\nimport { render as __render } from ${JSON.stringify(
      templateRequest
    )}`;
    code += `\n__script.render = __render`;
  }
  ctx.type = "js";
  code += `\nexport default __script`;
  ctx.body = code;
}

自顶向下看,定义一个code变量,用于后续代码拼接。外层先会判断是否是被处理过的请求(被处理后的请求都会存在query参数),然后判断descriptor上有没有script属性(也就是单文件组件中是否存在 script标签),如果存在则给code变量添加相关代码。

我们在看它的处理代码部分前,最好再回想一下我们上面介绍过的一个vue组件需要被处理成什么样,

首先拿到解析后的内容,它是一个以export default开头的串,所以我们为了达到vite处理后的结果,就需要把它替换一下,用一个变量来保存,过程大致如下:

export default {
    ...
}
// ====>
const __script = {
    ...
}

接下来再来处理style,同样会先进行判断,它其实是一个数组,因为style标签可能会存在多个,所以只要判断一下它的length是否大于零,如果大于零就继续往下处理,对于style而言,这里不进行详细内容改动,而只是在code中添加import关键字,将这个style以请求的形式在后续进行处理,并在原有路径后面拼接该类型相关的type,用于标识该请求的处理方式,并给每个style请求代码序号。

相应的,我们再来看template的在这里的处理方式,同样不处理它的内容,也是使用import方式让浏览器去发起一个新的请求,并在这个请求后面拼接type,表明该请求的目标内容,然后拿到导出的render函数,并挂载到__script对象上。

处理拆解内容

综合上述拆解过程,我们现在对于styletemplate类型的请求还没有处理,所以,接下来需要将这部分详细的内容解析完返回给客户端。

style的处理:

// plugins/server/vueServerPlugin.js
if (ctx.query.type === "style") {
  const styleBlock = descriptor.styles[ctx.query.index];
  ctx.type = "js";
  ctx.body = `
        \n const __css = ${JSON.stringify(styleBlock.content)}
        \n updateCss(__css)
        \n export default __css
    `;
}

首先拿到请求的query参数中的当前style请求的需要,也就是它在descriptor.styles中的索引位置,然后就能拿到这个style标签内部的内容,接着设置响应类型,再将需要在客户端执行挂载css的代码返回给客户端。

这里我们唯一需要关注的点就在于updateCss这个方法,它是用来处理css的解析的,也是需要预先被注入到客户端,所以我们就需要在之前客户端注入的中间件中加上该方法。

// plugins/server/htmlRewritePlugin.js
const { readBody } = require("./utils");

// 用于处理项目获取环境变量报错问题
module.exports = function ({ root, app }) {
  const inject = `
        <script type='text/javasript'>
            window.process = {
                env: {
                    NODE_ENV: 'development'
                }
            };
			function updateCss(css) {
                const style = document.createElement('style');
                style.type = 'text/css';
                style.innerHTML = css;
                document.head.appendChild(style);
            }
        </script>
    `;
  app.use(async (ctx, next) => {
    await next();
    if (ctx.response.is("html")) {
      let html = await readBody(ctx.body);
      ctx.body = html.replace(/<head>/, `$&${inject}`);
    }
  });
};

这里的实现实际上十分简单,就直接创建一个style标签并添加到head头中即可,这样就能让相关css生效了。

最后就只剩下处理template类型请求了:

// plugins/server/vueServerPlugin.js
if (ctx.query.type === "template") {
  ctx.type = "js";
  let content = descriptor.template.content;
  const { code } = compileTemplate({ source: content });
  ctx.body = code;
}

首先设置响应类型,表明这是一个js文件,然后拿到descriptor上的template的内容,使用compiler-sfccompileTemplate编译一下,拿到最终结果中的code,并作为返回体回传给客户端。

自此整个流程基本叙述完毕了。

vite 的简单原理总结

实现原理是利用 es6 的 import 发送请求去加载文件的特性,拦截这些请求,做一些预编译,省去 webpack 冗长的打包时间。

  1. 用 node 进程托管静态资源请求
  2. 重写模块路径,浏览器只能识别 ./..//这种开头的路径,直接 import 的 npm 包,路径需要被重写为 /@modules
  3. 解析模块的路径,在 node_modules 中读取 vue 的相关包,其中最主要的是 compiler-sfc.Vue3几个比较核心的包有:runtime-coreruntime-domreactivityshared.对第三方模块也进行解析,也是在 node_modules 文件夹中找到对应的入口文件。
  4. 客户端注入,在 index.html 文件中动态注入了一个 script 标签, 内容是设置 process 全局变量以及一个 updateCss 函数。
  5. compiler-sfc 解析 vue 文件,会被拆成 template script 和 style 三个部分。
    1. template 会被编译成一个 render 函数,放在一个 __script 对象中,最终返回的代码是一个 js 文件内容是导出了一个 script;
    2. script 部分基本不变,扩展到该对象中;
    3. style 部分会处理成一个 updateCss 函数,通过原生方式在插入一个 style 标签。

核心原理

  1. Vite 其核心原理是利用浏览器现在已经支持 ES6 的 import,碰见 import 就会发送一个 HTTP 请求去加载文件。
  2. Vite 启动一个 connect 服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以 ESM 格式返回返回给浏览器。整个过程中没有对文件进行打包编译。
  3. Webpack 是先解析依赖、打包构建再启动开发服务器,Dev Server 必须等待所有模块构建完成,当我们修改了 bundle 模块中的一个子模块, 整个 bundle 文件都会重新打包然后输出。项目应用越大,启动时间越长。
  4. 而 Vite 利用浏览器对 ESM 的支持,当 import 模块时,浏览器就会下载被导入的模块。先启动开发服务器,当代码执行到模块加载时再请求对应模块的文件,本质上实现了动态加载。
  5. 目前所有的打包工具实现热更新的思路都大同小异:主要是通过 WebSocket 创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。
  6. Vite 通过 chokidar 来监听文件系统的变更,只用对发生变更的模块重新加载, 只需要精确的使相关模块与其临近的 HMR 边界连接失效即可,这样 HMR 更新速度就不会因为应用体积的增加而变慢。

vite 热更新原理

https://juejin.cn/post/6844904146915573773#heading-10 https://juejin.cn/post/6868591130720600078#heading-6

  1. 通过创建 WebSocket 建立浏览器与服务器建立通信,通过监听文件的改变像客户端发出消息,客户端对应不同的文件进行不同的操作的更新。
  2. 针对 Vue 组件本身的一些更新,都可以直接调用 HMRRuntime 提供的方法,非常方便。
  3. 其余的更新逻辑,基本上都是利用了 timestamp 刷新缓存重新执行的方法来达到更新的目的。

服务端:

  1. watch 监听修改的文件,一般需要处理的文件包含 js css 以及 vue 文件。 js 文件与 css 文件处理方式类似,vue 文件比较特殊一些。
  2. 拿 vue 文件为例,首先会通过 @vue/compiler-sfc parse 编译 vue 文件,获取到三部分的内容,从缓存中获取上一次编译的结果。 如果是第一次进入,则不需要进行热更新;如果 script 部分不同,则直接 reload, template 部分不同则 rerender;css 的话,如果是 css Modules 或者 scopes 形式的话,reload,其他的情况 rerender。 当然还有 style-remove 之类的事件。 这些都是客户端发出的事件。
  3. js 文件需要向上寻找引用它的文件,如果找不到则直接 full-reload, 否则的话就是发出更新引用的文件的热更新事件。 根据文件路径引用,判断被哪个 vue 组件所依赖,如果未找到 vue 组件依赖,则判断页面需要刷新,否则走组件更新逻辑

客户端:

主要监听的消息以及对应的措施主要包括:

  • connected: WebSocket 连接成功
  • vue-reload: Vue 组件重新加载(当你修改了 script 里的内容时)
  • vue-rerender: Vue 组件重新渲染(当你修改了 template 里的内容时)
  • style-update: 样式更新
  • style-remove: 样式移除
  • js-update: js 文件更新
  • full-reload: fallback 机制,网页重刷新

这里的更新主要是通过 timestamp 刷新重新请求获取更新后的内容,vue 文件再通过 HMRRuntime 实现更新。 reload 和 rerender 就是用 __VUE_HMR_RUNTIME__ 上的两个对应 api 完成的。

reload 事件接收到之后都会被推入一个 queueUpdate 队列中去执行任务,实际上是带上时间戳、文件名等重新发一个 http 请求,在回调中调用 __VUE_HMR_RUNTIME__ 的 reload 事件来更新内容。

热更新流程

在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活[1](大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新。

Vite 同时利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据  304 Not Modified  进行协商缓存,而依赖模块请求则会通过  Cache-Control: max-age=31536000,immutable  进行强缓存,因此一旦被缓存它们将不需要再次请求。

Vite 整个热更新过程可以分成四步:

  1. 创建一个 websocket 服务端和 client 文件,启动服务
  2. 通过 chokidar 监听文件变更
  3. 当代码变更后,服务端进行判断并推送到客户端
  4. 客户端根据推送的信息执行不同操作的更新

启动热更新:createWebSocketServer: 在 Vite dev server 启动之前,Vite 会为 HMR 做一些准备工作:比如创建 websocket 服务,利用 chokidar 创建一个监听对象 watcher 用于对文件修改进行监听等等。createWebSocketServer 这个方法主要是创建 WebSocket 服务并对错误进行一些处理,最后返回封装好的 on、off、 send 和 close 方法,用于后续服务端推送消息和关闭服务。

执行热更新:moduleGraph+handleHMRUpdate 模块。接收到文件改动执行的回调,这里主要两个操作:moduleGraph.onFileChange 修改文件的缓存和 handleHMRUpdate 执行热更新。moduleGraph 是 Vite 定义的用来记录整个应用的模块依赖图的类,除此之外还有 moduleNode。moduleGraph 是由一系列 map 组成,而这些 map 分别是 url、id、file 等与 ModuleNode 的映射,而 ModuleNode 是 Vite 中定义的最小模块单位。

handleHMRUpdate: 主要是监听文件的更改,进行处理和判断通过 WebSocket 给客户端发送消息通知客户端去请求新的模块代码。

vite 是如何处理 es 模块的循环引用问题?

预编译原理

Vite 预编译之后,将文件缓存在 node_modules/.vite/文件夹下。根据以下地方来决定是否需要重新执行预构建。

  • package.json 中:dependencies 发生变化
  • 包管理器的 lockfile

如果想强制让 Vite 重新预构建依赖,可以使用--force 启动开发服务器,或者直接删掉 node_modules/.vite/文件夹。

依赖预构建

当你首次启动 vite 时,Vite 在本地加载你的站点之前预构建了项目依赖。默认情况下,它是自动且透明地完成的。会在终端输出一些内容,表明一些包已经进行了 Pre-bundling dependencies.

步骤:

  1. 默认情况下,Vite 会将 package.json 中生产依赖 dependencies 的部分启用依赖预构建,即会先对该依赖进行构建,然后将构建后的文件缓存在内存中(node_modules/.vite 文件下),在启动 DevServer 时直接请求该缓存内容。
  2. vite.config.js 文件中配置 optimizeDeps 选项可以选择需要或不需要进行预构建的依赖的名称,Vite 则会根据该选项来确定是否对该依赖进行预构建。
  3. 在启动时添加 --force options,可以用来强制重新进行依赖预构建。

依赖预构建的作用

  1. CommonJS 和 UMD 兼容性: 在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
  2. 性能,减少模块间依赖引用导致过多的请求次数: 为了提高后续页面的加载性能,Vite 将那些具有许多内部模块的 ESM 依赖项转换为单个模块。有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。

依赖预构建的实现

1. runOptimize

  1. 进行依赖预构建。optimizeDeps 函数是 Vite 实现依赖预构建的核心函数,它会根据配置 vite.config.js 的 optimizeDeps 选项和 package.json 的 dependencies 的参数进行第一次预构建。它会返回解析 node_modules/.vite/_metadata.json 文件后生成的对象(包含预构建后的依赖所在的文件位置、原文件所处的文件位置等)。

_metadata.json 文件:

{
  "hash": "bade5e5e",
  "browserHash": "830194d7",
  "optimized": {
    "vue": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/vue.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "lodash-es": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/lodash-es.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    }
  }
}

这里,我们来分别认识一下这 4 个属性的含义:

  • hash 由需要进行预构建的文件内容生成的,用于防止 DevServer 启动时重复构建相同的依赖,即依赖并没有发生变化,不需要重新构建。
  • browserHashhash 和在运行时发现的额外的依赖生成的,用于让预构建的依赖的浏览器请求无效。
  • optimized 包含每个进行过预构建的依赖,其对应的属性会描述依赖源文件路径 src 和构建后所在路径 file
  • needsInterop 主要用于在 Vite 进行依赖性导入分析,这是由 importAnalysisPlugin 插件中的 transformCjsImport 函数负责的,它会对需要预构建且为 CommonJS 的依赖导入代码进行重写。举个例子,当我们在 Vite 项目中使用 react 时:
import React, { useState, createContext } from "react";

此时 react 它是属于 needsInteroptrue 的范畴,所以 importAnalysisPlugin 插件的会对导入 react 的代码进行重写:

import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];

之所以要进行重写的缘由是因为 CommonJS 的模块并不支持命名方式的导出。所以,如果不经过插件的转化,则会看到这样的异常:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'

注册依赖预构建相关函数

调用 createMissingImpoterRegisterFn 函数,它会返回一个函数,其仍然内部会调用 optimizeDeps 函数进行预构建,只是不同于第一次预构建过程,此时会传人一个 newDeps,即新的需要进行预构建的依赖。

那么,显然无论是第一次预构建,还是后续的预构建,它们两者的实现都是调用的 optimizeDeps 函数。所以,下面我们来看一下 optimizeDeps 函数~

预构建实现核心 optimizeDeps 函数

optimizeDeps 函数被定义在 packages/vite/node/optimizer/index.ts 中,它负责对依赖进行预构建过程:


// packages/vite/node/optimizer/index.ts
export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string>
): Promise<DepOptimizationMetadata | null> {
...
}

由于 optimizeDeps 内部逻辑较为繁多,这里我们拆分为 5 个步骤讲解:

1. 读取该依赖此时的文件信息

既然是构建依赖,很显然的是每次构建都需要知道此时文件内容对应的 Hash 值,以便于依赖发生变化时可以重新进行依赖构建,从而应用最新的依赖内容。

所以,这里会先调用 getDepHash 函数获取依赖的 Hash 值:

// 获取该文件此时的 hash
const mainHash = getDepHash(root, config);
const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {},
};

而对于 data 中的这三个属性,我们在上面已经介绍过了,这里就不重复论述了~

2. 对比缓存文件的 Hash

前面,我们也提及了如果启动 Vite 时使用了 --force Option,则会强制重新进行依赖预构建。所以,当不是 --force 场景时,则会进行比较新旧依赖的 Hash 值的过程:

// 默认为 false
if (!force) {
  let prevData;
  try {
    // 获取到此时缓存(本地磁盘)中构建的文件信息
    prevData = JSON.parse(fs.readFileSync(dataPath, "utf-8"));
  } catch (e) {}
  // 对比此时的
  if (prevData && prevData.hash === data.hash) {
    log("Hash is consistent. Skipping. Use --force to override.");
    return prevData;
  }
}

可以看到如果新旧依赖的 Hash 值相等的时候,则会直接返回旧的依赖内容。

3. 缓存失效或未缓存

如果上面的 Hash 不等,则表示缓存失效,所以会删除 cacheDir 文件夹,又或者此时未进行缓存,即第一次依赖预构建逻辑( cacheDir 文件夹不存在),则创建 cacheDir 文件夹:

if (fs.existsSync(cacheDir)) {
  emptyDir(cacheDir);
} else {
  fs.mkdirSync(cacheDir, { recursive: true });
}

需要注意的是,这里的 cacheDir 则指的是 node_modules/.vite 文件夹

前面在讲 DevServer 启动时,我们提及预构建过程会分为两种:第一次预构建和后续的预构建。两者的区别在于后者会传入一个 newDeps,它表示新的需要进行预构建的依赖:

let deps: Record<string, string>, missing: Record<string, string>;
if (!newDeps) {
  ({ deps, missing } = await scanImports(config));
} else {
  // 存在 newDeps 的时候,直接将 newDeps 赋值给 deps
  deps = newDeps;
  missing = {};
}

并且,这里可以看到对于前者,第一次预构建,则会调用 scanImports 函数来找出和预构建相关的依赖 depsdeps 会是一个对象:


{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

missing 则表示在 node_modules 中没找到的依赖。所以,当 missing 存在时,你会看到这样的提示:

scanImports 函数内部则是调用的一个名为 dep-scan 的内部插件(Plugin)。这里就不讲解 dep-scan 插件的具体实现了,有兴趣的同学可以自行了解哈~

那么,回到上面对于后者(newDeps 存在时)的逻辑则较为简单,会直接给 deps 赋值为 newDeps,并且不需要处理 missing。因为,newDeps 只有在后续导入并安装了新的 dependencies 依赖,才会传入的,此时是不存在 missing 的依赖的( Vite 内置的 importAnalysisPlugin 插件会提前过滤掉这些)。

4. 处理 optimizeDeps.include 相关依赖

在前面,我们也提及了需要进行构建的依赖也会由 vite.config.js 的 optimizeDeps 选项决定。所以,在处理完 dependencies 之后,接着需要处理 optimizeDeps

此时,会遍历前面从 dependencies 获取到的 deps,判断 optimizeDeps.iclude(数组)所指定的依赖是否存在,不存在则会抛出异常:

const include = config.optimizeDeps?.include;
if (include) {
  const resolve = config.createResolver({ asSrc: false });
  for (const id of include) {
    if (!deps[id]) {
      const entry = await resolve(id);
      if (entry) {
        deps[id] = entry;
      } else {
        throw new Error(
          `Failed to resolve force included dependency: ${chalk.cyan(id)}`
        );
      }
    }
  }
}

5. 使用 esbuild 构建依赖

那么,在做好上述和预构建依赖相关的处理(文件 hash 生成、预构建依赖确定等)后。则进入依赖预构建的最后一步,使用 esbuild 来对相应的依赖进行构建:


  ...
  const esbuildService = await ensureService()
  await esbuildService.build({
    entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    ...
  })
  ...

ensureService 函数是 Vite 内部封装的 util,它的本质是创建一个 esbuildservice,使用 service.build 函数来完成构建过程。

此时,传入的 flatIdDeps 参数是一个对象,它是由上面提及的 deps 收集好的依赖创建的,它的作用是为 esbuild 进行构建的时候提供多路口(entry),flatIdDeps 对象:


{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  moment:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/moment/dist/moment.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

好了,到此我们已经分析完了整个依赖预构建的实现 😲(手动给看到这的大家 👍)。

那么,接下来在 DevServer 启动后,当模块需要请求经过预构建的依赖的时候,Vite 内部的 resolvePlugin 插件会解析该依赖是否存在 seen 中(seen 中会存储构建过的依赖映射),是则直接应用 node_modules/.vite 目录下对应的构建后的依赖,避免直接去请求构建前的依赖的情况出现,从而缩短冷启动的时间。

2. devServer

浏览器缓存

已预构建的依赖请求使用 HTTP 头 max-age=31536000, immutable 进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。

是否支持 commonjs?

纯业务代码,一般建议采用 ESM 写法。如果引入的三方组件或者三方库采用了 CJS 写法,vite 在预构建的时候就会将 CJS 模块转化为 ESM 模块。

在代码中默认不可以使用 CommonJS, 如果非要在业务代码中采用 CJS 模块,那么我们可以提供一个 vite 插件,定义 load hook,在 hook 内部识别是 CJS 模块还是 ESM 模块。如果是 CJS 模块,利用 esbuild 的 transform 功能,将 CJS 模块转化为 ESM 模块。

基于 Esbuild 的依赖预编译优化

Vite 预编译之后,将文件缓存在 node_modules/.vite文件夹下

为什么需要预编译

  1. 支持 非 ESM 格式的依赖包:Vite 是基于浏览器原生支持 ESM 的能力实现的,因此必须将 commonJs 的文件提前处理,转化成 ESM 模块并缓存入 node_modules/.vite
  2. 减少模块和请求数量:Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。
    1. 如果不使用 esbuild 进行预构建,浏览器每检测到一个 import 语句就会向服务器发送一个请求,如果一个三方包被分割成很多的文件,这样就会发送很多请求,会触发浏览器并发请求限制;

缺点

  1. 首屏和懒加载的性能下降:预构建?
  2. 生态不如 webpack,webpack 的 loader 和 plugin 非常的丰富。
  3. 生产环境使用 rollup 打包可能会造成开发环境与生产环境的不一致。
  4. 兼容性问题:目前 Vite 还是使用的 es module 模块不能直接使用生产环境(如果你的项目不需要兼容 IE11 等低版本的浏览器,自然是可以使用的)

Vite 插件

应用

  1. vite 线程不做类型检查,只做 ts 语法转译,相当于开启第二个线程做类型检查。 vite-plugin-checker 可以配置检查 ts
  2. cdn 加速 vite-plugin-cdn-import
  3. 压缩 vite-plugin-compression

自定义插件 TODO

const path = require("path");
const fs = require("fs");
export default function myPlugin() {
  return {
    name: "rename-plugin",
    transform(code, id) {
      let fullPath = path.join(__dirname, "src/");
      if (
        /<([A-Z>]|div|span)/g.test(code) &&
        id.startsWith(fullPath) &&
        id.endsWith(".js")
      ) {
        fs.rename(id, id + "x", function (err) {
          if (err === null) {
            console.log("重命名成功 %s", id + "x");
          }
        });

        return {
          code,
        };
      }
    },
  };
}

为什么要背靠 Rollup

我的理解是 Vite 实际上算不上一个打包工具,它支持修改了部分我们实际的代码,来让原生代码直接运行在浏览器上。构建环境使用 Rollup 的原因是最大程度上服用它的插件体系和架构,几乎可以直接复用他的生态。

Last Updated:
Contributors: yiliang114