此名字暂不可见
No distraction,it's you and me

Vite

2025-08-19 Vite

Vite

前言

                                                                     `王的决斗总是快人三步`

​ ——杰克•阿特拉斯

Vite(法语意为 “快速的”,发音 /vit/,发音同 “veet”)是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

Vite 意在提供开箱即用的配置,同时它的 插件 APIJavaScript API 带来了高度的可扩展性,并有完整的类型支持。

Vite的特点如下:

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

同样是打包工具,Webpack无论是开发环境还是生产环境,都是通过构建模块的依赖图然后打包模块,只是采取策略不同而已。而Vite在开发环境下对于非第三方模块是不会打包的,而是直接启动服务器,通过服务器拦截浏览器的HTTP请求,将项目中使用的模块处理之后之后返回给服务器。

启动开发服务器

Vite自己说了,开发服务器是Vite中不可缺少的一环。在启动项目的同时也启动了一个开发服务器,过程大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Koa = require('koa')
const { createServer } = require('vite')

async function startServer() {
const server = await createServer({
configFile: './vite.config.js'
})

const app = new Koa()

app.use(server.middlewares)

app.listen(3000, () => {
console.log('准备就绪')
})
}

注意到,这里有个createServer的函数来自于Vite中,它是Vite服务器启动的关键,具体参数如下:

1
2
3
4
5
6
7
8
9
10
const server = await createServer({
root, // 项目的根目录
base: options.base, // 打包时的公共路径
mode: options.mode, // 当前模式,开发或者生产
configFile: options.config, // Vite的配置项文件路径
logLevel: options.logLevel, // 日志输出级别
clearScreen: options.clearScreen, // 是否在每次启动或热更新时清空控制台屏幕
optimizeDeps: { force: options.force }, // 依赖预构建的配置项
server: cleanOptions(options) // 服务器的相关配置
});

后面就是createServer启动的具体过程

解析配置

这一步有一个resolveConfig函数,和Webpack一样,会将命令行中的配置项和配置文件合并,处理插件的执行顺序和对应的配置钩子,最后输出一个总的config

初始化http服务

这一步会创建相应的http服务来处理来自client的请求。比如浏览器在发现import的模块时会向服务器发送请求,启动的http服务就是处理这个的,对请求的模块进行相应的处理

初始化WebSocket服务

在http服务的基础上Vite还会初始化一个WebSocket服务用于双方通信,初始化会返回一个WebSocket对象,内部已经实现了on,off,send,listen等方法,主要用于服务端向客户端推送HMR。

启动chokidar监听文件

为了实现HMR,Vite需要能够监听文件。这里通过chokidar创建了一个文件监听器,用于在文件修改,新增或删除时触发相应的钩子函数以及重新处理模块图,并向服务器通知。

创建ModuleGraph实例

和Webpack一样,Vite也需要一个模块依赖图来维护项目中各个模块的信息和依赖关系。Vite的依赖图主要由ModuleGraph和ModuleNode来实现。

创建插件容器

Vite也有自己的插件机制,到了这一步Vite会创建一个插件容器,它会注册所有的Vite插件,并在合适的时机调度这些插件的钩子函数,并提供相应的API供插件与Vite交互。

创建ViteDevServer对象

到了这一步基本差不多了,Vite就会创建一个ViteDevServer对象,把前面得到的对象和方法都挂载到ViteDevServer上。

挂载内部中间件

这一步主要会挂载中间件用来处理来自客户端的HTTP请求。中间件主要是用来处理 HTTP 请求和响应,通过定义一系列的中间件并且按照一定的顺序执行,每个中间件函数对请求和响应进行处理,然后将处理后的请求和响应传递给下一个中间件函数,直到最后一个中间件函数处理完毕并发送响应

预构建

什么是预构建

从Webpack换到Vite之后,感觉就是相当快。

但是为什么Vite可以这么快,而Webpack就像🐢一样,原因就是Vite有依赖预构建。

在本地服务器启动之前,Vite会扫描使用到的依赖对其进行构建,在代码中每次import时就会加载构建过的依赖。

