在浏览器原生支持ES Module之前,开发者没有以模块化的方式开发JavaScript的原生机制。而因此诞生了“打包”这个概念,打包指的是使用工具("WebPack")抓取、处理和链接我们源码模块到文件中,使其可以运行在浏览器中。

但是如果我们开始构建(“打包”) 越来越大型的应用时,需要处理的JavaScript代码量成指数级增长。大型项目打包数千个模块的情况并不少见。我们开始遇到性能瓶颈,也就是使用JavaScript开发的构建工具通常需要很长的时间(几分钟)才能启动开发服务器,即使使用HMR,文件修改过后的效果也需要几秒钟才能在浏览器中反应过来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。

所以Vite出现了,Vite的出现就是为了解决上诉所描述出的问题。

  1. 什么是Vite?
  2. 什么是构建工具?
  3. Vite是如何实现的快速冷启动?
  4. Vite的实现原理是什么?
  5. 基于Vite的思路来实现一个简版Vite

Vite是一种新型的前端构建工具,能够显著提升前端开发体验。

它基于浏览器原生的ES Module,来请求import资源,在服务端按需编译返回,完全跳过了”打包“这个概念,服务器随起随用。

有以下特点:

  • 闪电般快速的冷服务器启动
  • 即时热模块更换(HMR)
  • 真正的按需编译

随着前端技术发展之快,各种可以提高效率的工具和框架层出不穷。但是他们都有一个特点:源代码无法在浏览器中直接运行,必须通过转换之后才能够正常运行。

构建工具就是做这件事的,他将源代码转换成可以执行的JavaScript,CSS, HTML代码。包括如下内容:

  • 代码转换:将 TypeScript 编译成JavaScript、将 SCSS 编译成 CSS等。
  • 文件优化:压缩JavaScript、CSS、HTML 代码,压缩合并图片等。
  • 代码分割:提取多个页面的公共代码,提取首屏不需要执行部分代码让其异步记在。
  • 模块合并:在采用模块化的项目里会有很多个模块和文件,需要通过构建功能将模块分类合并成一个文件。
  • 自动刷新:监听本地源代码变化,自动重新构建、刷新浏览器。

目前由于前端工程师很熟悉JavaScript,Node.Js又可以胜任所有构建需求,所以大部分构建工具都是由Node.Js开发的。

下面给大家说一下目前我知道的前端构建工具。

  • Gulp: 是一个基于流的自动化构建工具。除了可以管理任务和执行任务,还支持监听文件、读写文件。
  • WebPack: 是一个打包模块化的JavaScript的工具,在Webpack里一切文件皆模块,通过 loader 转换文件,通过Plugin 注入钩子,最后输出由多个模块组合成的文件。Webpack 专注于构建模块化项目。
  • ESBuild: 它是一个「JavaScript」Bundler 打包和压缩工具,它可以将「JavaScript」和「TypeScript」代码打包分发在网页上运行。esbuild 比其他 JavaScript 打包程序 快至少 100 倍,它是使用GO语言构建的。

这时候我们就需要介绍一下现有项目使用的 WebPack构建过程。

基于打包器的方式:这个方式也就是目前WebPack的webpack-dev-server的启动方式,他必须要先抓取项目代码并全量进行打包,不管用没用得到的文件,然后再进行构建你的整个应用,才能够启动开发服务器。

再看看我们Vite的构建过程是怎么样的?

基于无打包器的方式: 首先会通过esbuild来构建依赖,esbuild使用go语言,它比普通的打包器预构建依赖快10-100倍。对于页面源码,以原生ESM的方式来服务。实际上是让浏览器接管了打包程序的部分工作,Vite只需要在浏览器请求源码的时候进行源码的转换与提供。

但是Vite的表面上看起来确实是比基于打包器方式构建项目的工具要快,但实际上它把对时间的消耗转移到了我们浏览器的request上,如果你的资源请求无限的慢,实际上可能并没有基于打包器方式的构建工具看起来快。

总体来说,还是有较为可观的打包耗时节省。

首先,Vite实现的核心是:拦截浏览器对模块的请求并返回处理后的结果。

