HMR 原理概述
§概述
HMR 全称 Hot Module Replacement,中文通常翻译为模块热更新,它能够在保持页面状态的情况下动态替换资源模块,提供更好的开发体验。它最初由 Webpack 设计实现,现在已经是前端工程的必备功能。
主要还是因为写文章的时候,更新一下就刷新一次太麻烦了,就想着整个自动刷信的东西,后来就想到,都自动刷新了,何不一步到位折腾个 HMR 玩玩。于是就看了看源码,上手改了改就折腾出了这个,本文主要就是介绍下我这里是怎么实现 HMR 的,以及和 Webpack 的实现有何不同。
§基本原理
本身博客就有调试用的虚拟文件服务器,HMR 功能只需要在这上面扩展即可。现在的虚拟文件服务器,其流程是这样的:
- 编译完成
- 浏览器访问网址
- 静态服务器从内存中找到对应文件
- 向网页端发送文件内容
- 文件变更后重新编译
- 浏览器刷信页面,回到第二步
而添加了 HMR 之后,整个流程是这样的:
- 编译时往前端网页中注入 HMR 客户端入口代码
- 浏览器访问网址
- 静态服务器从内存中找到对应文件,并生成注入代码一并返回浏览器
- 浏览器中初始化页面,并与服务器建立 WebSocket 连接
- 文件变更后重新编译
- 计算新生成的文件和旧文件的差异
- 将所有差异聚合成数据发送至浏览器
- 浏览器接收数据后执行代码变更逻辑
§代码结构
从上面的流程中可以看出,和之前的静态服务器相比多了三个部分:
- 负责前后端通信的 WebSocket 服务
- 编译端用于计算编译前后文件变更服务
- 注入浏览器端的运行时代码
§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
- ;
- ;
- ;
- ;
- ;
- ;
- 'connection',
-
-
-
-
- ;
-
-
-
其中,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
- ;
- ;
-
- // 公共 layout
-
-
-
-
-
-
-
-
-
-
-
-
-
后端服务器:
- 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
- ;
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
通过这样的方式,每当浏览器端加载页面时,就都会请求运行时代码,在请求之后,服务器也将会实时的把文件编译完成并返回前端,这样前端每次都能获取最新的运行时代码。
§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
- ;
- if 'WebSocket' in window
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- else
-
§前端更新流程
在前文中我们可以看到,我这里是将所有文件区分开来更新的,针对不同的文件有不同的更新策略。另外,在 Webpack 构成的项目中,不管路由在哪里,都靠着文件 HASH 编码来更新的,所以前端只需要接受所有更新,然后去匹配不同的 HASH 标记的模块就行。而在这里不行,因为没有 HASH 做标记,所以首先需要判断这个更新是不是属于当前页面,不是当前页面的文件就要抛弃。
§CSS 更新
CSS 文件更新是最简单的,只需要替换新的link
标签即可。但是注意要在链接后面加上新的 hash 编码,这样浏览器才会去读新的文件内容,而不是从缓存里拿。所以它的更新代码大约可以是:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
-
-
-
-
-
-
-
-
-
按照 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
-
-
-
-
但是,这还没完。这么直接替换,页面元素看起来都被更新了,但是有交互的页面元素并未从内存中卸载,并且新呈现的元素没有经过各种交互的初始化,它们是没有交互动作的。这里就牵扯到 JS 的更新了,请看下一节。
§JS 更新
JS 模块的更新麻烦的部分有两个,其一是网页端加载 JS 的机制问题,另外则是 JS 内存回收的问题。
第一点很好理解,浏览器加载 JS 和 CSS 是不同的,CSS 每次添加个新的link
元素,网页都会重新读取并加载;而 JS 不同,只要script
的src
内指向的地址不变,网页是不会运行第二遍的,所以这里就需要前端把代码手动运行一遍。这个很好做,只需要使用(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
- /** 卸载函数 */
- ;
- /** 脚本模块 */
-
-
-
-
-
-
-
-
-
- /** 模块装载器 */
-
-
-
-
-
-
- /** 全局装载器 */
- ;
- /** 当前模块装载函数 */
- ;
- /** 获取当前脚本地址 */
- ;
- // 模块注册
- if process.env.NODE_ENV === 'development'
-
-
-
-
在调试模式下,每个模块都必须将自己注册到全局的Module
类中,active
初始化函数将会返回卸载函数,这个卸载函数由每个模块自己控制,它必须保证其中的状态量都被安全卸载。这样下来每次更新 JS 模块时,首先需要将相同路径的模块全部卸载,然后再装载新的模块代码。
上文提到在更新 HTML 时,也需要更新模块,和直接更新 JS 代码不同,更新 HTML 时,由于 JS 代码并未变更,所以这里只需要将当前页面的模块重新装载,更新状态量即可。
§总结
不管细节怎么变化,HMR 的基本原理是相同的,都是监听文件变化并通过 WebSocket 发送变更数据;然后需要浏览器端提供对应的更新方法,细节上的差异主要都是在浏览器端如何更新页面元素上了。整体看下来,其实也并不复杂。