也就是说,在开发环境下,相较于Webpack启动之前会打包所有的代码,Vite不会对所有的代码和依赖进行打包,而是将代码直接交给浏览器处理。这就体现了ESM的好处,因为Webpack所支持的CommonJS模块不被浏览器所支持,Webpack不得不将文件处理为浏览器所能识别的格式;而Vite的ESM因为被浏览器原生支持,因此可以不经过处理直接放到浏览器中执行,只需要在script中声明"type = "module"

浏览器每遇到一个import都会向本地服务器发起一个请求,服务器读取相应的文件内容,把结果返回给浏览器。

实际上,Vite将项目中的模块区分为依赖源码两类:

  • 依赖 更多指的是代码中使用到的第三方模块,比如 vuereact 等。Vite 将会使用 esbuild在应用启动时对于依赖部分进行预构建依赖。
  • 源码 指的是我们项目中的 jsxvue 等文件,这部分代码会在运行时被编译,并不会进行任何打包。

Vite 以 ESM 方式提供源码,这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据实际动态导入代码,也就是说会在进行交互,实际使用时才会被处理。

所以这里的依赖预构建,针对的是依赖而不是源码。它的主要目的有两个:

  • 规范依赖的模块规范:有的依赖可能采用非ESM模块规范,这就需要借助预构建将非ESM模块的依赖转化为ESM模块。
  • 减少http的请求次数:有时在使用一些库时,如果不进行预构建浏览器会同时发出大量http请求,这也需要预构建来将依赖打包起来,从而减少请求次数。

预构建的过程

Vite中的预构建是默认开启的,在你第一次启动时,Vite会自动将依赖打包,并写入编译后文件,同时重写依赖引入路径,设置缓存。

除了 HTTP 缓存,Vite 还设置了本地文件系统的缓存,所有的预构建产物默认缓存在node_modules/.vite目录中。如果以下 3 个地方都没有改动,Vite 将一直使用缓存文件:

  1. package.json 的 dependencies 字段
  2. 各种包管理器的 lock 文件
  3. optimizeDeps 配置内容

如果改动了,Vite将重新进行一次预构建。

之后每次启动,Vite会先进行缓存判断,计算一次项目的hash值,如果和_metadate.json中的相同,则跳过依赖预构建的过程。

如果没有命中缓存,那么Vite会扫描项目中的依赖项。在没有指定入口文件的情况下,Vite会获取项目目录下所有的.html文件,并来检测需要预构建的依赖项。通常默认会使用单个 index.html 作为入口文件。


注:Vite对于依赖和源码的判断很简单,就是bare import。bare import 很好理解,就像下面一样:

1
2
3
4
5
6
7
// bare import
import xxx from "react"
import xxx from "react/xxx"

// 非 bare import
import xxx from "./app.js"
import xxx from "/app.ts"

省流:

  • 用名称去访问的模块是裸模块
  • 用路径去访问的模块,不是 bare import

之所以可以这样判断,是因为node.js的寻址机制允许bare import的模块去当前目录的node_modules下寻找,找不到就去上一级的node_modules,直到根目录为止。


到了index.html文件中,Vite就会开始依赖扫描。对于这个过程,可以把文件中的依赖关系看成一颗树,依赖扫描就是一个DFS的过程,所以判断何时结束遍历就成了关键。一般结束遍历的情况如下:

  • 当遇到 bare import 节点时,记录下该依赖,就不需要继续深入遍历
  • 遇到其他 JS 无关的模块,如 CSS、SVG 等,因为不是 JS 代码,因此也不需要继续深入遍历

在所有的节点都遍历完成之后,记录的bare import模块就是扫描的结果,之后就可以将结果交给esbuild打包处理了。

大概的遍历过程如下:

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
36
37
38
39
40
41
42
43
import { build } from 'esbuild'
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {

// 将项目中所有的 html 文件作为入口
let entries: string[] = await globEntries('**/*.html', config)

// 扫描到的依赖,会放到该对象
const deps: Record<string, string> = {}
// 缺少的依赖,用于错误提示
const missing: Record<string, string> = {}

// esbuild 扫描插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)

// 获取用户配置的 esbuild 自定义配置,没有配置就是空的
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}

await Promise.all(
// 入口可能不止一个,分别用 esbuid 遍历
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
// 使用插件
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)

return {
deps,
missing
}
}

省流:

  1. 将项目内的所有html文件作为入口文件
  2. 将每个入口文件交给esbuild的插件扫描依赖

