如何看待 Web 开发构建工具 Vite?

1,623 阅读6分钟

vite 启动链路

命令解析

这部分代码在 src/node/cli.ts 里,借助 cac 用于构建命令行 cli 的 script 脚本, vite 主要构建了四条 script 的命令 dev、build、preview、optimize。

server

server 模块是用在开发模式下,这部分代码在 src/node/server/index.ts 里,主要暴露一个 createServer 方法。 vite 会启动一个服务器,当浏览器读取到 html 这个文件之后,会在执行到 import 的时候向服务端发送 main.vue 模块中的请求。在 server 中创建了一个文件改动的 watcher,如何实现热更新,下文重点分析的时候会再说。

optimize

optimize 的含义就是依赖预编译,在为用户启动开发服务器之前,vite 先用 esbuild 把检测到的依赖先构建一遍。

vite 一直主张 no-bundle,但为啥在启动之前还是将依赖进行一次 build 了呢?

比如在项目中用到了 import { debounce } from 'lodash' 这样的函数,如果 debounce 函数的模块内部又依赖了很多其他的函数,这样就形成了一个依赖图,如果在项目中不提前把依赖进行一次 build 的话,在实际开发中可能会发起多个请求。

const listen = httpServer.listen.bind(httpServer);
httpServer.listen = (async (port: number, ...args: any[]) => {
  try {
    await container.buildStart({});
    // 这里会进行依赖的预构建
    await runOptimize();
  } catch (e) {
    httpServer.emit("error", e);
    return;
  }
  return listen(port, ...args);
}) as any;

runOptimize 中导入了 src/node/optimizer/index.ts 中的optimizeDepsDepOptimizationMetadata 方法。具体怎么用 esbuild 把依赖都提前打包成单文件的 bundle 这里就不在继续描述了。 在依赖预编译之后,浏览器请求到相关模块的时候,返回这个预构建好的模块,这样当浏览器请求 lodash-es 中的 debounce 模块的时候,就可以保证只发生一次接口请求。

build

这部分在 src/node/build.ts 中,vite 中使用了 rollup 打包,并提供了插件机制,兼容 Rollup 格式。所谓的插件就是在对外提供的一些时机钩子,还有一些工具方法,可以让给你用户去写一些配置代码,以此介入 rollup 运行的各个时机之中。

vite 运行原理

模块化

什么是前端模块化,通俗简单来讲就是组件,一个模块就是实现特定功能的文件(js),遵循模块化的机制,需要用到什么就进行加载相应模块。

在 js 中模块化规范用四种:AMD、CMD、Commonjs、EsModule。目前用的最多的 Commonjs、EsModule。

CommonJs

function a() {}
module.exports = { a };
/*引入*/
const a = require("a");

EsModule

export function a() {}
const { a } from '..xxx'

commonJs 是在被家在的时候运行,输出的值是前拷贝,但是具有缓存,在第一次加载时,会完整运行整个文件并输出一个对象,下次加载文件时,会直接冲内存中取值。而 esModule 是在编译的时候加载,输出的只是模块值的引用。

目前大部分浏览器支持 exportimport 的方式导入和导出模块,在 script 标签里设置 type="module",然后使用模块内容。这样就省去了一个打包步骤,想用什么资源直接在浏览器里引入即可。利用浏览器去解析 imports, 在服务器端按需编译返回。

去掉打包步骤

打包的概念是开发者利用工具将应用中各个模块集合在一起形成一个 bundle ,以一定规则读取模块的代码--以便在不支持模块化的浏览器里使用。虽然在代码中有路由懒加载等手段,但这些并不代表懒构建。还是需要把所有的用到的模块都提前构建好,借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用__webpack_require__ 方法获取模块导出。当项目越来越大的时候,需要构建的文件也会随之变大。启动和 HMR 也会变得越来越慢。而 vite 利用原生支持模块化导入这一特性,省略了对模块的组装,就不需要 bundle。可以用两张图去生动的描述这两种方式。 打包

esmodule

vite 模块解析

浏览器使用 es module 是通过 http 请求拿到模块,所以 vite 提供了一个 web server 去代理这些模块。通过对请求路径的劫持获取资源内容返回给浏览器。

通过工程下的 index.html 和开发环境下的 html 源文件对比,发现 script 标签里的内容发生了改变,由

import { createApp } from "vue";
import App from "/App.vue";
createApp(App).mount("#app");

变成了

import { createApp } from "/node_modules/.vite/vue.js?v=df64d8b4";
import App from "/App.vue";

createApp(App).mount("#app");
  • http 请求获取到的 body 内容,
  • 通过 es-module-lexer 进行词法分析解析资源 ast 拿到 import 的内容。
  • 如果模块不包含 exports 关键字,则认为是 commonjs 模块,如果包含 exports 关键字,我们便认为是 esmodule 进一步分析 import 导入关系,如果导入的模块是以.开头的,则认为是一个真正的依赖,如果是绝对导入则认为是一个 npm 模块。

如果我们在模块里写下一下代码的时候,浏览器的 esm 是不可能活到到导入的模块

import vue from "vue";

因为 vue 这个模块安装在 node_modules 里,以往使用 webpack,webpack 遇到上面的代码,会帮我们做以下几件事:获取这段代码的内容解析成 AST 遍历 AST 拿到 import 语句中的包的名称使用 enhanced-resolve 拿到包的实际地址进行打包,但是浏览器中 ESM 无法直接访问项目下的 node_modules,所以 vite 对所有 import 都做了处理,

热替换

热替换(Hot Module Replacement) 指的是修改代码后无需刷新页面即可生效。vite 通过 websocket 建立服务端与浏览器的通信。热更新一般需要四个部分:

  1. 首先需要 web 框架支持模块的 rerender/reload
  2. 通过 watcher 监听文件改动
  3. 通过 server 端编译资源,并推送新模块内容给 client
  4. client 收到新的模块内容,执行 rerender/reload

在 server 端,通过 watcher 监听页面改动,根据文件类型判断是 js Reload 还是 Vue Reload,css reload。解析器拿到当前文件的 template 等 ,并且与缓存里的上一次解析的结果进行比较。

vue 文件修改时触发 handleVueReload 方法

//src/node/server/serverPluginVue.ts
watcher.on("change", (file) => {
  if (file.endsWith(".vue")) {
    handleVueReload(file);
  }
});

首先利用 parseSFC 库对 vue 单文件进行编译,从缓存中读取之前的组件缓存。如果没有则说明该组件还没有被渲染什么都不用做。

  • 当我们同一个组件前后两次渲染时的 script 或者 scriptSetup 不一致时,需要使用 vue-reload 重新 load 整个组件。
  • 前后的 template 不一致,则发送 vue-rerender 消息。只需要发起 type 为 template 的请求即可。
  • style 的不同通过 style-update style 标签更新