我们知道,由于是在locakhost:3000打开的网页,所以浏览器发起的第一个请求自然是请求localhost:3000。这个请求发送之后,会被我们的Vite后端拦截,经过Vite的处理,会进而请求到/index.html,此时Vite就开始对这个请求做拦截和处理了。

首先我们有个小demo,使用vite 2.0创建。

这个demo里的index.html是这样的。

|

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

src/main.js里是这样的:

|

import { createApp } from 'vue'
import App from './App.vue'
import {get} from 'lodash'
createApp(App).mount('#app')

console.log(get(app, 'name'))

在浏览器中运行是这样的:

我们打开控制台,来看我们的main.js内容是什么:

注意到有什么不同点吗?可以发现,我们的import { createApp } from 'vue' 换成了 import {createApp} from '/node_modules/.vite/vue.js?v=42bb2bb2'

这里就不得不说浏览器对import的模块发起请求时的一些局限了,平时我们写代码,如果不是引用相对路径的模块,而是引用node_modules的模块,都是直接 import xxx from 'xxx' ,由Webpack等工具来帮我们找到这个模块的具体路径。但是浏览器不知道你项目里有node_modules,它只能通过相对路径去寻找模块。

因此在Vite的拦截请求里,对直接引用node_modules的模块都做了路径的替换,换成了/node_modules/.vite/并返回回去。而后浏览器收到后,会发起对/node_modules/.vite/xxx的请求,然后被Vite再次拦截,并由Vite内部去访问真正的模块,并将得到的内容再次做相同的处理后,返回给浏览器。

imports 替换

普通js import 替换

|

<script type="module">
 import Vue from 'vue';
</script>

|

<script type="module" src="/index.html?html-proxy&index=1.js"></script>
  1. script 标签 src 替换

|

<script type="module" src="/src/main.js"></script>
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import {get} from 'lodash'
createApp(App).mount('#app')
console.log(get(app, 'name'))

|

<script type="module" src="/src/main.js"></script>
// main.js
import { createApp } from '/node_modules/.vite/vue.js?v=42bb2bb2'
import App from '/src/App.vue?t=1623985162462'
import __vite__cjsImport2_lodash from "/node_modules/.vite/lodash.js?v=42bb2bb2"; const get = __vite__cjsImport2_lodash["get"]
createApp(App).mount('#app')
console.log(get(app, 'name'))

.vue 文件的替换

如果 import 的是 .vue 文件,将会做更进一步的替换:

原本的 App.vue 文件长这样:

|

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <!-- <HelloWorld msg="Hello Vue 3 + Vite" /> -->
  {{title}}
</template>


<script setup>
// import HelloWorld from './components/HelloWorld.vue'
import {ref} from 'vue'
let title = ref('Hello Vue 3')
// This starter template is using Vue 3 experimental <script setup> SFCs
// Check out https://github.com/vuejs/rfcs/blob/script-setup-2/active-rfcs/0000-script-setup.md
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

替换之后长这样:

|

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.vue");
// 抽出script逻辑
const _sfc_main = {
    expose: [],
    setup(__props) {

        // import HelloWorld from './components/HelloWorld.vue'
        // import {ref} from 'vue'
        let title = ref('Hello Vue 3')
        // This starter template is using Vue 3 experimental <script setup> SFCs
        // Check out https://github.com/vuejs/rfcs/blob/script-setup-2/active-rfcs/0000-script-setup.md

        return {
            title
        }
    }

}
import {createVNode as _createVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock} from "/node_modules/.vite/vue.js?v=42bb2bb2"

const _hoisted_1 = /*#__PURE__*/
_createVNode("img", {
    alt: "Vue logo",
    src: "/src/assets/logo.png"
}, null, -1 /* HOISTED */
)
// 创建dom
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(),
    _createBlock(_Fragment, null, [_hoisted_1, _createCommentVNode(" <HelloWorld msg=\"Hello Vue 3 + Vite\" /> "), _createTextVNode(_toDisplayString($setup.title), 1 /* TEXT */
    )], 64 /* STABLE_FRAGMENT */
    ))
}
// 将 style 拆分成 /App.vue?type=style 请求,由浏览器继续发起请求获取样式
import "/src/App.vue?vue&type=style&index=0&lang.css"