注意到,扫描这一步被交给了esbuildScanPlugin处理,所以具体的逻辑都是在esbuildScanPlugin的内部实现的。


注:esbuild的插件在执行时都会经过解析和加载的过程,解析会将模块的路径由相对路径转换为绝对路径并做一些别的处理,而加载则会根据解析的路径读取文件的内容,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const plugin = {
name: 'xxx',
setup(build) {

build.onResolve({ filter: /.*\.less/ }, args => ({
path: args.path,
namespace: 'less',
}))

build.onLoad({ filter: /.*/, namespace: 'less' }, () => {
const raw = fs.readFileSync(path, 'utf-8')
const contents = ...// less 处理成 css
return {
contents,
loader: 'css'
}
})
}
}

esbuild插件通过onResolve和onLoad来定义解析和加载过程。

onResolve 的第一个参数为过滤条件,第二个参数为回调函数,解析时调用,返回值可以给模块做标记,如 external、namespace,还需要返回模块的路径,

onLoad 的第一个参数为过滤条件,第二个参数为回调函数,加载时调用,可以读取文件的内容,然后进行处理,最后返回加载的内容。


esbuildScanPlugin对不同模块的处理方式也不同,比如JS模块是esbuild本来就支持的,因此不需要插件扩展,直接就分析出JS文件中的依赖并深入遍历。

非JS模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// external urls
build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
path,
external: true
}))

// external css 等文件
build.onResolve(
{
filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/
},
({ path }) => ({
path,
external: true
}
)

对于非JS文件,不用管,无脑external,构建时遇到被external的文件就会停止递归

bare import

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
build.onResolve(
{
// 第一个字符串为字母或 @,且第二个字符串不是 : 。如 vite、@vite/plugin-vue
filter: /^[\w@][^:]/
},
async ({ path: id, importer, pluginData }) => {
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
// 将模块路径转换成真实路径
const resolved = await resolve(id, importer, {
custom: {
depScan: { loader: pluginData?.htmlType?.loader }
}
})

// 如果解析到路径,证明找得到依赖
// 如果解析不到路径,则证明找不到依赖,要记录下来后面报错
if (resolved) {
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 如果模块在 node_modules 中,则记录 bare import
if (resolved.includes('node_modules')) {
// 记录 bare import
depImports[id] = resolved

return {
path,
external: true
}
}
// isScannable 判断该文件是否可以扫描,可扫描的文件有 JS、html、vue 等
else if (isScannable(resolved)) {
// 真实路径不在 node_modules 中,则证明是 monorepo,实际上代码还是在用户的目录中
// 是用户自己写的代码,不应该 external
return {
path: path.resolve(resolved)
}
} else {
// 其他模块不可扫描,直接忽略,external
return {
path,
external: true
}
}
} else {
// 解析不到依赖,则记录缺少的依赖
missing[id] = normalizePath(importer)
}
}
)

  • 如果文件在 node_modules 中,才认为是 bare import,记录当前模块

  • 文件不在 node_modules 中,则是 monorepo,继续判断:

    • 如果这些代码 isScanable 可扫描(即含有 JS 代码),则继续深入处理

    • 其他非 JS 模块,直接external

html模块

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
const htmlTypesRE = /\.(html|vue|svelte|astro)$/

// html types: 提取 script 标签
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
// 将模块路径,转成文件的真实路径
const resolved = await resolve(path, importer)
if (!resolved) return

return {
path: resolved,
// 标记 namespace 为 html
namespace: 'html'
}
})

// 正则,匹配例子: <script type=module></script>
const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
// 正则,匹配例子: <script></script>
export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims

