即时代码热更新,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 热更新主要分为三步

  1. 创建模块依赖图:建立模块间的依赖关系
  2. 服务端收集更新模块:监听文件变化,确定需要更新的模块
  3. 客户端派发更新:客户端执行文件更新

创建模块依赖图

在 vite 中,主要通过 ModuleGraphModuleNode 来建立各模块依赖关系,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}

小结一下创建模块依赖图这一步骤

  1. 服务启动时创建 ModuleGraph 实例,记录模块信息
  2. 执行 transform 钩子过程中,创建 ModuleNode 实例记录模块节点具体信息
  3. transform 钩子的 vite:import-analysis 插件执行过程中,解析记录模块间的依赖关系,记录三个核心属性:importers、clientImportedModules、acceptedHmrDeps

服务端收集更新模块

在服务启动阶段,使用 chokidar 的 watch 方法创建文件监听器,监听文件的修改、新增、删除操作

1const watcher = chokidar.watch( 2 [root, ...config.configFileDependencies, config.envDir], 3 resolvedWatchOptions 4) as FSWatcher

当文件修改时,有三个执行步骤

  1. 获取到标准的文件路径
  2. 通过 moduleGraph 实例的 onFileChange 方法移除文件缓存信息
  3. 执行热更新方法 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)

所以核心的两个方法是 onFileChangehandleHMRUpdate ,下面来具体分析这两个方法

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 有三个执行步骤:

  1. 如果是配置文件、环境变量更新,直接重启服务,因为热更新相关的配置可能有变化
  2. 如果是客户端注入的文件(vite/dist/client/client.mjs)、html 文件更新,直接刷新页面,因为对于这两类文件没有办法进行局部热更新
  3. 如果是普通文件更新,通过 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}

小结一下服务端收集更新模块这一步

  1. 在服务启动阶段,会通过 chokidar 的 watch 方法方法创建一个文件监听器,当文件发生修改、新增和删除操作时,执行热更新操作
  2. 热更新操作前会调用 moduleGraph 实例的 onFileChange 方法,清理文件的缓存信息
  3. 通过 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 个步骤

  1. 通过 hotModulesMap 获取 HMR 边界模块相关信息
  2. 获取需要执行的更新回调函数
  3. 对将要更新更新的模块进行失活操作,并通过动态 import 拉去最新的模块信息
  4. 返回函数,用来执行所有回调
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 热更新的实现原理

  1. 创建模块依赖图:服务启动时创建 ModuleGraph 实例,执行 transform 钩子时创建 ModuleNode 实例,记录模块间的依赖关系
  2. 服务端收集更新模块:服务启动时通过 chokidar 创建监听器,当文件发生变化时收集需要热更新的模块,将需要更新的模块信息通过 websocket 发送给客户端
  3. 客户端派发更新:服务器启动时会在 index.html 注入一段客户端代码,创建一个 websocket 服务监听服务端端发送的热更新信息,在收到服务端的信息后根据模块依赖关系进行模块热更新

1691319058993.png