_sfc_main.render = _sfc_render // render 方法挂载,用于 createApp 时渲染
_sfc_main.__file = "C:/Users/xasdasda/Desktop/vite-project/src/App.vue" // 记录文件的原始的路径,后续热更新能用到
export default _sfc_main
_sfc_main.__hmrId = "7ba5bd90" // 记录 HMR 的 id,用于热更新
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main)
import.meta.hot.accept(({default: updated, _rerender_only})=>{
    if (_rerender_only) {
        __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)
    } else {
        __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)
    }
}
)
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIkM6L1VzZXJzL3NpbXBsZXhpL0Rlc2t0b3Avdml0ZS1wcm9qZWN0L3NyYy9BcHAudnVlIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQU1jO0FBQ2QsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQztBQUN2RCxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7QUFDMUIsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7QUFDOUIsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7QUFDeEUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDOzs7Ozs7O2dDQVY3RixhQUE4QztFQUF6QyxHQUFHLEVBQUMsVUFBVTtFQUFDLEdBQUcsRUFBQyxzQkFBbUI7Ozs7O0lBQTNDLFVBQThDO0lBQzlDLGtFQUFnRDtzQ0FDOUMsWUFBSyIsInNvdXJjZXNDb250ZW50IjpbIjx0ZW1wbGF0ZT5cbiAgPGltZyBhbHQ9XCJWdWUgbG9nb1wiIHNyYz1cIi4vYXNzZXRzL2xvZ28ucG5nXCIgLz5cbiAgPCEtLSA8SGVsbG9Xb3JsZCBtc2c9XCJIZWxsbyBWdWUgMyArIFZpdGVcIiAvPiAtLT5cbiAge3t0aXRsZX19XG48L3RlbXBsYXRlPlxuXG48c2NyaXB0IHNldHVwPlxuLy8gaW1wb3J0IEhlbGxvV29ybGQgZnJvbSAnLi9jb21wb25lbnRzL0hlbGxvV29ybGQudnVlJ1xuLy8gaW1wb3J0IHtyZWZ9IGZyb20gJ3Z1ZSdcbmxldCB0aXRsZSA9IHJlZignSGVsbG8gVnVlIDMnKVxuLy8gVGhpcyBzdGFydGVyIHRlbXBsYXRlIGlzIHVzaW5nIFZ1ZSAzIGV4cGVyaW1lbnRhbCA8c2NyaXB0IHNldHVwPiBTRkNzXG4vLyBDaGVjayBvdXQgaHR0cHM6Ly9naXRodWIuY29tL3Z1ZWpzL3JmY3MvYmxvYi9zY3JpcHQtc2V0dXAtMi9hY3RpdmUtcmZjcy8wMDAwLXNjcmlwdC1zZXR1cC5tZFxuPC9zY3JpcHQ+XG5cbjxzdHlsZT5cbiNhcHAge1xuICBmb250LWZhbWlseTogQXZlbmlyLCBIZWx2ZXRpY2EsIEFyaWFsLCBzYW5zLXNlcmlmO1xuICAtd2Via2l0LWZvbnQtc21vb3RoaW5nOiBhbnRpYWxpYXNlZDtcbiAgLW1vei1vc3gtZm9udC1zbW9vdGhpbmc6IGdyYXlzY2FsZTtcbiAgdGV4dC1hbGlnbjogY2VudGVyO1xuICBjb2xvcjogIzJjM2U1MDtcbiAgbWFyZ2luLXRvcDogNjBweDtcbn1cbjwvc3R5bGU+XG4iXX0=

这样 浏览器解析到我们资源的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。

具体的实现呢,我们需要借助Koa来实现。(Vite1.0同样是借助了Koa,但Vite2.0之后使用了Node原生的http模块)

|

// 写一个node服务器,相当于devServer
const Koa = require("koa");
const app = new Koa();
const fs = require("fs");
const path = require("path");
const compilerSfc = require("@vue/compiler-sfc");
const compilerDom = require("@vue/compiler-dom");


