即时代码热更新,vite 热更新背后的原理
vite 热更新的主要作用是为了实现局部刷新的效果,这样之前操作的状态都能够保存
vite 热更新的基本实现方式如下
- 基于一套完整的 ESM HMR 规范,在文件发生改变时 vite 会检测到相应 ESM 模块变化,触发相应的 API,实现局部的更新
import.meta
对象是现代浏览器原生的一个内置对象,vite 在这个对象上的hot
属性中定义了一套完整的热更新属性和方法
简单举一个例子来说,就是当 import.meta.hot
属性存在时,会调用 accept 方法,对相关模块重新渲染
1if (import.meta.hot) {
2 import.meta.hot.accept((mod) => mod.render())
3}
下面我们就来具体分析 vite 热更新的具体实现原理
实现原理
从整体角度来看,vite 热更新主要分为三步
- 创建模块依赖图:建立模块间的依赖关系
- 服务端收集更新模块:监听文件变化,确定需要更新的模块
- 客户端派发更新:客户端执行文件更新
创建模块依赖图
在 vite 中,主要通过 ModuleGraph
和 ModuleNode
来建立各模块依赖关系,ModuleGraph
记录模块及模块的所有依赖,ModuleNode
记录模块节点具体信息
模块依赖图在项目启动时通过 ModuleGraph
类创建一个实例
1const moduleGraph: ModuleGraph = new ModuleGraph((url, ssr) =>
2 container.resolveId(url, undefined, { ssr })
3)
ModuleGraph
主要通过三个 Map 和一个 Set 来记录模块信息,包括
- urlToModuleMap:原始请求 url 到模块节点的映射,如 /src/index.tsx(vite 中的每个模块 url 是唯一的)
- idToModuleMap:模块 id 到模块节点的映射,id 是原始请求 url 经过
resolveId
钩子解析后的结果 - fileToModulesMap:文件到模块节点的映射,由于单文件可能包含多个模块,如 .vue 文件,因此 Map 的 value 值为一个集合
- safeModulesPath:录被认为是“安全”的模块路径,安全路径不需要模块转换和处理
1// 目录:packages/vite/src/node/server/moduleGraph.ts
2export class ModuleGraph {
3 urlToModuleMap = new Map<string, ModuleNode>()
4 idToModuleMap = new Map<string, ModuleNode>()
5 fileToModulesMap = new Map<string, Set<ModuleNode>>()
6 safeModulesPath = new Set<string>()
7}
ModuleGraph
三个 map 中存储的就是 ModuleNode
模块节点的信息,ModuleNode
中记录了三个和热更新相关的重要属性
- importers:当前模块被哪些模块引用
- clientImportedModules:当前模块依赖的其他模块
- acceptedHmrDeps:其他模块对当前模块的依赖关系,发生热更新时,根据 acceptedHmrDeps 记录的信息通知其他模块信息热更新
1export class ModuleNode {
2 // 原始请求 url
3 url: string
4 // 文件绝对路径 + query
5 id: string | null = null
6 // 文件绝对路径
7 file: string | null = null
8 type: 'js' | 'css'
9 info?: ModuleInfo
10 // resolveId 钩子返回结构的元数据
11 meta?: Record<string, any>
12 // 重要:当前模块被哪些模块引用
13 importers = new Set<ModuleNode>()
14 // 重要:当前模块依赖的其他模块
15 clientImportedModules = new Set<ModuleNode>()
16 // 接收热更新的模块
17 acceptedHmrDeps = new Set<ModuleNode>()
18 acceptedHmrExports: Set<string> | null = null
19 importedBindings: Map<string, Set<string>> | null = null
20 // 是否为 接受自身模块更新
21 isSelfAccepting?: boolean
22 // 经过 transform 钩子编译后的结果
23 transformResult: TransformResult | null = null
24 // 上一次热更新时间戳
25 lastHMRTimestamp = 0
26 lastInvalidationTimestamp = 0
27
28 constructor(url: string, setIsSelfAccepting = true) {
29 this.url = url
30 this.type = isDirectCSSRequest(url) ? 'css' : 'js'
31 if (setIsSelfAccepting) {
32 this.isSelfAccepting = false
33 }
34 }
35}
那么 ModuleNode
模块的节点信息是在什么时候创建的呢,上一篇文章中介绍了 vite 会模拟 Rollup 执行一系列钩子,其中有一个 transform
代码转换钩子,ModuleNode
就是在这个时候创建的
首先通过 transformRequest
方法获取代码转换的结果,该方法会调用 doTransform
方法执行代码转换过程
在通过 doTransform
-> loadAndTransform
-> _ensureEntryFromUrl
,如果在 idToModuleMap 中没有记录模块节点信息的话,就会创建一个 ModuleNode
实例并记录到对应的 map 中
1// 目录:packages/vite/src/node/server/middlewares/transform.ts
2const result = await transformRequest(url, server, {
3 html: req.headers.accept?.includes('text/html'),
4})
5
6// 目录:packages/vite/src/node/server/transformRequest.ts
7export function transformRequest() {
8 const request = doTransform(url, server, options, timestamp)
9}
10
11/**
12 * 执行代码转换过程
13 */
14async function doTransform() {
15 // 从 ModuleGraph 查找节点信息
16 const module = await server.moduleGraph.getModuleByUrl(url, ssr)
17
18 // 命中缓存,直接返回缓存
19 const cached = module && module.transformResult
20 if (cached) {
21 return cached
22 }
23
24 // 调用 PluginContainer 的 resolveId 和 load 方法进行模块加载
25 const resolved = module
26 ? undefined
27 : (await pluginContainer.resolveId(url, undefined, { ssr })) ?? undefined
28
29 const result = loadAndTransform()
30 return result
31}
32
33async function loadAndTransform() {
34 mod ??= await moduleGraph._ensureEntryFromUrl(url, ssr, undefined, resolved)
35}
36
37async _ensureEntryFromUrl() {
38 rawUrl = removeImportQuery(removeTimestampQuery(rawUrl))
39 let mod = this._getUnresolvedUrlToModule(rawUrl, ssr)
40 if (mod) return mod
41
42 const modPromise = (async () => {
43 // 调用各插件的 resolveId 得到路径
44 const [url, resolvedId, meta] = await this._resolveUrl(rawUrl,ssr,resolved)
45 mod = this.idToModuleMap.get(resolvedId)
46
47 if (!mod) {
48 // 如果没有缓存,创建新的 ModuleNode 对象
49 // 记录到 urlToModuleMap、idToModuleMap、fileToModulesMap
50 mod = new ModuleNode(url, setIsSelfAccepting)
51
52 this.urlToModuleMap.set(url, mod)
53 this.idToModuleMap.set(resolvedId, mod)
54 fileMappedModules.add(mod)
55 }
56 return mod
57 })()
58
59 return modPromise
60}
在创建了 ModuleNode
实例之后,模块之间的依赖关系同样是在 transform
钩子中创建,在钩子中 vite 定义了一个 vite:import-analysis
插件,插件执行过程中会得到三个解析信息
- importedUrls: 当前模块的依赖模块 url 集合
- acceptedUrls: 当前模块中通过 import.meta.hot.accept 声明的依赖模块 url 集合
- isSelfAccepting: 分析 import.meta.hot.accept 的用法,标记是否为接受自身更新的类型
根据这三个信息,通过 updateModuleInfo
方法更新 ModuleNode
实例的三个核心属性:importers、clientImportedModules、acceptedHmrDeps
1async updateModuleInfo(
2 mod: ModuleNode,
3 importedModules: Set<string | ModuleNode>,
4 importedBindings: Map<string, Set<string>> | null,
5 acceptedModules: Set<string | ModuleNode>,
6 acceptedExports: Set<string> | null,
7 isSelfAccepting: boolean,
8): Promise<Set<ModuleNode> | undefined> {
9 mod.isSelfAccepting = isSelfAccepting
10 let resolveResults = new Array(importedModules.size)
11
12 for (const imported of importedModules) {
13 // 当前模块被哪些模块引用
14 imported.importers.add(mod)
15 resolveResults[nextIndex] = imported
16 }
17 }
18 // 当前模块依赖的其他模块
19 mod.clientImportedModules = new Set(resolveResults)
20
21 resolveResults = new Array(acceptedModules.size)
22 for (const accepted of acceptedModules) {
23 resolveResults[nextIndex] = accepted
24 }
25 // 接收热更新的模块
26 mod.acceptedHmrDeps = new Set(resolveResults)
27
28 return noLongerImported
29}
小结一下创建模块依赖图这一步骤
- 服务启动时创建
ModuleGraph
实例,记录模块信息 - 执行 transform 钩子过程中,创建
ModuleNode
实例记录模块节点具体信息 - transform 钩子的
vite:import-analysis
插件执行过程中,解析记录模块间的依赖关系,记录三个核心属性:importers、clientImportedModules、acceptedHmrDeps
服务端收集更新模块
在服务启动阶段,使用 chokidar 的 watch
方法创建文件监听器,监听文件的修改、新增、删除操作
1const watcher = chokidar.watch(
2 [root, ...config.configFileDependencies, config.envDir],
3 resolvedWatchOptions
4) as FSWatcher
当文件修改时,有三个执行步骤
- 获取到标准的文件路径
- 通过 moduleGraph 实例的
onFileChange
方法移除文件缓存信息 - 执行热更新方法
onHMRUpdate
1// 监听文件修改操作
2watcher.on('change', async (file) => {
3 // 标准化文件路径
4 file = normalizePath(file)
5 // 移除文件缓存信息
6 moduleGraph.onFileChange(file)
7 // 执行热更新方法
8 await onHMRUpdate(file, false)
9})
对于文件的新增和删除,使用的同一个方法,执行步骤和文件修改类似,只是第二步的方法有所不同,但本质上都是使用 moduleGraph 的 onFileChange
方法移除文件缓存信息,再执行热更新方法 onHMRUpdate
1// 监听文件新增和删除操作
2const onFileAddUnlink = async (file: string) => {
3 // 标准化文件路径
4 file = normalizePath(file)
5 // 处理新增和修改文件操作,本质也是移除文件缓存信息
6 await handleFileAddUnlink(file, server)
7 // 执行热更新方法
8 await onHMRUpdate(file, true)
9}
10
11// 监听文件新增
12watcher.on('add', onFileAddUnlink)
13// 监听文件删除
14watcher.on('unlink', onFileAddUnlink)
所以核心的两个方法是 onFileChange
和 handleHMRUpdate
,下面来具体分析这两个方法
onFileChange
方法会根据文件路径获取到所有模块,并遍历所有模块调用 invalidateModule
方法去除文件缓存信息
在 invalidateModule
方法的执行过程中,还会遍历依赖当前模块的其他模块,清除掉依赖信息,做到完整的清除文件缓存
1onFileChange(file: string): void {
2 const mods = this.getModulesByFile(file)
3 if (mods) {
4 // 记录被遍历过的模块,避免重复清理
5 const seen = new Set<ModuleNode>()
6
7 mods.forEach((mod) => {
8 // 去除文件缓存信息
9 this.invalidateModule(mod, seen)
10 })
11 }
12}
13
14
15invalidateModule(
16 mod: ModuleNode,
17 seen: Set<ModuleNode> = new Set(),
18 timestamp: number = Date.now(),
19 isHmr: boolean = false,
20 hmrBoundaries: ModuleNode[] = [],
21): void {
22 // 如果当前模块被遍历清理过,则直接返回
23 if (seen.has(mod)) return
24 seen.add(mod)
25
26 mod.transformResult = null
27
28 if (hmrBoundaries.includes(mod)) return
29
30 // 遍历依赖当前模块的其他模块,清除掉依赖信息
31 mod.importers.forEach((importer) => {
32 if (!importer.acceptedHmrDeps.has(mod)) {
33 this.invalidateModule(importer, seen, timestamp, isHmr)
34 }
35 })
36}
onHMRUpdate
方法中调用 handleHMRUpdate
执行具体模块热更新
1const onHMRUpdate = async (file: string, configOnly: boolean) => {
2 if (serverConfig.hmr !== false) {
3 try {
4 await handleHMRUpdate(file, server, configOnly)
5 } catch (err) {
6 ws.send({
7 type: 'error',
8 err: prepareError(err),
9 })
10 }
11 }
12}
handleHMRUpdate
有三个执行步骤:
- 如果是配置文件、环境变量更新,直接重启服务,因为热更新相关的配置可能有变化
- 如果是客户端注入的文件(vite/dist/client/client.mjs)、html 文件更新,直接刷新页面,因为对于这两类文件没有办法进行局部热更新
- 如果是普通文件更新,通过
updateModules
执行热更新操作
1export async function handleHMRUpdate(
2 file: string,
3 server: ViteDevServer,
4 configOnly: boolean
5): Promise<void> {
6 const { ws, config, moduleGraph } = server
7 const shortFile = getShortName(file, config.root)
8 const fileName = path.basename(file)
9
10 const isConfig = file === config.configFile
11 const isConfigDependency = config.configFileDependencies.some(
12 (name) => file === name
13 )
14 const isEnv =
15 config.inlineConfig.envFile !== false &&
16 (fileName === '.env' || fileName.startsWith('.env.'))
17 // ===== 1.配置文件/环境变量声明文件变化,直接重启服务 =====
18 if (isConfig || isConfigDependency || isEnv) {
19 try {
20 await server.restart()
21 } catch (e) {
22 config.logger.error(colors.red(e))
23 }
24 return
25 }
26
27 if (configOnly) return
28
29 // ===== 2.客户端注入的文件(vite/dist/client/client.mjs)更改 =====
30 // 给客户端发送 full-reload 信号,刷新页面
31 if (file.startsWith(normalizedClientDir)) {
32 ws.send({
33 type: 'full-reload',
34 path: '*',
35 })
36 return
37 }
38
39 // ===== 3.普通文件更改 =====
40 // 获取需要更新的文件
41 const mods = moduleGraph.getModulesByFile(file)
42
43 const timestamp = Date.now()
44 // 初始化 hmr 上下文
45 const hmrContext: HmrContext = {
46 file,
47 timestamp,
48 modules: mods ? [...mods] : [],
49 read: () => readModifiedFile(file),
50 server,
51 }
52
53 // 依次处理 handleHotUpdate 钩子,拿到插件处理后的 hmr 模块
54 for (const hook of config.getSortedPluginHooks('handleHotUpdate')) {
55 const filteredModules = await hook(hmrContext)
56 if (filteredModules) {
57 hmrContext.modules = filteredModules
58 }
59 }
60
61 // 没有需要热更新的模块直接 return
62 if (!hmrContext.modules.length) {
63 // html 文件更新重新刷新页面
64 if (file.endsWith('.html')) {
65 ws.send({
66 type: 'full-reload',
67 path: config.server.middlewareMode
68 ? '*'
69 : '/' + normalizePath(path.relative(config.root, file)),
70 })
71 }
72 return
73 }
74
75 // 模块热更新核心方法
76 updateModules(shortFile, hmrContext.modules, timestamp, server)
77}
updateModules
方法会遍历需要更新的模块,通过 propagateUpdate
方法收集热更新边界并判断是否超过边界,如果超过了边界范围则需要全量刷新,如果在范围内则记录下来需要热更新的模块信息
1export function updateModules(
2 file: string,
3 modules: ModuleNode[],
4 timestamp: number,
5 { config, ws, moduleGraph }: ViteDevServer
6): void {
7 const updates: Update[] = []
8 const traversedModules = new Set<ModuleNode>()
9 let needFullReload = false
10
11 for (const mod of modules) {
12 // 初始化热更新边界集合
13 const boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[] = []
14 // 收集 热更新 边界
15 const hasDeadEnd = propagateUpdate(mod, traversedModules, boundaries)
16
17 if (needFullReload) continue
18 // 在热更新边界范围外,需要全量刷新
19 if (hasDeadEnd) {
20 needFullReload = true
21 continue
22 }
23
24 // 记录热更新边界信息
25 updates.push(
26 ...boundaries.map(({ boundary, acceptedVia }) => ({
27 type: `${boundary.type}-update` as const,
28 timestamp,
29 path: normalizeHmrUrl(boundary.url),
30 explicitImportRequired:
31 boundary.type === 'js'
32 ? isExplicitImportRequired(acceptedVia.url)
33 : undefined,
34 acceptedPath: normalizeHmrUrl(acceptedVia.url),
35 }))
36 )
37 }
38
39 // full load 标识,全量刷新
40 if (needFullReload) {
41 ws.send({
42 type: 'full-reload',
43 })
44 return
45 }
46
47 // 通过 websocket 向客户端发送需要热更新的模块
48 ws.send({
49 type: 'update',
50 updates,
51 })
52}
小结一下服务端收集更新模块这一步
- 在服务启动阶段,会通过 chokidar 的
watch
方法方法创建一个文件监听器,当文件发生修改、新增和删除操作时,执行热更新操作 - 热更新操作前会调用 moduleGraph 实例的
onFileChange
方法,清理文件的缓存信息 - 通过
updateModules
执行收集需要热更新的模块,通过 websocket 向客户端发送需要热更新的模块
客户端派发更新
上一步服务端通过 websocket 发送给客户端需要热更新的信息如下,接下来我们就来分析客户端是如何接收这个信息,并进行热更新操作的
1{
2 "type": "update",
3 "update": [
4 {
5 // 更新类型,也可能是 `css-update`
6 "type": "js-update",
7 // 更新时间戳
8 "timestamp": 1650702020986,
9 // 热更模块路径
10 "path": "/src/main.ts",
11 // 接受的子模块路径
12 "acceptedPath": "/src/render.ts"
13 }
14 ]
15}
在项目启动阶段,会向创建的 index.html 中拼接一段 script 脚本 <script type="module" src="/@vite/client"></script>
1server.transformIndexHtml = createDevHtmlTransformFn(server)
2
3const devHtmlHook: IndexHtmlTransformHook = async (
4 html,
5 { path: htmlPath, filename, server, originalUrl }
6) => {
7 // 代码省略 。。。
8
9 html = s.toString()
10
11 // html 末尾拼接 <script type="module" src="/@vite/client"></script>
12 const CLIENT_PUBLIC_PATH = '/@vite/client'
13 return {
14 html,
15 tags: [
16 {
17 tag: 'script',
18 attrs: {
19 type: 'module',
20 src: path.posix.join(base, CLIENT_PUBLIC_PATH),
21 },
22 injectTo: 'head-prepend',
23 },
24 ],
25 }
26}
script 脚本 /@vite/client
会向客户端注入一段默认的代码,代码中执行的 setupWebSocket
方法会创建一个 websocket 服务用于监听服务端发送的热更新信息,接收到的信息会通过 handleMessage
方法处理
1function setupWebSocket(
2 protocol: string,
3 hostAndPath: string,
4 onCloseWithoutOpen?: () => void
5) {
6 const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
7 let isOpened = false
8
9 // 开启事件
10 socket.addEventListener(
11 'open',
12 () => {
13 isOpened = true
14 notifyListeners('vite:ws:connect', { webSocket: socket })
15 },
16 { once: true }
17 )
18
19 socket.addEventListener('message', async ({ data }) => {
20 // 接收并处理服务端的热更新信息
21 handleMessage(JSON.parse(data))
22 })
23
24 return socket
25}
handleMessage
方法主要是根据不同的类型执行不同的操作,我们接下来主要分析 update 时的热更新核心逻辑
1async function handleMessage(payload: HMRPayload) {
2 switch (payload.type) {
3 case 'connected': {
4 // 当客户端成功连接到服务器时触发,表示 HMR 已准备就绪
5 break
6 }
7 case 'update': {
8 // 当一个或多个模块发生更新时触发,热更新的核心逻辑
9 break
10 }
11 case 'custom': {
12 // 自定义消息类型,用于实现特定的自定义功能
13 break
14 }
15 case 'full-reload': {
16 // 页面完全刷新时的操作
17 break
18 }
19 case 'prune': {
20 // 清除不再使用的模块
21 break
22 }
23 case 'error': {
24 // 在 HMR 过程中发生错误时触发
25 break
26 }
27 default: {
28 // 默认情况下,处理未知的消息类型
29 const check: never = payload
30 return check
31 }
32 }
33}
update 类型的操作中,包含 js 和 css 文件的热更新,两类文件的更新原理类似,我们主要分析 js 文件的热更新。在遍历 payload 的 updates 时,如果类型是 js-update 就会将 fetchUpdate
方法放入 queueUpdate
方法中执行
1case 'update':
2 await Promise.all(
3 payload.updates.map(async (update): Promise<void> => {
4 // js 文件热更新
5 if (update.type === 'js-update') {
6 return queueUpdate(fetchUpdate(update))
7 }
8 }
9 )
10 break
queueUpdate
方法的作用是缓冲由同一 src 文件变化触发的多个热更新,以相同的发送顺序调用,避免因为 HTTP 请求往返而导致顺序不一致
1async function queueUpdate(p: Promise<(() => void) | undefined>) {
2 queued.push(p)
3 if (!pending) {
4 pending = true
5 await Promise.resolve()
6 pending = false
7 const loading = [...queued]
8 queued = []
9 ;(await Promise.all(loading)).forEach((fn) => fn && fn())
10 }
11}
fetchUpdate
方法是执行客户端热更新的主要逻辑,有 4 个步骤
- 通过 hotModulesMap 获取 HMR 边界模块相关信息
- 获取需要执行的更新回调函数
- 对将要更新更新的模块进行失活操作,并通过动态 import 拉去最新的模块信息
- 返回函数,用来执行所有回调
1async function fetchUpdate({
2 path,
3 acceptedPath,
4 timestamp,
5 explicitImportRequired,
6}: Update) {
7 // 1. 获取 HMR 边界模块相关信息
8 const mod = hotModulesMap.get(path)
9 if (!mod) return
10
11 let fetchedModule: ModuleNamespace | undefined
12 const isSelfUpdate = path === acceptedPath
13
14 // 2. 需要执行的更新回调函数
15 // mod.callbacks 为 import.meta.hot.accept 中绑定的更新回调函数
16 const qualifiedCallbacks = mod.callbacks.filter(({ deps }) =>
17 deps.includes(acceptedPath),
18 )
19
20 // 3. 对将要更新更新的模块进行失活操作,并通过动态 import 拉去最新的模块信息
21 if (isSelfUpdate || qualifiedCallbacks.length > 0) {
22 const disposer = disposeMap.get(acceptedPath)
23 if (disposer) await disposer(dataMap.get(acceptedPath))
24
25 const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
26 try {
27 fetchedModule = await import(
28 base +
29 acceptedPathWithoutQuery.slice(1) +
30 `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
31 query ? `&${query}` : ''
32 }`
33 )
34 }
35 }
36
37 // 4. 返回函数,用来执行所有回调
38 return () => {
39 for (const { deps, fn } of qualifiedCallbacks) {
40 fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
41 }
42 }
43}
其中需要解释一下的就是 hotModulesMap 存储的边界模块信息是什么时候获取的,同样也是在 /@vite/client
注入的客户端脚本中,通过 createHotContext
方法注入,并赋值给 import.meta.hot
1str().prepend(
2 `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` +
3 `import.meta.hot = __vite__createHotContext(${JSON.stringify(
4 normalizeHmrUrl(importerModule.url)
5 )});`
6)
总结
最后总结一些 vite 热更新的实现原理
- 创建模块依赖图:服务启动时创建
ModuleGraph
实例,执行 transform 钩子时创建ModuleNode
实例,记录模块间的依赖关系 - 服务端收集更新模块:服务启动时通过 chokidar 创建监听器,当文件发生变化时收集需要热更新的模块,将需要更新的模块信息通过 websocket 发送给客户端
- 客户端派发更新:服务器启动时会在 index.html 注入一段客户端代码,创建一个 websocket 服务监听服务端端发送的热更新信息,在收到服务端的信息后根据模块依赖关系进行模块热更新