前言
将近一年前自己尝试阅读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 = 200
ctx.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 | MultiUpdatePayload
if (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.ts
watcher.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-fetched
hmrDirtyFilesMap.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.decline
return 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 importer
isHmrAccepted(importer, importee) ||
// importer is a self accepting module
isHmrAccepted(importer, importer)
) {
// vue boundaries are considered dirty for the reload
if (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 end
return 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
| MultiUpdatePayload
interface ConnectedPayload {
type: 'connected'
}
export interface UpdatePayload {
type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
path: string
changeSrcPath: string
timestamp: number
}
interface StyleRemovePayload {
type: 'style-remove'
path: string
id: string
}
interface FullReloadPayload {
type: 'full-reload'
path: string
}
interface SWBustCachePayload {
type: 'sw-bust-cache'
path: string
}
interface CustomPayload {
type: 'custom'
id: string
customData: 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.`)
})
break
case 'style-remove':
removeStyle(payload.id)
break
case 'js-update':
queueUpdate(updateModule(path, changeSrcPath, timestamp))
break
case 'full-reload':
if (path.endsWith('.html')) {
const pagePath = location.pathname
if (
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 ... - 感谢阅读!
|