build.onLoad(
{ filter: htmlTypesRE, namespace: 'html' },
async ({ path }) => {
// 读取源码
let raw = fs.readFileSync(path, 'utf-8')
// 去掉注释,避免后面匹配到注释
raw = raw.replace(commentRE, '<!---->')

const isHtml = path.endsWith('.html')
// scriptModuleRE: <script type=module></script>
// scriptRE: <script></script>
// html 模块,需要匹配 module 类型的 script,因为只有 module 类型的 script 才能使用 import
const regex = isHtml ? scriptModuleRE : scriptRE

// 重置正则表达式的索引位置,因为同一个正则表达式对象,每次匹配后,lastIndex 都会改变
// regex 会被重复使用,每次都需要重置为 0,代表从第 0 个字符开始正则匹配
regex.lastIndex = 0
// load 钩子返回值,表示加载后的 js 代码
let js = ''
let scriptId = 0
let match: RegExpExecArray | null

// 匹配源码的 script 标签,用 while 循环,因为 html 可能有多个 script 标签
while ((match = regex.exec(raw))) {
// openTag: 它的值的例子: <script type="module" lang="ecmascript" src="xxx">
// content: script 标签的内容
const [, openTag, content] = match

// 正则匹配出 openTag 中的 type 和 lang 属性
const typeMatch = openTag.match(typeRE)
const type =
typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
const langMatch = openTag.match(langRE)
const lang =
langMatch && (langMatch[1] || langMatch[2] || langMatch[3])

// 跳过 type="application/ld+json" 和其他非 non-JS 类型
if (
type &&
!(
type.includes('javascript') ||
type.includes('ecmascript') ||
type === 'module'
)
) {
continue
}

// esbuild load 钩子可以设置 应的 loader
let loader: Loader = 'js'
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
} else if (path.endsWith('.astro')) {
loader = 'ts'
}

// 正则匹配出 script src 属性
const srcMatch = openTag.match(srcRE)
// 有 src 属性,证明是外部 script
if (srcMatch) {

const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
// 外部 script,改为用 import 用引入外部 script
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
// 内联的 script,它的内容要做成虚拟模块

// 缓存虚拟模块的内容
// 一个 html 可能有多个 script,用 scriptId 区分
const key = `${path}?id=${scriptId++}`
scripts[key] = {
loader,
content,
pluginData: {
htmlType: { loader }
}
}

// 虚拟模块的路径,如 virtual-module:D:/project/index.html?id=0
const virtualModulePath = virtualModulePrefix + key
js += `export * from ${virtualModulePath}\n`
}
}

return {
loader: 'js',
contents: js
}
}
)

加载阶段的主要做有以下流程:

  • 读取文件源码
  • 正则匹配出所有的 script 标签,并对每个 script 标签的内容进行处理
    • 外部 script,改为用 import 引入
    • 内联 script,改为引入虚拟模块,并将对应的虚拟模块的内容缓存到 script 对象。
  • 最后返回转换后的 js

🤓👆🏻注:虚拟模块,即模块不存在于磁盘中而是存在于内存中,也就是没有相应的.js文件。

Vite在遇见内联script,比如这样:

1
2
3
4
<script>
const a = 1 + 1
console.log(a)
</script>

一方面为了做到模块化管理,另一方面为了HMR,Vite会把内联script包装为一个模块,比如

并存放在内存中:

1
2
3
4
5
6
7
8
// 内存中的虚拟模块
/@id/index.html?type=script
---------------------------

const a = 1 + 1;
console.log(a)
export {};

浏览器看到之后就会发起模块请求,服务器便将内存中的虚拟模块发送给浏览器


在对入口文件的所有扫描过后,会将得到的deps交给esbuild,里面有依赖及其绝对路径,esbuild会根据路径找到相应的文件并将其打包,预构建的依赖会存在于项目node_modules/.vite/deps目录之下,而依赖列表则放在_metadate.json中,用来展示预构建阶段生成文件的映射关系。

‍HMR

HMR的主要作用就是为了实现局部刷新的效果,这样之前操作的状态都可以保存。

Vite的HMR的基本实现是基于ESM的HMR规范,在文件发生改变时Vite会检测到相应模块的变化,从而触发相应的API,实现局部的更新。

HMR的API

根据ESM的HMR规范,Vite在客户端使用了一些与HMR有关的API,主要如下:

  • import.meta.hot.accept()
  • import.meta.hot.dispose()
  • import.meta.hot.prune()
  • import.meta.hot.invalidate()

下面可以简单看看它们都有什么作用:


注:import.meta 是 ESM模块中的一个对象,用于提供当前模块的上下文,它带有一个null的原型对象,因此宿主环境(比如浏览器或者node.js)可以为这个对象进行扩展。

比如浏览器提供了import.meta.url来暴露当前ESM模块的绝对URL:

1
2
// main.mjs
console.log(import.meta.url);

