vite1.x 热更新(HMR)的实现原理
前言
将近一年前自己尝试阅读vite源码(2.x),虽然也有些收获但整体并没有到达我的预期,对于vite也是停留在一知半解的程度上。最近想重新开始学习vite,但回顾之前的学习历程,感觉不太想继续之前的方式,自己的水平有限,读起来太费劲,经常在不同的函数调用间迷失自己,最后草草收场。想起之前看文章很多人是看代码的最初实现版本的,于是也想尝试一下,选择阅读vite的最初版本分支1.x,效果是明显比之前好的,后续我觉得再阅读最新版本的代码的话是有很大帮助的。
阅读过程中发现关于热更新(HMR)这块逻辑略微复杂,想着记录下来,避免之后忘记。
HMR
之前对于HMR的了解大概是:webpack/vite会在启动后开启websocket服务用于浏览器端和服务端之间的通信,每当我们修改代码后服务端就会发送消息给浏览器端,浏览器端进行更新,对于具体过程是不太了解的
阅读分支
vite-v1
下文中的vite如无特殊说明均指的v1版本
前置了解
vite开发模式下会启动一个server以供开发者访问调试,具体实现中是启动了一个Koa服务,vite对被访问文件的处理都是以插件的形式进行的,HMR相关的主要有以下几个文件
vite
├─ src
│ ├─ client
│ │ ├─ client.ts
│ ├─ hmrPayload.ts
│ └─ node
│ ├─ server
│ │ ├─ index.ts
│ │ ├─ serverPluginClient.ts
│ │ ├─ serverPluginCss.ts
│ │ ├─ serverPluginHmr.ts
│ │ ├─ serverPluginHtml.ts
│ │ ├─ serverPluginModuleRewrite.ts
│ │ ├─ serverPluginVue.ts
整体流程
首先按照vite官网命令起一个demo,npm run dev
之后打开开发者工具,可以看到请求的大概过程是:
浏览器端
入口文件index.html
的处理
第一个请求是访问index.html
的,与源文件不同的是这里多了一行代码,浏览器就会请求client.js
<script type="module" src="/@vite/client"></script>
这个处理是有htmlRewritePlugin
插件完成的,代码如下(不过vite-v1中不是以src
的方式引入的,而是import /vite/client)
:
export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"
</script>\n`
...
injectScriptToHtml(html, devInjectionCode)
...
client.js
对于/vite/client
的访问 是由clientPlugin
插件处理的,主要是读取client/client.js
文件并进行一些初始化后返回,具体实现如下:
// src/node/server/serverPluginClient.ts
export const clientFilePath = path.resolve(__dirname, '../../client/client.js')
export const clientPublicPath = `/vite/client`
export const clientPlugin: ServerPlugin = ({ app, config }) => {const clientCode = fs.readFileSync(clientFilePath, 'utf-8').replace(`__MODE__`, JSON.stringify(config.mode || 'development'))app.use(async (ctx, next) => {if (ctx.path === clientPublicPath) {// ...ctx.type = 'js'ctx.status = 200ctx.body = clientCode}})
}
client.js
中主要做了以下三件事
- 启动websocket建立与服务端之前的连接
- 接受websocket信息并进行相应处理(处理细节在后面)
- 暴露出一个HMR Context,以供其他模块(文件)调用
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')socket.addEventListener('message', async ({ data }) => {const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayloadif (payload.type === 'multi') {payload.updates.forEach(handleMessage)} else {handleMessage(payload)}
})export const createHotContext = (id: string) => {...const hot = {accept(callback: HotCallback['fn'] = () => {}) {hot.acceptDeps(id, callback)},acceptDeps(deps: HotCallback['deps'],callback: HotCallback['fn'] = () => {}) {...},...}return hot
}
其他模块(文件)HMR能力的注入
随便打开几个文件可以发现在某些文件中是由HMR相关代码注入的,比如App.vue
可以看到除了业务代码外在最开始引入了client.js
并创建了一个App.vue
的HMR模块,在结束的地方调用了一些HMR的方法,有了这些就可以完成App.vue
的热更新了
服务端
服务端的处理主要是hmrPlugin
moduleRewritePlugin
插件和一些专门处理某类文件HMR的cssPlugin
vuePlugin
插件完成的。
hmrPlugin
主要做了以下几件事
- 启动服务端的websocket
- 每当文件有变化的时候会向浏览器端发送信息
import chokidar from 'chokidar' // `chokidar`是用来监听文件变化的
const watcher = chokidar.watch(root, {ignored: ['**/node_modules/**', '**/.git/**'],ignoreInitial: true,...chokidarWatchOptions
}) as HMRWatcher
const wss = new WebSocket.Server({ noServer: true })
watcher.on('change', (file) => {if (!(file.endsWith('.vue') || isCSSRequest(file))) {// vue文件和plain css文件在serverPluginVue 和 serverPluginCss文件中处理handleJSReload(file)}
})// 这里把send方法直接放到watcher实例上了,便于有文件变化的话可以直接send消息
const send = (watcher.send = (payload: HMRPayload) => {wss.clients.forEach((client) => {if (client.readyState === WebSocket.OPEN) {client.send(stringified)}})
})
cssPlugin
和vuePlugin
是分别用来处理.css
文件和.vue
文件的,里面包含了HMR相关的部分,比如,App.vue
最下方的HMR逻辑的注入就是从vuePlugin
写入的(这里其实我没找到import.meta.hot.accept相关的逻辑,只有hmrId注入,但在最新版plugin-vue插件中找到了相关逻辑,这里我就认为是在vue插件中注入的了)
// src/node/server/serverPluginVue.ts
...code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`code += `\ntypeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)`code += `\n__script.__file = ${JSON.stringify(filePath)}`code += `\nexport default __script`
...
// https://github.com/vitejs/vite/blob/7a6d4bc0d7fa614d3ac469ca35352a23aaef8232/packages/plugin-vue/src/main.ts#L115
// HMR
if (devServer &&devServer.config.server.hmr !== false &&!ssr &&!isProduction
) {...output.push(`import.meta.hot.accept(mod => {`,` if (!mod) return`,` const { default: updated, _rerender_only } = mod`,` if (_rerender_only) {`,` __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render)`,` } else {`,` __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated)`,` }`,`})`)
}
moduleRewritePlugin
主要并不是来处理HMR的,只是在对请求的模块(文件)进行重写处理的过程中进行了文件依赖关系的分析和HMR逻辑的相关重写,支撑了HMR功能。App.vue
模块上方HMR Context的注入就是在此插件中完成的。
const hasHMR = source.includes('import.meta.hot')
if (hasHMR) {rewriteFileWithHMR(root, source, importer, resolver, s)
}function rewriteFileWithHMR() {...s.prepend(`import { createHotContext } from "${clientPublicPath}"; ` +`import.meta.hot = createHotContext(${JSON.stringify(importer)}); `)...
}
具体流程
通过以上插件的执行,浏览器端和服务端通信的websocket就有了,各个文件要进行HMR的预先处理也完成了,只要文件发生变化,服务端就通知客户端进行热更新,那么文件变化=>进行热更新的具体流程是啥呢?
前置了解-HMR设计的思路
hmrPlugin
中对于HMR的设计思路进行了注释(如下图)
大意是指对文件进行HMR graph analysis, 只要是走到dead end
,就发送full page reload
,否则找到相应的hmr boundary(指的是本次hmr受到影响的文件)
,把要进行hmr的所有模块hmr Boundaries
都发送给浏览器端。对应的代码实现就是上面提到的hmrPlugin
里的handleJSReload(file)
法,这里留个大概印象就行,先不用关心具体细节比如是dead end
,什么是hmr boundary
,下面都有涉及
初次访问某个文件
moduleRewritePlugin
是最后执行的koa插件,接收到的文件已经全部被处理为了js文件,moduleRewritePlugin
的主要作用就是
- 路径的重写,比如把对某些第三方包的请求路径改为预购建后的路径
- 记录模块之前的依赖关系
- 加入HMR逻辑并
track HMR boundary accept whitelists(这一段不知道该怎么翻译)
这里的2、3都是使用es-module-lexer
将文件转化为ast然后再进行解析得到的。
其中模块之间的依赖关系被放在了以下两个变量中
// moduleRewritePlugin / function rewriteImports
export const importerMap: HMRStateMap = new Map()
export const importeeMap: HMRStateMap = new Map()
// 例如,a模块有有一句`import {x} from 'b'`
// 那么importeeMap里就会加一条 {key: a, value:['b']}
// importerMap里就会加一条{ key: b, value: ['a']},两者的key 、value是相反的关系
// 每个文件都这么记录下来就能获取到所有文件的依赖关系
加入HMR逻辑是指上面提到的检测到vuePlugin
注入了一段import.meta.hot.accept
后在文件头部注入的HMR Context
,track HMR boundary accept whitelists(这一段不知道该怎么翻译)
是指下面这两个变量
// fucntion rewriteFileWithHMR
export const hmrAcceptanceMap: HMRStateMap = new Map()
export const hmrDeclineSet = new Set<string>() // 当发现文件中有调用Hmr方法,比如 import.hot.meta.accept 或者其他方法时,就会开始记录
// 比如 a 模块中有 import.meta.hot.accept(['./b','./c'],callback)
// hmrAcceptanceMap里就会加一个 {key: a, value: ['./b', './c']}
// 这里简单说下accept是单个模块,如`import.meta.hot.accept('./foo', () => {})`
// `import.meta.hot.accept() OR import.meta.hot.accept(() => {})`会把当前模块加进去
// accepts是接受多个模块
// decline的话会加到hmrDeclineSet里
有了以上这些变量后就能够支持HMR graph analysis
了
监听文件变化
// src/node/server/serverPluginHmr.tswatcher.on('change', (file) => {if (!(file.endsWith('.vue') || isCSSRequest(file))) {// everything except plain .css are considered HMR dependencies.// plain css has its own HMR logic in ./serverPluginCss.ts.handleJSReload(file)}})
可以看到当监听到文件变化后,会有两大类的处理
- 一般性的处理 ,执行
handleJSReload
- 特殊处理,对于
.vue .css
文件需要在其对应的插件中处理
这里我们先看对一般性文件handleJSReload
的处理
HMR graph analysis
这里的实现也就对应着上面提到的HMR设计思路
fn handleJSReload
里表明 HMR graph analysis分析的结果就两种,要么是dead end
,发送
send({ type: 'full-reload', path: publicPath })
要么就是把找到的多个hmr boundary
发送出去
fn walkImportChain
是来判断到底是dead end
还是存在hmr boundary
的,
- 当前文件调用了
import.meta.hot.decline()
,那么一定是dead end
- 当前文件自己就存在自己的
hmrAcceptanceMap
里, 那么自己就是hmr boundary
- 如果(被当前文件使用的文件)是.vue文件或者(被当前文件使用的文件)的
hmrAcceptanceMap
里包括当前文件或者自己,那么被(当前文件使用的文件)就是hmr boundary
- 如果非以上情况,那么就递归的判断 被(被当前文件使用的文件)使用的文件,一直往上,直到结束
// 在importer 的hmrAcceptanceMap里
function isHmrAccepted(importer: string, dep: string): boolean {const deps = hmrAcceptanceMap.get(importer)return deps ? deps.has(dep) : false
}
const handleJSReload = (watcher.handleJSReload = (filePath: string,timestamp: number = Date.now()
) => {const publicPath = resolver.fileToRequest(filePath)const importers = importerMap.get(publicPath) // 获取被publicPath使用的模块if (importers || isHmrAccepted(publicPath, publicPath)) {const hmrBoundaries = new Set<string>()const dirtyFiles = new Set<string>() // 记录被影响了的文件dirtyFiles.add(publicPath)const hasDeadEnd = walkImportChain(publicPath,importers || new Set(),hmrBoundaries,dirtyFiles)// record dirty files - this is used when HMR requests coming in with// timestamp to determine what files need to be force re-fetchedhmrDirtyFilesMap.set(String(timestamp), dirtyFiles)const relativeFile = '/' + slash(path.relative(root, filePath))if (hasDeadEnd) {send({type: 'full-reload',path: publicPath})console.log(chalk.green(`[vite] `) + `page reloaded.`)} else {const boundaries = [...hmrBoundaries]send({type: 'multi',updates: boundaries.map((boundary) => {return {type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',path: boundary,changeSrcPath: publicPath,timestamp}})})}} else {debugHmr(`no importers for ${publicPath}.`)}
})
function walkImportChain(importee: string,importers: Set<string>,hmrBoundaries: Set<string>,dirtyFiles: Set<string>,currentChain: string[] = []
): boolean {if (hmrDeclineSet.has(importee)) {// 文件调用了import.meta.hot.declinereturn true}if (isHmrAccepted(importee, importee)) {// 自己就自己的hmrAcceptanceMap里的话,直接返回了hmrBoundaries.add(importee)dirtyFiles.add(importee)return false}for (const importer of importers) {if (importer.endsWith('.vue') ||// explicitly accepted by this importerisHmrAccepted(importer, importee) ||// importer is a self accepting moduleisHmrAccepted(importer, importer)) {// vue boundaries are considered dirty for the reloadif (importer.endsWith('.vue')) {dirtyFiles.add(importer)}hmrBoundaries.add(importer)currentChain.forEach((file) => dirtyFiles.add(file))} else {const parentImpoters = importerMap.get(importer) // 获取被importer(被当前importee使用的模块)使用的模块if (!parentImpoters) {// dead endreturn true} else if (!currentChain.includes(importer)) {if (walkImportChain(importer,parentImpoters,hmrBoundaries,dirtyFiles,currentChain.concat(importer))) {return true}}}}return false
}
消息类型
send的消息类型定义在 src/hmrPayload.ts
里,针对每种type,浏览器端都会有不同的相应,在src/client/client.ts
中
export type HMRPayload =| ConnectedPayload| UpdatePayload| FullReloadPayload| StyleRemovePayload| SWBustCachePayload| CustomPayload| MultiUpdatePayloadinterface ConnectedPayload {type: 'connected'
}export interface UpdatePayload {type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'path: stringchangeSrcPath: stringtimestamp: number
}interface StyleRemovePayload {type: 'style-remove'path: stringid: string
}interface FullReloadPayload {type: 'full-reload'path: string
}interface SWBustCachePayload {type: 'sw-bust-cache'path: string
}interface CustomPayload {type: 'custom'id: stringcustomData: any
}export interface MultiUpdatePayload {type: 'multi'updates: UpdatePayload[]
}
浏览器端响应
clients.js
除了前面描述的一些功能外,还定义了一些变量和方法用于处理HMR相关的逻辑
const hotModulesMap = new Map<string, HotModule>() // 记录HMR模块相关的信息
初次访问
比如App.vue
中增加了如下的HMR逻辑
import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/App.vue"); // 注册一个HMR模块
_sfc_main.__hmrId = "7a7a37b1";
import.meta.hot.accept((mod)=>{if (!mod)return;const {default: updated, _rerender_only} = mod;if (_rerender_only) {__VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render);} else {__VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated);}
}
);
当import.meta.hot.accept
执行后,hotModulesMap
里会增加一条记录
key: '/src/App.vue'
values: {id: '/src/App.vue',callbacks: [{deps: '/src/App.vue',fn: callback},{deps: 'xxxxxx/xxxx.vue',fn: callback}]
}
响应HMR
服务端发送的消息类型有很多,每种类型都有对应的方法,比如full-reload
会触发页面刷新等等,这里我们主要看下js-update
的时候
switch (payload.type) {...case 'vue-rerender':const templatePath = `${path}?type=template`import(`${templatePath}&t=${timestamp}`).then((m) => {__VUE_HMR_RUNTIME__.rerender(path, m.render)console.log(`[vite] ${path} template updated.`)})breakcase 'style-remove':removeStyle(payload.id)breakcase 'js-update':queueUpdate(updateModule(path, changeSrcPath, timestamp))breakcase 'full-reload':if (path.endsWith('.html')) {const pagePath = location.pathnameif (pagePath === path ||(pagePath.endsWith('/') && pagePath + 'index.html' === path)) {location.reload()}return} else {location.reload()}
}
服务端把hmr有变化的文件目录都发送了过来,fn updateModule
里就是把hotModulesMap里这些文件里注册的所有callback(deps,callback )都拿出来,并重新请求各个depsimport deps
, fn queueUpdate
就是在这么deps重新加载后执行之前对应的callback,到这里一次HMR就完成了
总结
HMR消息类型有多种,以下是多个hmr boundary
类型为js-update
时的一次更新流程图
End
- 本次源码阅读的结论主要是通过阅读源码和百度一些资料得到的,没有经过断点一步一步调试,所以可能会存在理解有偏差的地方,有任何问题都欢迎大家一起讨论
- 阅读过程中建了一个分支,有随手加的一些注释,有需要可以看下
- 自己阅读源码的记录会统一放在这里,包括
single-spa rollup qiankun ...
- 感谢阅读!
vite1.x 热更新(HMR)的实现原理相关推荐
- Webpack 热更新HMR 原理全解析
这是 Webpack 原理分析系列第十篇文章,前文可到公众号[Tecvan]查阅. 一.什么是 HMR HMR 全称 Hot Module Replacement,中文语境通常翻译为模块热更新,它能够 ...
- webpack的热重载/热更新HMR是如何实现的
概念 关键词:热更新/热重载/HMR(Hot Module Replacement) 效果:浏览器的无刷新更新,允许在运行时替换,添加,删除各种模块,而无需进行完全刷新重新加载整个页面 目的:加快开发 ...
- IOS热更新-JSPatch实现原理+Patch现场恢复
关于HotfixPatch 在IOS开发领域,由于Apple严格的审核标准和低效率,IOS应用的发版速度极慢,稍微大型的app发版基本上都在一个月以上,所以代码热更新(HotfixPatch)对于IO ...
- 修改html时webpack热更新,webpack热更新(HMR)
8种机械键盘轴体对比 本人程序员,要买一个写代码的键盘,请问红轴和茶轴怎么选? 一.HMR介绍 在我们开发react应用的时候,在配置了webpack-dev-server的前提下每一次的组件内容修改 ...
- Unity 热更新技术 | (一) 热更新的基本概念原理及主流热更新方案介绍
- webpack 热更新原理解析
一.什么是 HMR HMR 全称 Hot Module Replacement,中文语境通常翻译为模块热更新,它能够在保持页面状态的情况下动态替换资源模块,提供丝滑顺畅的 Web 页面开发体验. 1. ...
- webpack热更新
什么是模热更新?有什么优点 模块热更新是webpack的一个功能,它可以使得代码修改之后,不用刷新浏览器就可以更新. 在应用过程中替换添加删出模块,无需重新加载整个页面,是高级版的自动刷新浏览器. 优 ...
- webpack4.x热更新,自动刷新
模块热替换(Hot Module Replacement) 模块热替换功能会在应用程序运行过程中替换.添加或删除模块,无需重新加载整个页面.主要是通过以下几种方式,来显著加快开发速度: 保留在完全重新 ...
- python flask热更新_客户端python热更新
介绍: 热更新,就是在维持服务不间断的情况下,对软件代码逻辑或配置数据进行更新修复.随着游戏项目引入了脚本语言以后,热更新技术逐渐成为了标配,在我经历过的游戏项目中,无论是服务端还是客户端,版本的更新 ...
最新文章
- php 编译mcrypt,centos 6下编译安装php时安装mcrypt支持库
- Python将彩色图转换为灰度图
- MaxCompute的任务状态和多任务执行
- 《剑指offer》答案整理
- python 3.8.0安卓_Python 3.8.0稳定版正式发布
- Exchange 企业邮件与Windows安全应用 — Exchange 2007 收件人管理
- python之父名言_Python之父:为什么操作符很有用?
- android 滑动tabhost,tabhost左右滑动按钮
- 计算机发展的英语介绍ppt模板,计算机发展跟应用-锐得ppt模板资料.ppt
- 金蝶kis商贸采购单商品代码_金蝶KIS云商贸版(采购模块)常见问题汇总
- 教你一个小技巧给latex表格添加脚注 (非footnote)
- 贝叶斯公式的理解【转】
- 我非英雄,广目无双,我本坏蛋,无限嚣张
- C#.NET发EMAIL的几种方法 MailMessage/SmtpClient/CDO.Message
- Matlab符号运算(符号的创建和简单运算、函数求导、不定积分和定积分、解方程组)代码和解释
- 【SCSS】1300- 这些 SCSS 使用技巧真好用~
- 【转】下一代密码模块安全标准探讨
- 计算机软考网络工程师 查询,计算机软考网络工程师考试成绩查询指南
- nodejs+express搭建小程序后台服务器
- 哈里波特与魔法石pdf_哈里·罗伯茨(CSS)CSS框架的命运与失败