什么是hmr?
hot-module-replacement,热更新模块,简称hmr
简单来说,热更新就是: 当我们开发时修改代码后,不需要刷新浏览器,即可把改动展示在浏览器(以下都称为客户端了) 狭义的热更新指的是 bundler框架(如webpack、vite等)监听代码变动,实时更新到客户端 广义的热更新在狭义的基础上,增加了一个步骤:客户端接受新模块代码后,调用bundler框架的hmr api,自身决定如何执行新模块代码
我们说的hmr,普遍指的是广义热更新
hmr的基本原理
ok,现在让我们来思考一下,热更新大概是如何实现的 (同学们回答~)
我认为,热更新主要经过以下步骤
- 监听开发者代码变化(比如 onChange, onAdd, onDelete 等事件)
- 把变化后的代码构建好之后,使用websocket向客户端发送更新信息
- 客户端接受到更新信息,向服务端请求更新模块的代码
- 客户端派发更新模块代码
以上步骤不一定是对的,我们稍后看完源码再回来看看是否正确
请注意,这些步骤里面我没有提到任何的前端框架,React 或 Vue, 热更新并不是打包框架一个人的事,前端框架需要提供相应的热更新库,比如 React 的 react-refresh 打包框架只是把编译后的新代码发送给客户端,至于客户端如何利用这些代码,打包框架就管不到了
Dan的这个文章可以帮助我们更好的理解上面这段话
硬核解析核心源码
以下只会截取核心代码
如何监听代码变化?
很简单,监听到代码变化后,去执行hmr相关逻辑
监听到代码变化后,做了什么?
如何编译代码?如何判定更新边界?如何把新的代码发送给客户端?
经过这个这三个处理后,vite把改变的模块文件筛选出来了,接下来准备更新这些模块 如何更新这些模块,就有讲究了,假设修改的文件,层级比较深,莫非要把它涉及到的所有模块都全部更新?这样肯定会很慢,慢的话,vite还能叫vite吗?🤪 所以vite需要计算出模块的更新边界,也就是影响范围最小的那个模块文件,从边界模块向上的导入者,不会收到热更新的消息,这样可以极大加速热更新的速度
function propagateUpdate(
node: ModuleNode,
traversedModules: Set<ModuleNode>,
boundaries: { boundary: ModuleNode; acceptedVia: ModuleNode }[],
currentChain: ModuleNode[] = [node],
): HasDeadEnd {
// 如果当前模块已经被递归过了,直接返回
if (traversedModules.has(node)) {
return false
}
// 否则,把当前模块添加到已递归的模块中
traversedModules.add(node)
if (node.isSelfAccepting) {
boundaries.push({ boundary: node, acceptedVia: node })
const result = isNodeWithinCircularImports(node, currentChain)
if (result) return result
return false
}
// A partially accepted module with no importers is considered self accepting,
// because the deal is "there are parts of myself I can't self accept if they
// are used outside of me".
// Also, the imported module (this one) must be updated before the importers,
// so that they do get the fresh imported module when/if they are reloaded.
// impoter 指的是导入者,比如 在A文件中import B文件,则A被称为importer,B被称为importee
// 没有importer的模块,被视为 self-accepting,自我接受模块
// 如果模块的某些部分被外部使用,则不是 self-accepting
// 另外,被导入的模块(也就是此模块),必须在impoter之前更新
// 这样的话,当这些模块重新加载的时候,也可以获取到最新的importee
if (node.acceptedHmrExports) {
boundaries.push({ boundary: node, acceptedVia: node })
const result = isNodeWithinCircularImports(node, currentChain)
if (result) return result
} else if (!node.importers.size) {
return true
}
for (const importer of node.importers) {
const subChain = currentChain.concat(importer)
if (importer.acceptedHmrDeps.has(node)) {
boundaries.push({ boundary: importer, acceptedVia: node })
const result = isNodeWithinCircularImports(importer, subChain)
if (result) return result
continue
}
if (node.id && node.acceptedHmrExports && importer.importedBindings) {
const importedBindingsFromNode = importer.importedBindings.get(node.id)
if (
importedBindingsFromNode &&
areAllImportsAccepted(importedBindingsFromNode, node.acceptedHmrExports)
) {
continue
}
}
if (
!currentChain.includes(importer) &&
propagateUpdate(importer, traversedModules, boundaries, subChain)
) {
return true
}
}
return false
}
// 处理循环导入
/**
* Check importers recursively if it's an import loop. An accepted module within
* an import loop cannot recover its execution order and should be reloaded.
*
* @param node The node that accepts HMR and is a boundary
* @param nodeChain The chain of nodes/imports that lead to the node.
* (The last node in the chain imports the `node` parameter)
* @param currentChain The current chain tracked from the `node` parameter
*/
function isNodeWithinCircularImports(
node: ModuleNode,
nodeChain: ModuleNode[],
currentChain: ModuleNode[] = [node],
): HasDeadEnd {
// To help visualize how each parameters work, imagine this import graph:
//
// A -> B -> C -> ACCEPTED -> D -> E -> NODE
// ^--------------------------|
//
// ACCEPTED: the node that accepts HMR. the `node` parameter.
// NODE : the initial node that triggered this HMR.
//
// This function will return true in the above graph, which:
// `node` : ACCEPTED
// `nodeChain` : [NODE, E, D, ACCEPTED]
// `currentChain` : [ACCEPTED, C, B]
//
// It works by checking if any `node` importers are within `nodeChain`, which
// means there's an import loop with a HMR-accepted module in it.
for (const importer of node.importers) {
// Node may import itself which is safe
if (importer === node) continue
// Check circular imports
const importerIndex = nodeChain.indexOf(importer)
if (importerIndex > -1) {
// Log extra debug information so users can fix and remove the circular imports
if (debugHmr) {
// Following explanation above:
// `importer` : E
// `currentChain` reversed : [B, C, ACCEPTED]
// `nodeChain` sliced & reversed : [D, E]
// Combined : [E, B, C, ACCEPTED, D, E]
const importChain = [
importer,
...[...currentChain].reverse(),
...nodeChain.slice(importerIndex, -1).reverse(),
]
debugHmr(
colors.yellow(`circular imports detected: `) +
importChain.map((m) => colors.dim(m.url)).join(' -> '),
)
}
return 'circular imports'
}
// Continue recursively
if (!currentChain.includes(importer)) {
const result = isNodeWithinCircularImports(
importer,
nodeChain,
currentChain.concat(importer),
)
if (result) return result
}
}
return false
}
经过一系列的计算后,得到了优化后的待更新的模块信息,包括模块的uri、类型、更新时间戳等,然后使用websocket发送消息告诉客户端 至此,HMR的服务端功能就完成了,难吗?说难也难,说不难也不难
客户端如何接受热更新消息?
给socket注册一个监听事件即可
客户端接受到更新消息后,做了什么?
请求更新的模块文件,然后执行
// !!! 这个函数是闭包
async function fetchUpdate({
path,
acceptedPath,
timestamp,
explicitImportRequired,
}: Update) {
const mod = hotModulesMap.get(path)
if (!mod) {
// In a code-splitting project,
// it is common that the hot-updating module is not loaded yet.
// https://github.com/vitejs/vite/issues/721
return
}
let fetchedModule: ModuleNamespace | undefined
const isSelfUpdate = path === acceptedPath
// determine the qualified callbacks before we re-import the modules
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(
// suppresses dynamic import warning
/* @vite-ignore */
`${base +
acceptedPathWithoutQuery.slice(1)
}?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
query ? `&${query}` : ''
}`
)
} catch (e) {
warnFailedFetch(e, acceptedPath)
}
}
return () => {
for (const { deps, fn } of qualifiedCallbacks) {
// 这里才是真的执行了fetchedModule
fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)))
}
const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}`
console.debug(`[vite] hot updated: ${loggedPath}`)
}
}
至此,客户端的hmr就执行完了。 vite的整个hmr就执行完了 小朋友,你是否有一些问号?嗯?这就完了?咱们的页面是怎么更新的呀?也妹看到有相应的处理呀 是的,接下来的任务,就交给框架的热更新了,我用 react-refresh 来举例
我们的react项目,都引入了 @vitejs/plugin-react 这个插件,毋庸置疑 这个插件中,就集成了 react-refresh 功能
react 是如何热更新的?
先看看这个https://github.com/facebook/react/issues/16604#issuecomment-528663101
ok,然后我们再理解以下,如何把react-refresh集成到vite的插件中
async transform(code, id, options) {
if (id.includes('/node_modules/')) return
// ... unimportant code ...
const babel = await loadBabel()
const result = await babel.transformAsync(code, {
// ...
})
if (result) {
let code = result.code!
if (useFastRefresh && refreshContentRE.test(code)) {
// 核心:给每个需要热更新的文件,注入运行时代码
code = addRefreshWrapper(code, id)
}
return { code, map: result.map }
}
},
经过注入代码后,我们的每个jsx文件,都会变成这样:
header里面的代码呢,就是把模块注册到 react-refresh 中,这样才能有热更新功能 footer里面的代码呢,就是
- 执行 react-refresh 热更新
- 在 refresh runtime 中注册新的模块热更新
- 调用vite的hmr client api,让当前模块可以成为更新边界
- 校验当前模块的热更新是否生效,若不生效,则向上传导边界
在上面,我不是提了一嘴吗 执行 fethedModule,其实就是执行了一个jsx文件,我们现在再看看
带hmr的jsx
这张图, 可以看到 App 这个组件,并没有执行,而是交给了 refresh, 这也是为什么热更新可以做到局部更新,多亏 react-refresh! 关于 react-refresh 的原理,比较复杂,不赘述,简单来说,就是把 旧模块 和 新模块 合并了,保留了组件的state
footer里面调用vite的hmr api也很重要,如果不加这个api,这个文件就永远不会成为更新边界,这会导致更新的范围更广,因为更新边界向上传导了
无前端框架 vite如何热更新?
调用vite的hmr api,然后自己编码控制热更新逻辑
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(counter)
}
if (import.meta.hot) {
// 如果模块文件中没有 `import.meta.hot.aceept`, 则模块没有热更新能力
// 即不会成为更新边界
import.meta.hot.accept((newModule) => {
// 当保存文件时:
// newModule: { default: setupCounter }
// 开发者自己编码,来控制当前模块如何热更新
// 如果没有热更新逻辑,hmr不会无感知更新页面
})
}
与webpack热更新对比
vite与webpack的热更新,在流程上基本上差不多,但由于vite dev是基于浏览器的esm,而webpack是构建完成后交由浏览器,所以在「客户端请求热更新文件」这里,差别较大 梳理一下webpack的热更新流程
- 监听开发者代码变化
- webpack构建,然后把热更新相关的信息(一个json文件,包含构建后的文件hash值)发送给客户端
- 客户端接收到热更新信息后,向服务端请求更新的代码块
- 客户端执行更新代码
很简陋,但也很简单,实际上不止这4个步骤,我只把重要的部分挑出来了
webpack的源码就不探究了,我不感兴趣 😌 咱们实操看看是怎么个事
pnpx create-react-app --template typescript
pnpm eject
pnpm start
{"c":["main"],"r":[],"m":[]} => {chunkIds, removedChunks, removedModules,}
上文中,我们提到了 现在我们来看看,具体的区别在哪里
- vite是如何加载更新模块的?而webpack又是如何加载的?
已经到这里了,大家应该都知道 vite 是如何加载更新模块了吧?
webpack呢? 可以看到,先是通过jsonp获取到代码chunk,然后使用webpack_require导入并执行更新后的chunk
最后我们看看更新代码长什么样 跟vite基本上一致,很大部分原因是因为客户端热更新 是前端框架来主动集成的
最后
至此,基本上把hmr的主流程讲完了,当然,还有很多值得深究的地方,时间原因,不再这里赘述 但是我们也可以浅浅地做一些课后思考:
- css的热更新怎么做?
- 如果热更新出错,如何兜底?
- 模块依赖图是怎么实现的?
- 热更新、增量更新,能否应用在生产环境?
- 为什么webpack使用jsonp获取更新模块,而vite使用dymanic import?
- 执行了更新模块的代码后,之前的老代码去哪里了?老代码还会再执行吗?