而Vite就是通过在模块的import.meta上来挂载hot实现的热更新。


import.meta.hot.accept()

import.meta.hot.accept() 可以传入一个回调函数时,该回调函数将负责用新模块替换旧模块。而使用这个 API 进行监听的模块也被称为“接收模块”。

一个接收模块将会创建一个 “HMR 边界”。HMR 边界包含了模块本身和它的所有递归导入模块。因为 HMR 边界通常是一个图结构,所以接收模块也被称为 HMR 边界的“根”。

注:热更新边界,即沿着依赖树,往上找到最近的一个可以接收热更新的模块,就是热更新边界

根据 HMR 的回调函数签名,“接收模块”还可以被细分为“自接收模块”:

  • import.meta.hot.accept(callback):接受来自自身更新的更改
  • import.meta.hot.accept(deps, callback):接受来自其他导入模块更新的更改

如果是第一种,则该模块被称为“自接收模块”。这种区分对于 HMR 传播非常重要,稍后再讲。

以下是它们的使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let data = [1, 2, 3]

if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
data = newModule.data
})
}

-----------------------------------------------------------------------------------------

import { value } from './value.js'

document.querySelector('#value').textContent = value

if (import.meta.hot) {
import.meta.hot.accept(['./value.js'], ([newModule]) => {
// 用新值来重新渲染
document.querySelector('#value').textContent = newModule.value
})
}

import.meta.hot.dispose()

当一个接收模块被其上游模块替换为新模块、或被移除时,可以使用 import.meta.hot.dispose() 钩子进行清理工作。这可以清理过时模块产生的任何副作用,比如移除事件监听器、清除计时器或重置状态。

1
2
3
4
5
6
7
global.listener = {'外币八部'}

if (import.meta.hot) {
import.meta.hot.dispose(() => {
global.listener = {}
})
}

import.meta.hot.prune()

当一个模块完全从运行时中被移除(例如文件被删除时)可以使用 import.meta.hot.prune() 进行最终的清理工作。这有点类似于 import.meta.hot.dispose(),但区别是它仅在模块被从图中整个移除时调用一次。

1
2
3
4
5
6
7
8
const socket = new WebSocket("ws://server/analytics");

if (import.meta.hot) {
// 模块彻底失效时,断开连接
import.meta.hot.prune(() => {
socket.close();
});
}

import.meta.hot.invalidate()

与上述其他 API 不同,import.meta.hot.invalidate() 是一个命令式的调用而不是一个生命周期钩子。通常会在 import.meta.hot.accept 的钩子内使用它。它通常在当前模块无法安全热更新时将HMR传递给父模块

1
2
3
4
5
6
7
8
9
10
11
12
// a.js
export const count = 1;

if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
if ('count' in newModule) {
count = newModule.count;
} else {
import.meta.hot.invalidate();
}
});
}

HMR的过程

省流:

  1. 修改代码,vite server 监听到代码被修改
  2. vite 计算出热更新的边界
  3. vite server 通过 websocket 告诉 vite client 需要进行热更新
  4. 浏览器拉取修改后的模块
  5. 执行热更新的代码

Vite的dist目录下一共有client和node两个文件夹,分别代表了客户端和服务端。启动Vite时,也就启动了node服务,它与HMR相关的功能一共有两个:

  1. 启动chokidar监听项目文件变化
  2. 启动 WebSocket 服务,向客户端主动推送消息

另一个就是 client,比如项目中有入口 index.html 文件,在实际请求时返回的 html 内容中就会注入@vite/client

1
<script type="module" src="/@vite/client"></script>

其与 HMR 相关的功能主要有两个:

  1. 监听 WebSocket消息,解析后发起文件请求
  2. 注入 import.meta.hot 实现客户端HMR
1
2
import { createHotContext } from '/@vite/client'
import.meta.hot = createHotContext('/src/app.jsx')

服务端

在开始启动热更新的时候,Vite会对模块构建依赖图,其目的是路径定位,依赖追踪和边界判断。构建的依据就是moduleGraph和moduleNode,前者用来记录模块与模块之前的所有依赖,后者主要记录模块节点的具体信息。

1
2
3
4
5
6
7
export class ModuleGraph {
urlToModuleMap = new Map<string, ModuleNode>()
idToModuleMap = new Map<string, ModuleNode>()
fileToModulesMap = new Map<string, Set<ModuleNode>>()
safeModulesPath = new Set<string>()
}