// 返回用户首页
app.use(async (ctx) => {
  const { url, query } = ctx.request;
  if (url === "/") {
    // 首页
    ctx.type = "text/html";
    const p = path.join(__dirname, "./index.html");
    //mock process
    const content = fs.readFileSync(p, "utf8").replace(
      '<script type="module" src="/src/main.js"></script>',
      `<script>
        window.process = { env: { NODE_ENV: 'dev' } }
      </script>
      <script type="module" src="/src/main.js"></script>
      `
    );

    ctx.body = content;
  } else if (url.endsWith(".js")) {
    // 响应js请求
    const p = path.join(__dirname, url);
    console.log(p);
    ctx.type = "text/javascript";
    const file = rewriteImport(fs.readFileSync(p, "utf8"));
    ctx.body = file;
  } else if (url.startsWith("/@modules/")) {
    console.log('url',url)
    // 获取@modules后面部分,模块名称
    const moduleName = url.replace("/@modules/", "");
    const prefix = path.join(__dirname, "/node_modules", moduleName);
    // 要加载文件的地址
    console.log('prefix',prefix)
    const module = require(prefix+"\\"+ "package.json").module;
    const filePath = path.join(prefix, module);
    console.log(filePath)
    const ret = fs.readFileSync(filePath, "utf8");
    ctx.type = "text/javascript";
    ctx.body = rewriteImport(ret);
  } else if (url.indexOf(".vue") > -1) {
    // 读取vue文件内容
    const p = path.join(__dirname, url.split("?")[0]);
    // compilerSfc解析SFC, 获得一个ast
    const ret = compilerSfc.parse(fs.readFileSync(p, "utf8"));
    console.log('query',query.type)
    // 没有query.type,说明是SFC
    if (!query.type) {
      // 处理内部script
      console.log('ret',ret);
      // 获取脚本内容
      const scriptContent = ret.descriptor.script.content;
      const styleContent = ret.descriptor.styles;
      console.log('styleContent', styleContent)
      // 需要生成style
      // 转换默认导出配置对象为变量
      const script = scriptContent.replace(
        "export default ",
        "const __script = "
      );
      ctx.type = "text/javascript";
      ctx.body = `
      ${rewriteImport(script)}
      // template解析转换为另一个请求单独做
      import {render as __render} from '${url}?type=template'
      __script.render = __render
      export default __script
    `;
    } else if (query.type === "template") {
      const tpl = ret.descriptor.template.content;
      // 编译为包含render模块
      const render = compilerDom.compile(tpl, { mode: "module" }).code;
      console.log('render', render)
      ctx.type = "text/javascript";
      ctx.body = rewriteImport(render);
    }
  } else if (url.endsWith(".png")) {
    ctx.body = fs.readFileSync("src" + url);
  }
});

// 重写导入,变成相对地址
function rewriteImport(content) {
  return content.replace(/ from ['"](.*)['"]/g, function (s0, s1) {
    // s0匹配字符串,s1分组内容
    // 看看是不是相对地址
    if (s1.startsWith("./") || s1.startsWith("/") || s1.startsWith("../")) {
      // 原封不动的返回
      return s0;
    } else {
      // 裸模块
      return ` from '/@modules/${s1}'`;
    }
  });
}

app.listen(3001, () => {
  console.log("kvite start!");
});

结合上面的分析和简版源码,可以用一句话来简述Vite的原理:Static Server + Compile + HMR:

  • 将当前项目目录作为静态文件服务器的根目录
  • 拦截部分文件请求

    • 处理代码中import node_modules中的模块
    • 处理vue单文件组件(SFC)的编译
  • 通过WebSocket实现HMR

当然关于类似手写Vite实现的文章社区已经有很多了,这里就不赘述了,大致原理都是一样的。

Vue Conf关于Vite的分享给我带来的启发

Vite 原理浅析

Vite 官方中文文档

JavaScript modules 模块

最后修改:2023 年 05 月 08 日
如果觉得我的文章对你有用,请随意赞赏