Dreaming Cat's

HMR 原理概述

§概述

HMR 全称 Hot Module Replacement,中文通常翻译为模块热更新,它能够在保持页面状态的情况下动态替换资源模块,提供更好的开发体验。它最初由 Webpack 设计实现,现在已经是前端工程的必备功能。

主要还是因为写文章的时候,更新一下就刷新一次太麻烦了,就想着整个自动刷信的东西,后来就想到,都自动刷新了,何不一步到位折腾个 HMR 玩玩。于是就看了看源码,上手改了改就折腾出了这个,本文主要就是介绍下我这里是怎么实现 HMR 的,以及和 Webpack 的实现有何不同。

§基本原理

本身博客就有调试用的虚拟文件服务器,HMR 功能只需要在这上面扩展即可。现在的虚拟文件服务器,其流程是这样的:

  1. 编译完成
  2. 浏览器访问网址
  3. 静态服务器从内存中找到对应文件
  4. 向网页端发送文件内容
  5. 文件变更后重新编译
  6. 浏览器刷信页面,回到第二步

而添加了 HMR 之后,整个流程是这样的:

  1. 编译时往前端网页中注入 HMR 客户端入口代码
  2. 浏览器访问网址
  3. 静态服务器从内存中找到对应文件,并生成注入代码一并返回浏览器
  4. 浏览器中初始化页面,并与服务器建立 WebSocket 连接
  5. 文件变更后重新编译
  6. 计算新生成的文件和旧文件的差异
  7. 将所有差异聚合成数据发送至浏览器
  8. 浏览器接收数据后执行代码变更逻辑

§代码结构

从上面的流程中可以看出,和之前的静态服务器相比多了三个部分:

  1. 负责前后端通信的 WebSocket 服务
  2. 编译端用于计算编译前后文件变更服务
  3. 注入浏览器端的运行时代码

§WebSocket 服务

因为 HMR 需要后端主动通知前端变更,所以只能使用 WebSocket 服务来建立连接,由于之前的静态服务器使用 Koa 来完成,这里也就直接用koa-websocket来完成 WS 服务了。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • import Koa from 'koa';
  • import Ws from 'koa-websocket';
  • import type { WebSocket } from 'ws';
  • const app = Ws(new Koa());
  • const sockets: WebSocket[] = [];
  • declare const remove: (sockets: WebSocket[], socket: WebSocket) => void;
  • app.ws.server?.on('connection', (socket: WebSocket) => {
  • sockets.push(socket);
  • socket.on('close', () => {
  • remove(sockets, socket);
  • });
  • });
  • export function broadcast(data: any) {
  • for (const socket of sockets) {
  • socket.send(JSON.stringify(data));
  • }
  • }

其中,broadcast函数即为向前端广播文件变更的方法。

§处理文件变更

Webpack 为了兼容各种不同的情况,使用了All in JS的模式,即是指所有文件最终都会被转换为 JS 文件处理。在调试时,对每个文件都单独给予独立的编号用于识别,在文件变更触发重新编译时,Webpack 将会使用增量编译仅仅只编译当前文件模块,然后按照首次编译生成的编码将其发送至前端,前端根据编号再去执行更新。

这样写的好处是可以极大的兼容各种情况,但是并不符合我的博客的情况。

从前文我的博客的架构里可以看出,我的博客编译流程并不是一次性编译完成的,其中实际上是经过了很多小的编译打包,然后运行打包好的代码,最后将这些结果整合起来的,定制化程度太高,并不好直接借用 Webpack 的模式。所以我这里只能对比编译前后所有成品文件的异同,然后将不同的文件整合发送至前端,然后再由前端来判断如何更新。由于博客网站的容量小,这样的工作量是可以接受的,对比 Webpack 的工作流来说反而更简单,因为这种模式并不需要介入编译流程,只需要比肩编译结果的文件即可。

§注入浏览器端运行时

这个运行时包含了诸如建立 WebSocket 连接、接受以及更新页面等等功能,浏览器端的所有更新行为都在这里完成。

§注入文件的原理

不管再怎么奇怪的功能,在前端它总归是使用 JS 来实现的,所以这里注入入口其实就是给所有 HTML 文件添加一个script标签,这个标签的src指向这个运行时的地址(这个地址一般是虚拟)。然后浏览器读取和加载完整网页后,将会向服务器请求这个地址的文件,此时再在服务器中返回准备好的运行时文件内容即可。