ModuleGraph 主要通过三个 Map 和一个 Set 来记录模块信息,包括

  • urlToModuleMap:原始请求 url 到模块节点的映射,如 /src/index.tsx => ModuleNode(index.js),

  • idToModuleMap:模块 id 到模块节点的映射,id 是原始请求 url 经过 resolveId解析后的结果

  • fileToModulesMap:文件到模块节点的映射,由于单文件可能包含多个模块,如 .jsx 文件,因此 Map 的 value 值为一个集合

  • safeModulesPath:记录被认为是“安全”的模块路径,即可以跳过模块依赖构建的文件路径,比如vite/client

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
36
export class ModuleNode {
// 原始请求 url
url: string
// 文件绝对路径 + query
id: string | null = null
// 文件绝对路径
file: string | null = null
type: 'js' | 'css'
info?: ModuleInfo
// resolveId 钩子返回结构的元数据
meta?: Record<string, any>
// 重要:当前模块被哪些模块引用
importers = new Set<ModuleNode>()
// 重要:当前模块依赖的其他模块
clientImportedModules = new Set<ModuleNode>()
// 接收热更新的模块
acceptedHmrDeps = new Set<ModuleNode>()
acceptedHmrExports: Set<string> | null = null
importedBindings: Map<string, Set<string>> | null = null
// 是否为 接受自身模块更新
isSelfAccepting?: boolean
// 经过 transform 钩子编译后的结果
transformResult: TransformResult | null = null
// 上一次热更新时间戳
lastHMRTimestamp = 0
lastInvalidationTimestamp = 0

constructor(url: string, setIsSelfAccepting = true) {
this.url = url
this.type = isDirectCSSRequest(url) ? 'css' : 'js'
if (setIsSelfAccepting) {
this.isSelfAccepting = false
}
}
}

ModuleGraph 三个 map 中存储的就是 ModuleNode 模块节点的信息,ModuleNode 中记录了三个和热更新相关的重要属性

  • importers:当前模块被哪些模块引用
  • clientImportedModules:当前模块依赖的其他模块
  • acceptedHmrDeps:其他模块对当前模块HMR的接收关系

在构建了模块依赖图中,服务器会通过chokidar的watch方法来监听文件的修改,新增和删除。

如果监听到了文件的修改,则会经过三个执行步骤:

  1. 获取到标准的文件路径
  2. 通过 moduleGraph 实例的 onFileChange 方法移除文件缓存信息
  3. 执行热更新方法 onHMRUpdate
1
2
3
4
5
6
7
8
9
10
// 监听文件修改操作
watcher.on('change', async (file) => {
// 标准化文件路径
file = normalizePath(file)
// 移除文件缓存信息
moduleGraph.onFileChange(file)
// 执行热更新方法
await onHMRUpdate(file, false)
})

onFileChange 方法会根据文件路径获取到所有模块,并遍历所有模块调用 invalidateModule 方法去除文件缓存信息

在 invalidateModule 方法的执行过程中,还会遍历依赖当前模块的其他模块,清除掉依赖信息,做到完整的清除文件缓存

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
36
37
onFileChange(file: string): void {
const mods = this.getModulesByFile(file)
if (mods) {
// 记录被遍历过的模块,避免重复清理
const seen = new Set<ModuleNode>()

mods.forEach((mod) => {
// 去除文件缓存信息
this.invalidateModule(mod, seen)
})
}
}


invalidateModule(
mod: ModuleNode,
seen: Set<ModuleNode> = new Set(),
timestamp: number = Date.now(),
isHmr: boolean = false,
hmrBoundaries: ModuleNode[] = [],
): void {
// 如果当前模块被遍历清理过,则直接返回
if (seen.has(mod)) return
seen.add(mod)

mod.transformResult = null

if (hmrBoundaries.includes(mod)) return

// 遍历依赖当前模块的其他模块,清除掉依赖信息
mod.importers.forEach((importer) => {
if (!importer.acceptedHmrDeps.has(mod)) {
this.invalidateModule(importer, seen, timestamp, isHmr)
}
})
}

onHMRUpdate 方法中调用 handleHMRUpdate 执行具体模块热更新

