Vite
前言
`王的决斗总是快人三步`
——杰克•阿特拉斯
Vite(法语意为 “快速的”,发音 /vit/
,发音同 “veet”)是一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:
Vite 意在提供开箱即用的配置,同时它的 插件 API 和 JavaScript API 带来了高度的可扩展性,并有完整的类型支持。
Vite的特点如下:
- 快速的冷启动:
No Bundle
+ esbuild
预构建
- 即时的模块热更新: 基于
ESM
的HMR
,同时利用浏览器缓存策略提升速度
- 真正的按需加载: 利用浏览器
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, 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将项目中的模块区分为依赖和源码两类:
- 依赖 更多指的是代码中使用到的第三方模块,比如
vue
、react
等。Vite 将会使用 esbuild在应用启动时对于依赖部分进行预构建依赖。
- 源码 指的是我们项目中的
jsx
、vue
等文件,这部分代码会在运行时被编译,并不会进行任何打包。
Vite 以 ESM 方式提供源码,这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据实际动态导入代码,也就是说会在进行交互,实际使用时才会被处理。
所以这里的依赖预构建,针对的是依赖而不是源码。它的主要目的有两个:
- 规范依赖的模块规范:有的依赖可能采用非ESM模块规范,这就需要借助预构建将非ESM模块的依赖转化为ESM模块。
- 减少http的请求次数:有时在使用一些库时,如果不进行预构建浏览器会同时发出大量http请求,这也需要预构建来将依赖打包起来,从而减少请求次数。
预构建的过程
Vite中的预构建是默认开启的,在你第一次启动时,Vite会自动将依赖打包,并写入编译后文件,同时重写依赖引入路径,设置缓存。
除了 HTTP 缓存,Vite 还设置了本地文件系统的缓存,所有的预构建产物默认缓存在node_modules/.vite目录中。如果以下 3 个地方都没有改动,Vite 将一直使用缓存文件:
- package.json 的 dependencies 字段
- 各种包管理器的 lock 文件
- optimizeDeps 配置内容
如果改动了,Vite将重新进行一次预构建。
之后每次启动,Vite会先进行缓存判断,计算一次项目的hash值,如果和_metadate.json中的相同,则跳过依赖预构建的过程。
如果没有命中缓存,那么Vite会扫描项目中的依赖项。在没有指定入口文件的情况下,Vite会获取项目目录下所有的.html文件,并来检测需要预构建的依赖项。通常默认会使用单个 index.html
作为入口文件。
注:Vite对于依赖和源码的判断很简单,就是bare import。bare import 很好理解,就像下面一样:
1 2 3 4 5 6 7
| import xxx from "react" import xxx from "react/xxx"
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> }> { let entries: string[] = await globEntries('**/*.html', config)
const deps: Record<string, string> = {} const missing: Record<string, string> = {} const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
await Promise.all( entries.map((entry) => build({ absWorkingDir: process.cwd(), write: false, entryPoints: [entry], bundle: true, format: 'esm', plugins: [...plugins, plugin], ...esbuildOptions }) ) )
return { deps, missing } }
|
省流:
- 将项目内的所有html文件作为入口文件
- 将每个入口文件交给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 = ... 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
| build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({ path, external: true }))
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( { 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 }) } if (resolved.includes('node_modules')) { depImports[id] = resolved
return { path, external: true } } else if (isScannable(resolved)) { return { path: path.resolve(resolved) } } else { return { path, external: true } } } else { missing[id] = normalizePath(importer) } } )
|
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)$/
build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => { const resolved = await resolve(path, importer) if (!resolved) return
return { path: resolved, namespace: 'html' } })
const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
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') const regex = isHtml ? scriptModuleRE : scriptRE
regex.lastIndex = 0 let js = '' let scriptId = 0 let match: RegExpExecArray | null
while ((match = regex.exec(raw))) { const [, openTag, content] = match 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]) if ( type && !( type.includes('javascript') || type.includes('ecmascript') || type === 'module' ) ) { continue } let loader: Loader = 'js' if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') { loader = lang } else if (path.endsWith('.astro')) { loader = 'ts' } const srcMatch = openTag.match(srcRE) if (srcMatch) { const src = srcMatch[1] || srcMatch[2] || srcMatch[3] js += `import ${JSON.stringify(src)}\n` } else if (content.trim()) {
const key = `${path}?id=${scriptId++}` scripts[key] = { loader, content, pluginData: { htmlType: { loader } } }
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
| 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
| 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的过程
省流:
- 修改代码,vite server 监听到代码被修改
- vite 计算出热更新的边界
- vite server 通过 websocket 告诉 vite client 需要进行热更新
- 浏览器拉取修改后的模块
- 执行热更新的代码
Vite的dist目录下一共有client和node两个文件夹,分别代表了客户端和服务端。启动Vite时,也就启动了node服务,它与HMR相关的功能一共有两个:
- 启动chokidar监听项目文件变化
- 启动 WebSocket 服务,向客户端主动推送消息
另一个就是 client,比如项目中有入口 index.html 文件,在实际请求时返回的 html 内容中就会注入@vite/client
1
| <script type="module" src="/@vite/client"></script>
|
其与 HMR 相关的功能主要有两个:
- 监听 WebSocket消息,解析后发起文件请求
- 注入 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: string id: string | null = null file: string | null = null type: 'js' | 'css' info?: ModuleInfo 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 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方法来监听文件的修改,新增和删除。
如果监听到了文件的修改,则会经过三个执行步骤:
- 获取到标准的文件路径
- 通过 moduleGraph 实例的 onFileChange 方法移除文件缓存信息
- 执行热更新方法 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 有三个执行步骤:
- 如果是配置文件、环境变量更新,直接重启服务,因为热更新相关的配置可能有变化
- 如果是客户端注入的文件、html 文件更新,直接刷新页面,因为对于这两类文件没有办法进行局部热更新
- 如果是普通文件更新,通过 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.')) if (isConfig || isConfigDependency || isEnv) { try { await server.restart() } catch (e) { config.logger.error(colors.red(e)) } return }
if (configOnly) return
if (file.startsWith(normalizedClientDir)) { ws.send({ type: 'full-reload', path: '*', }) return } const mods = moduleGraph.getModulesByFile(file)
const timestamp = Date.now() const hmrContext: HmrContext = { file, timestamp, modules: mods ? [...mods] : [], read: () => readModifiedFile(file), server, }
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 } 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), })), ) }
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': { break; } case 'update': { break; } case 'custom': { break; } case 'full-reload': { break; } case 'prune': { break; } case 'error': { 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> => { if (update.type === 'js-update') { return queueUpdate(fetchUpdate(update)) } } ) break
|
fetchUpdate 方法是执行客户端热更新的主要逻辑,有 4 个步骤
- 通过 hotModulesMap 获取 HMR 边界模块相关信息
- 获取需要执行的更新回调函数
- 对将要更新的模块进行失活操作,并通过动态 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
| async function fetchUpdate({ path, acceptedPath, timestamp, explicitImportRequired, }: Update) { const mod = hotModulesMap.get(path) if (!mod) return
let fetchedModule: ModuleNamespace | undefined const isSelfUpdate = path === acceptedPath
const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => deps.includes(acceptedPath), )
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}` : '' }` ) } }
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()) } }
|