从零到一,带你彻底搞懂 vite 中的 HMR 原理(源码分析)
前言距离尤大大在微博宣布「vite」的出现,不知不觉间已经过了 2 个月多。当时,「vite」只是支持对 .vue 文件的即时编译和 import 的 rewrite,相应地「Plugin」也没有几个。并且,最初在「GitHub」上「vite」的 slogan 是这样的:—— No-bundle Dev Server for Vue 3 Single-File Components.可以看到,起初
前言
距离尤大大在微博宣布「vite」的出现,不知不觉间已经过了 2 个月多。
当时,「vite」只是支持对 .vue 文件的即时编译和 import
的 rewrite
,相应地「Plugin」也没有几个。并且,最初在「GitHub」上「vite」的 slogan 是这样的:
—— No-bundle Dev Server for Vue 3 Single-File Components.
可以看到,起初介绍「vite」是一个不需要打包的开发阶段的服务器。但是,现在再回首,这句 slogan 已经消失了,而「vite」也已经处于 「beta」 阶段。并且,不仅仅是一个开发阶段的服务器这么简单。相应地也实现了很多「Feature」,例如:Web Assembly、JSX
、CSS Pre-processors、Dev Server Proxy 等等。
有兴趣了解这些「Feature」的同学,可以移步GitHub自行阅读
这两个月的时间,「vite」发展的劲头是非(xue)常(bu)猛(dong)的。并且,也出现了很多关于「vite」的文章,可以说是:“ 如雨后春笋般,络绎不绝 ”。
那么,作为一名「Vue」爱好者,我同样对「vite」充满了好奇。所以,回到本次文章,我会先浅析 webpack-dev-server
的「HMR」,然后再循序渐进地讲解「vite」在「HMR」这个过程做了什么。
Webpack 的 HMR 过程
提及「HMR」,不可避免地是会想起现在我们家喻户晓的 webpack-dev-server
中的「HMR」。所以,我们先来了解一番webpack-dev-server
的「HMR」。
首先,我们先对「HMR」建立一个基础的认知。「HMR」 全称即 Hot Module Replacement。相比较「live load」,它具有以下优点:
- 可以实现局部更新,避免多余的资源请求,提高开发效率
- 在更新的时候可以保存应用原有状态
- 在代码修改和页面更新方面,实现所见即所得
而在 webpack-dev-server
中实现「HMR」的核心就是 HotModuleReplacementPlugin
,它是「Webpack」内置的「Plugin」。在我们平常开发中,之所以改一个文件,例如 .vue
文件,会触发「HMR」,是因为在 vue-loader
中已经内置了使用 HotModuleReplacementPlugin
的逻辑。它看起来会是这样
- Helloworld.vue
<template>
<div>hello world</div>
</template>
<script lang="ts">
import { Vue, Component } from 'vue-property-decorator'
@Component
export default class Helloworld extends Vue() {}
</script>
- main.js(手动实现「HMR」效果)
import Vue from 'vue'
import HelloWorld from '_c/HelloWorld'
if (module.hot) {
module.hot.accept('_c/HelloWorld', ()=>{
// 拉取更新过的 HelloWorld.vue 文件
})
}
new Vue({
el: '#app',
template: '<HelloWorld/>'
component: { HelloWorld }
})
那么,这个就是 webpack-dev-server
实现「HMR」的本质吗?显然不是,上面说的只是,如果你要通过 webpack-dev-server
实现「HMR」,你可以这么写来实现。
如果究其底层实现,是有两个关键的点:
1.与本地服务器建立「socket」连接,注册 hash
和 ok
两个事件,发生文件修改时,给客户端推送 hash
事件。客户端根据 hash
事件中返回的参数来拉取更新后的文件。
2.HotModuleReplacementPlugin
会在文件修改后,生成两个文件,用于被客户端拉取使用。例如:
hash.hot-update.json
{
"c": {
"chunkname": true
},
"h": "d69324ef62c3872485a2"
}
chunkname.d69324ef62c3872485a2.hot-update.js,这里的 chunkname
即上面 c
中对于 key
。
webpackHotUpdate("main",{
"./src/test.js":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval(....)
})
})
当然,在这之前还会涉及到对原模块代码的注入,让它具备拉取文件的能力。而这其中实现的细节就不去扣了,要不然有点喧兵夺主的感觉。
基于 native ES Module 的 devServer
基于 native ES Module 的 devServer 是「vite」实现「HMR」的重要一环。总体来说,它会做这么两件事:
- 初始化本地服务器
- 加载并执行对应的
Plugin
,例如sourceMapPlugin
、moduleRewritePlugin
、htmlRewritePlugin
等等。
所以,本质上,devServer 做的最重要的一件事就是加载执行 Plugin
。目前,「vite」总共具备了 11 种 Plugin
。
这里大致列举几点 Plugin
会做:
- 拦截请求,处理「ES Module」语法相关的代码,转化为浏览器可识别的「ES Module」语法,例如第三方模块的
import
转化为/@module/vue.js
- 对
.ts
、.vue
进行即时的编译以及sass
或less
的预编译 - 建立模块间的导入导出关系,即
importeeMap
和客户端建立socket
连接,用于实现「HMR」
这里就列举 devServer 几个常见的
Plugin
需要做的事,至于其他像wasmPlugin
、webWorkerPlugin
之类的Plugin
会做些什么,有兴趣的同学可以自行去了解。
然后,我们再从代码地角度看看它是怎么实现我们上述所说的:
1.首先,我们执行 vite
命令.实际上是运行 cli.js 这个文件,这里我摘取了其中核心的逻辑:
(async () => {
const { help, h, mode, m, version, v } = argv
...
const envMode = mode || m || defaultMode
const options = await resolveOptions(envMode)
// 开发环境下,我们会命中 runServer
if (!options.command || options.command === 'serve') {
runServe(options)
} else if (options.command === 'build') {
runBuild(options)
} else if (options.command === 'optimize') {
runOptimize(options)
} else {
console.error(chalk.red(`unknown command: ${options.command}`))
process.exit(1)
}
})()
async function runServe(options: UserConfig) {
// 在 createServer() 的时候会对 HRM、serverConfig 之类的进行初始化
const server = require('./server').createServer(options)
...
}
可以看到,在自执行函数中,我们会命中 runServer()
的逻辑,而它的核心是调用 server.js 文件中的 createServer()
。
createServer
方法:
export function createServer(config: ServerConfig): Server {
const {
...,
enableEsbuild = true
} = config
const app = new Koa<State, Context>()
const server = resolveServer(config, app.callback())
const watcher = chokidar.watch(root, {
ignored: [/\bnode_modules\b/, /\b\.git\b/]
}) as HMRWatcher
const resolver = createResolver(root, resolvers, alias)
const context: ServerPluginContext = {
...
watcher
...
}
app.use((ctx, next) => {
Object.assign(ctx, context)
ctx.read = cachedRead.bind(null, ctx)
return next()
})
const resolvedPlugins = [
...,
moduleRewritePlugin,
hmrPlugin,
...
]
// 核心逻辑执行 hmrPlugin
resolvedPlugins.forEach((m) => m && m(context))
const listen = server.listen.bind(server)
server.listen = (async (port: number, ...args: any[]) => {
...
}) as any
return server
}
createServer
方法做了这么几件事:
- 创建一个
koa
实例 - 创建监听除了 node_modules 之外的文件的
watcher
,并传入context
中 - 将
context
上下文传入并调用每一个Plugin
到这里,「vite」的 devServer 的创建过程就已经完成。那么,接下来我们去领略一番属于「vite」的「HMR」过程!
vite 的 HMR 过程
在「vite」中「HMR」的实现是以 serverPluginHmr
这个 Plugin
为核心实现。这里我们以 .vue
文件的修改触发的「HMR」为例,这个过程会涉及三个 Plugin
:serverPluginHtml
、serverPluginHmr
、serverPluginVue
,这个过程看起来会是这样:
serverPluginHtml
从前面的流程图可以看到,首先是 serverPluginHtml
这个 Plugin
向 index.html 中注入了获取 hmr
模块的代码:
export const htmlRewritePlugin: ServerPlugin = ({
root,
app,
watcher,
resolver,
config
}) => {
const devInjectionCode =
`\n<script type="module">\n` +
`import "${hmrClientPublicPath}"\n` +
`window.process = { env: { NODE_ENV: ${JSON.stringify(
config.mode || 'development'
)} }}\n` +
`</script>\n`
const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/
async function rewriteHtml(importer: string, html: string) {
...
html = html!.replace(scriptRE, (matched, openTag, script) => {
...
return injectScriptToHtml(html, devInjectionCode)
}
app.use(async (ctx, next) => {
await next()
...
if (ctx.response.is('html') && ctx.body) {
const importer = ctx.path
const html = await readBody(ctx.body)
if (rewriteHtmlPluginCache.has(html)) {
...
} else {
if (!html) return
// 在这里给 index.html 文件注入代码块
ctx.body = await rewriteHtml(importer, html)
rewriteHtmlPluginCache.set(html, ctx.body)
}
return
}
})
}
所以,当我们访问一个 「vite」 启动的项目的时候,我们会在「network」中看到服务器返回给我们的 index.html 中的代码会多了这么一段代码:
<script type="module">
import "/vite/hmr"
window.process = { env: { NODE_ENV: "development" }}
</script>
而这一段代码,也是确保我们后续正常触发「HMR」的关键点。因为,在这里浏览器会向服务器发送请求获取 vite/hmr
模块,然后,在 serverPluginHmr
中会拦截 ctx.path===‘/vite/hmr’
的请求,建立 socket
连接。那么,接下来我们看看 serverPluginHmr
是进行这些过程的。
serverPluginHmr
上面我们说了 serverPluginHmr
它会劫持导入 /vite/hmr
的请求,然后返回 client.js
文件。所以,我们分点来细致化地分析这个过程:
1.读取 cliten.js
文件,劫持导入 /vite/hmr
的请求
export const hmrClientFilePath = path.resolve(
__dirname,
'../../client/client.js'
)
export const hmrClientPublicPath = `/vite/hmr`
const hmrClient = fs
.readFileSync(hmrClientFilePath, 'utf-8')
.replace(`__SW_ENABLED__`, String(!!config.serviceWorker))
app.use(async (ctx, next) => {
if (ctx.path === hmrClientPublicPath) {
ctx.type = 'js'
ctx.status = 200
ctx.body = hmrClient.replace(`__PORT__`, ctx.port.toString())
} else {
...
}
})
这里通过 readFileSync()
读取 client.js
文件,然后分别 replace
读取到的文件内容(字符串),一个是用于判断是否支持 serviceWorker
,另一个用于建立 socket
连接时的端口设置。
2.定义 send
方法,并赋值给 watcher.send
,用于其他 Plugin
在热更新时向浏览器推送更新信息:
const send = (watcher.send = (payload: HMRPayload) => {
const stringified = JSON.stringify(payload, null, 2)
debugHmr(`update: ${stringified}`)
wss.clients.forEach((client) => {
// OPEN 表示已经建立连接
if (client.readyState === WebSocket.OPEN) {
client.send(stringified)
}
})
})
3.client.js,它会做这两件事:
- 建立和服务器的
socket
连接,监听message
事件,拿到服务器推送的data
,例如我们只修改.vue
文件,它的data
类型定义会是这样:
export interface UpdatePayload {
type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
path: string
changeSrcPath: string
timestamp: number
}
- 对不同的
data.type
执行不同的逻辑,目前存在type
有:vue-reload
、vue-rerender
、style-update
、style-remove
、js-update
、custom
、full-reload
。本次我们只分析vue-rerender
的逻辑,其实现的核心代码如下:
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
const socketUrl = `${socketProtocol}://${location.hostname}:${__PORT__}`
const socket = new WebSocket(socketUrl, 'vite-hmr')
// 监听 message 事件,拿到服务端推送的 data
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
if (payload.type === 'multi') {
payload.updates.forEach(handleMessage)
} else {
// 通常情况下会命中这个逻辑
handleMessage(payload)
}
})
async function handleMessage(payload: HMRPayload) {
const { path, changeSrcPath, timestamp } = payload as UpdatePayload
...
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
...
}
}
其实,这个过程还用到了
serviceWorker
做一些处理,但是看了下提交记录是wip
,所以这里就没有分析这个逻辑,有兴趣的同学可以自己了解。
serverPluginVue
前面,我们讲了 serverPluginHtml
和 serverPluginHmr
在 HMR 过程会做的一些前期准备。然后,我们这次分析的修改 .vue
文件触发的「HMR」逻辑,它的开始是在
serverPluginVue
。
首先,它会解析 .vue
文件,做一些 compiler
处理,然后通过 watcher
监听 .vue
文件的修改:
watcher.on('change', (file) => {
if (file.endsWith('.vue')) {
handleVueReload(file)
}
})
可以看到在 change
事件的回调中调用了 handleVueReload()
,针对我们这个 case
,它会是这样:
const handleVueReload = (watcher.handleVueReload = async (
filePath: string,
timestamp: number = Date.now(),
content?: string
) => {
const publicPath = resolver.fileToRequest(filePath)
const cacheEntry = vueCache.get(filePath)
const { send } = watcher
...
let needRerender = false
...
if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
needRerender = true
}
...
if (needRerender) {
send({
type: 'vue-rerender',
path: publicPath,
changeSrcPath: publicPath,
timestamp
})
}
})
handleVueReload()
它会针对 .vue
文件中,不同情况走不同的逻辑。这里,我们只是修改了 .vue
文件,给它加了一行代码,那么此时就会命中 isEqualBlock()
为 false
的逻辑,所以 needRerender
为 true
,最终通过 send()
方法向浏览器推送 type
为 vue-rerender
以及携带修改的文件路径的信息。然后,我们前面 client.js
中监听 message
的地方就会拿到对应的 data
,再通过 import
发起获取该模块的请求。
小结
到这里,整个「vite」实现「HMR」的逻辑已经分析结束了。当然,这次只是针对 .vue
文件的修改来分析整个 HMR 的逻辑,相应地还有 .js
、.css
的文件的修改触发的 HMR 的逻辑,但是,可以说的是只要理解这个过程是如何进行的,那么每一个 case
的分析,也只是依葫芦画瓢。
并且,其实在「HMR」的过程中还有一些辅助变量和概念,例如 hrmBoundaries
、import chain
、child importer
等等,它们都是用于帮助更好地进行 HMR
处理。所以,这里我就没有提及这些。有兴趣的同学可以自己去看源码中的讲解这些辅助变量和概念的意义。
写在最后
其实,在当初尤大大发微博的时候,我就想着写一篇关于「vite」源码分析的文章。这两个月,我也经历一些起起伏伏,所以,一直到现在才开始交上这份答卷。但是,现在再看「vite」源码,已经不是仅仅 devServer 这么简单了,所以这次只分析了「HMR」这个点的实现,其他方面后续有时间应该会继续写其他方面,例如 rewrite
、bundle
、compiler
等等。最后,文章中可能会存在表述不对或不好的地方,欢迎各位同学提 Issue。
写作不易,如果你觉得有收获的话,可以帅气三连击!!!
更多推荐
所有评论(0)