1
2
3
4
5
6
7
8
9
10
11
12
13
const onHMRUpdate = async (file: string, configOnly: boolean) => {
if (serverConfig.hmr !== false) {
try {
await handleHMRUpdate(file, server, configOnly)
} catch (err) {
ws.send({
type: 'error',
err: prepareError(err),
})
}
}
}

handleHMRUpdate 有三个执行步骤:

  1. 如果是配置文件、环境变量更新,直接重启服务,因为热更新相关的配置可能有变化
  2. 如果是客户端注入的文件、html 文件更新,直接刷新页面,因为对于这两类文件没有办法进行局部热更新
  3. 如果是普通文件更新,通过 updateModules 执行热更新操作
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
export async function handleHMRUpdate(
file: string,
server: ViteDevServer,
configOnly: boolean,
): Promise<void> {
const { ws, config, moduleGraph } = server
const shortFile = getShortName(file, config.root)
const fileName = path.basename(file)

const isConfig = file === config.configFile
const isConfigDependency = config.configFileDependencies.some(
(name) => file === name,
)
const isEnv =
config.inlineConfig.envFile !== false &&
(fileName === '.env' || fileName.startsWith('.env.'))
// ===== 1.配置文件/环境变量声明文件变化,直接重启服务 =====
if (isConfig || isConfigDependency || isEnv) {
try {
await server.restart()
} catch (e) {
config.logger.error(colors.red(e))
}
return
}

if (configOnly) return

// ===== 2.客户端注入的文件更改 =====
// 给客户端发送 full-reload 信号,刷新页面
if (file.startsWith(normalizedClientDir)) {
ws.send({
type: 'full-reload',
path: '*',
})
return
}

// ===== 3.普通文件更改 =====
// 获取需要更新的文件
const mods = moduleGraph.getModulesByFile(file)

const timestamp = Date.now()
// 初始化 hmr 上下文
const hmrContext: HmrContext = {
file,
timestamp,
modules: mods ? [...mods] : [],
read: () => readModifiedFile(file),
server,
}

// 依次处理 handleHotUpdate 钩子,拿到插件处理后的 hmr 模块
for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
const filteredModules = await hook(hmrContext)
if (filteredModules) {
hmrContext.modules = filteredModules
}
}

// 模块热更新核心方法
updateModules(shortFile, hmrContext.modules, timestamp, server)
}

updateModules 方法会遍历需要更新的模块,收集热更新边界并判断是否超过边界,如果超过了边界范围则需要全量刷新,如果在范围内则记录下来需要热更新的模块信息

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
export function updateModules(
file: string,
modules: ModuleNode[],
timestamp: number,
{ config, ws, moduleGraph }: ViteDevServer,
): void {
const updates: Update[] = []
const traversedModules = new Set<ModuleNode>()
let needFullReload = false

for (const mod of modules) {
// 初始化热更新边界集合
const boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[] = []
// 收集 热更新 边界
const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)

if (needFullReload) continue
// 在热更新边界范围外,需要全量刷新
if (hasDeadEnd) {
needFullReload = true
continue
}

// full load 标识,全量刷新
if (needFullReload) {
ws.send({
type: 'full-reload',
})
return
}

// 记录热更新边界信息
updates.push(
...boundaries.map(({ boundary, acceptedVia }) => ({
type: `${boundary.type}-update` as const,
timestamp,
path: normalizeHmrUrl(boundary.url),
explicitImportRequired:
boundary.type === 'js'
? isExplicitImportRequired(acceptedVia.url)
: undefined,
acceptedPath: normalizeHmrUrl(acceptedVia.url),
})),
)
}

// 通过 websocket 向客户端发送需要热更新的模块
ws.send({
type: 'update',
updates,
})
}

在收集到了需要热更新的模块之后,会通过WS推送给客户端。

1
2
3
4
5
6
7
8
9
10
11
12
{
"type": "update",
"updates": [
{
"type": "js-update",
"timestamp": 1666362526781,
"path": "/context/a.jsx",
"explicitImportRequired": false,
"acceptedPath": "/context/a.jsx"
}
]
}

客户端

在项目启动之时,Vite会向入口文件注入script 脚本 /@vite/client, 脚本中的 setupWebSocket 方法会创建一个 websocket 服务用于监听服务端发送的热更新信息,接收到的信息会通过 handleMessage方法处理