前端渲染模板:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • import React from 'react';
  • const HMRClientScriptPath = '/__internal/dev/hmr_client.js';
  • interface Props {
  • hmr: boolean;
  • }
  • // 公共 layout
  • export default function Layout(props: Props) {
  • return (
  • <html lang='zh-cmn-Hans-CN'>
  • <head>
  • {/* code.. */}
  • {props.hmr ? <script type='text/javascript' src={HMRClientScriptPath} /> : ''}
  • {/* code.. */}
  • </head>
  • <body>
  • {/* code.. */}
  • </body>
  • </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
  • import { build } from 'esbuild';
  • import { ParameterizedContext, Next } from 'koa';
  • const HMRClientScriptPath = '/__internal/dev/hmr_client.js';
  • export function transformServe() {
  • return async (ctx: ParameterizedContext, next: Next) => {
  • if (ctx.path !== HMRClientScriptPath) {
  • return next();
  • }
  • // 运行时代码真实文件路径
  • const clientPath = 'xxxx/client.ts';
  • const { outputFiles: files } = await build({
  • entryPoints: [clientPath],
  • bundle: true,
  • minify: false,
  • sourcemap: 'inline',
  • write: false,
  • format: 'iife',
  • outdir: '/',
  • platform: 'browser',
  • logLevel: 'warning',
  • charset: 'utf8',
  • });
  • ctx.body = files[0].contents;
  • };
  • }

通过这样的方式,每当浏览器端加载页面时,就都会请求运行时代码,在请求之后,服务器也将会实时的把文件编译完成并返回前端,这样前端每次都能获取最新的运行时代码。

§WebSocket 前端服务

前端的 WS 服务就只有两个操作,初始化和等待后端数据。初始化很好理解,就是创建 WS 服务并绑定更新数据的回调。这个前端有现成的WebSocket类,new一个就成,这里不再赘述,重点还是在更新数据的具体操作上。

  • 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
  • declare const getSocketUrl: () => string;
  • if ('WebSocket' in window) {
  • console.log('Dev Server is running...');
  • new WebSocket(getSocketUrl(), 'blog-dev-server')
  • .addEventListener('message', (event: MessageEvent<string>) => {
  • const updates = JSON.parse(event.data);
  • for (const data of updates) {
  • switch (data.kind) {
  • case 'HTML': {
  • // 更新 HTML
  • break;
  • }
  • case 'CSS': {
  • // 更新 CSS
  • break;
  • }
  • case 'JS': {
  • // 更新 JS
  • break;
  • }
  • default: {
  • console.log(`Unknown update kind: ${(data as any).kind}`);
  • break;
  • }
  • }
  • }
  • });
  • } else {
  • console.log('WebSocket is not supported.');
  • }

§前端更新流程

在前文中我们可以看到,我这里是将所有文件区分开来更新的,针对不同的文件有不同的更新策略。另外,在 Webpack 构成的项目中,不管路由在哪里,都靠着文件 HASH 编码来更新的,所以前端只需要接受所有更新,然后去匹配不同的 HASH 标记的模块就行。而在这里不行,因为没有 HASH 做标记,所以首先需要判断这个更新是不是属于当前页面,不是当前页面的文件就要抛弃。

§CSS 更新

CSS 文件更新是最简单的,只需要替换新的link标签即可。但是注意要在链接后面加上新的 hash 编码,这样浏览器才会去读新的文件内容,而不是从缓存里拿。所以它的更新代码大约可以是:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • function reloadCSS(src: string) {
  • const link = document.querySelector(`link[href|="${src}"]`);
  • if (!link) {
  • return;
  • }
  • const newLink = link.cloneNode() as Element;
  • const newHref = `${(link.getAttribute('href') ?? '').split('?')[0]}?${Date.now()}`;
  • newLink.setAttribute('href', newHref);
  • newLink.addEventListener('load', () => link.parentNode?.removeChild(link));
  • link.parentNode?.insertBefore(newLink, link.nextSibling);
  • }

按照 href 的属性搜索当前页面是否存在此 CSS 元素,若是不存在则直接退出,若是存在,则复制该节点,然后给 href 后添加唯一标识,随后将其添加进文档流,最后当元素加载完成,卸载旧节点。

§HTML 更新

HTML 的更新要稍微复杂一些,因为还直接涉及了 JS 页面交互的问题。我本来的想法是按照两次渲染输出的 VDOM 按照 React 官方的 Diff 算法来得出不同,再在前端部署一个react-dom进行渲染的。结果发现这个方法过于复杂,前端运行时再带上react-dom就太重了,并不符合轻量级博客的定位。

于是我想到,博客几乎全是静态网页的文章,现在为止也就是目录导航之类的轻交互内容,何不直接替换body标签内容呢?能解决问题的方法就是好方法,更何况这只在开发阶段才会用,正式上线是没有 HMR 的。于是按照这个思路,后端只需要对比前后 body 标签内容是否变更即可,随后将 body 的内容发送至前端,前端对其直接替换。所以 HTML 的代码大约是:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • function reloadHTML(content: string) {
  • const element = document.querySelector('body');
  • if (element) {
  • element.innerHTML = content;
  • }
  • }

但是,这还没完。这么直接替换,页面元素看起来都被更新了,但是有交互的页面元素并未从内存中卸载,并且新呈现的元素没有经过各种交互的初始化,它们是没有交互动作的。这里就牵扯到 JS 的更新了,请看下一节。

§JS 更新

JS 模块的更新麻烦的部分有两个,其一是网页端加载 JS 的机制问题,另外则是 JS 内存回收的问题。

第一点很好理解,浏览器加载 JS 和 CSS 是不同的,CSS 每次添加个新的link元素,网页都会重新读取并加载;而 JS 不同,只要scriptsrc内指向的地址不变,网页是不会运行第二遍的,所以这里就需要前端把代码手动运行一遍。这个很好做,只需要使用(0, eval)(code)就可以实时运行了。

第二点的问题主要是,JS 运行是有有很多状态量存在内存中的,所以每次更新都必须要先将这些状态清空,保证这部分内存被回收,然后重新加载模块时,再次进行初始化,生成新的状态量。所以这里我设计了全局的Module类,由它来统一调度所有的脚本模块,每个脚本模块都必须含有装载install)和卸载uninstall),它们的类型是这样的:

  • 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
  • /** 卸载函数 */
  • type unActive = () => void;
  • /** 脚本模块 */
  • interface ScriptModule {
  • /** 当前模块地址 */
  • currentScript: string;
  • /**
  • * 启动模块
  • * - 返回卸载模块函数
  • */
  • active(): unActive;
  • /** 是否重载整个脚本 */
  • shouldReload?(): boolean;
  • }
  • /** 模块装载器 */
  • interface ModuleLoader {
  • /** 安装模块 */
  • install(module: ScriptModule): void;
  • /** 卸载模块 */
  • uninstall(src?: string): void;
  • /** 重载模块 */
  • reload(): void;
  • }
  • /** 全局装载器 */
  • declare const Module: ModuleLoader;
  • /** 当前模块装载函数 */
  • declare const active: () => unActive;
  • /** 获取当前脚本地址 */
  • declare function getCurrentScriptSrc(): string;
  • // 模块注册
  • if (process.env.NODE_ENV === 'development') {
  • Module.install({
  • currentScript: getCurrentScriptSrc(),
  • active,
  • });
  • }

在调试模式下,每个模块都必须将自己注册到全局的Module类中,active初始化函数将会返回卸载函数,这个卸载函数由每个模块自己控制,它必须保证其中的状态量都被安全卸载。这样下来每次更新 JS 模块时,首先需要将相同路径的模块全部卸载,然后再装载新的模块代码。

上文提到在更新 HTML 时,也需要更新模块,和直接更新 JS 代码不同,更新 HTML 时,由于 JS 代码并未变更,所以这里只需要将当前页面的模块重新装载,更新状态量即可。

§总结

不管细节怎么变化,HMR 的基本原理是相同的,都是监听文件变化并通过 WebSocket 发送变更数据;然后需要浏览器端提供对应的更新方法,细节上的差异主要都是在浏览器端如何更新页面元素上了。整体看下来,其实也并不复杂。