handleMessage 方法主要是根据不同的类型执行不同的操作:

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
async function handleMessage(payload: HMRPayload) {
switch (payload.type) {
case 'connected': {
// 当客户端成功连接到服务器时触发,表示 HMR 已准备就绪
break;
}
case 'update': {
// 当一个或多个模块发生更新时触发
break;
}
case 'custom': {
// 自定义消息类型,用于实现特定的自定义功能
break;
}
case 'full-reload': {
// 页面完全刷新时的操作
break;
}
case 'prune': {
// 清除不再使用的模块
break;
}
case 'error': {
// 在 HMR 过程中发生错误时触发
break;
}
default: {
// 默认情况下,处理未知的消息类型
const check: never = payload;
return check;
}
}
}


update中,会遍历payload的updates。这里不仅有JS的热更新,还可能有CSS之类的热更新。如果是js-update就会通过fetchUpdate获取到更新并放入queueUpdate中。

1
2
3
4
5
6
7
8
9
10
11
case 'update':
await Promise.all(
payload.updates.map(async (update): Promise<void> => {
// js 文件热更新
if (update.type === 'js-update') {
return queueUpdate(fetchUpdate(update))
}
}
)
break

fetchUpdate 方法是执行客户端热更新的主要逻辑,有 4 个步骤

  1. 通过 hotModulesMap 获取 HMR 边界模块相关信息
  2. 获取需要执行的更新回调函数
  3. 对将要更新的模块进行失活操作,并通过动态 import 拉去最新的模块信息
  4. 返回函数,用来执行所有回调
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
36
37
38
39
40
41
42
43
44
async function fetchUpdate({
path,
acceptedPath,
timestamp,
explicitImportRequired,
}: Update) {
// 1. 获取 HMR 边界模块相关信息
const mod = hotModulesMap.get(path)
if (!mod) return

let fetchedModule: ModuleNamespace | undefined
const isSelfUpdate = path === acceptedPath

// 2. 需要执行的更新回调函数
// mod.callbacks 为 import.meta.hot.accept 中绑定的更新回调函数
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
deps.includes(acceptedPath),
)

// 3. 对将要更新更新的模块进行一些清除操作,并通过动态 import 拉去最新的模块信息
if (isSelfUpdate || qualifiedCallbacks.length > 0) {
const disposer = disposeMap.get(acceptedPath)
if (disposer) await disposer(dataMap.get(acceptedPath))

const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
try {
fetchedModule = await import(
base +
acceptedPathWithoutQuery.slice(1) +
`?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
query ? `&${query}` : ''
}`
)
}
}

// 4. 返回函数,用来执行所有回调
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
}
}
}

queueUpdate 方法的作用是缓冲由同一 src 文件变化触发的多个热更新,以相同的发送顺序调用,避免因为 HTTP 请求往返而导致顺序不一致

1
2
3
4
5
6
7
8
9
10
11
12
async function queueUpdate(p: Promise<(() => void) | undefined>) {
queued.push(p)
if (!pending) {
pending = true
await Promise.resolve()
pending = false
const loading = [...queued]
queued = []
(await Promise.all(loading)).forEach((fn) => fn && fn())
}
}

Author: John Doe

Link: https://159357254680.github.io/2025/08/19/vite/

Copyright: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.

NextPost >
Webpack
CATALOG
  1. 1. Vite
    1. 1.1. 前言
    2. 1.2. 启动开发服务器
      1. 1.2.1. 解析配置
      2. 1.2.2. 初始化http服务
      3. 1.2.3. 初始化WebSocket服务
      4. 1.2.4. 启动chokidar监听文件
      5. 1.2.5. 创建ModuleGraph实例
      6. 1.2.6. 创建插件容器
      7. 1.2.7. 创建ViteDevServer对象
      8. 1.2.8. 挂载内部中间件
    3. 1.3. 预构建
      1. 1.3.1. 什么是预构建
      2. 1.3.2. 预构建的过程
    4. 1.4. ‍HMR
      1. 1.4.1. HMR的API
      2. 1.4.2. HMR的过程
        1. 1.4.2.1. 服务端
        2. 1.4.2.2. 客户端