前言

本文旨在对比webpack-dev-server源码,从零实现webpack发展至今引入的最令人兴奋的特性之一的Hot Module Replacement(以下简称 HMR)的整体运行逻辑,也就是我们常提到的热更新。

什么是 HMR

简单来讲,就是一种无感知局部刷新技术。
当你对代码进行修改并保存后,webpack 将对代码重新编译打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。
举个例子,在我们开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

为什么需要 HMR

在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如live-server(opens new window),这些库监控文件的变化,然后通知浏览器端刷新页面。那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。

  • live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失。典型的例子就是,一个页面如果有很多输入框需要全部填写,浏览器刷新后,恢复到最初状态,想要继续流程还需要再次填写,那么这个体验可想而知是非常糟糕的。而 webapck HMR 则不会刷新浏览器,而是运行时(runtime)对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。
  • 在早期的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。
  • HMR 兼容市面上大多前端框架或库,比如 React Hot Loader,Vue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。

    使用 HMR

    HMR 的使用是非常简单的,只需在webpack.config.js配置文件中添加如下代码:
    1. module.exports = {
    2. // ...省略
    3. devServer: {
    4. hot: true
    5. },
    6. plugins: [
    7. new webpack.HotModuleReplacementPlugin(),
    8. // ...省略
    9. ]
    10. }
    然后编写我们的源文件,在./src/index.js: ```css const input = document.createElement(‘input’) document.body.appendChild(input) const divEl = document.createElement(‘div’) document.body.appendChild(divEl)

const render = () => { const title = require(‘./title.js’)

divEl.innerHTML = title

} render()

if (module.hot) { module.hot.accept([‘./title.js’], () => { console.log(‘更新了。。。。。。’); render() }) }

  1. 上述代码,我们创建了一个input框元素,是为了更直观的看到热更新的效果,接着创建一个div标签,编写render函数用来显示导入文件的内容。<br />module.hot这里的代码才是我们真正想要哪个模块具备热更新功能的关键。那么关于accept这个暴露出来的api的原理我们接下来一点一点引申来讲解,大家目前只需知道这里要指定哪个模块具备热更新是要通过使用这个api去添加的就可以了~<br />另外,一般我们在用框架进行开发的时候,不需要编写这样的代码是因为框架的loader帮助我们实现了这里的逻辑,比如:**Vue**开发中,使用vue-loader支持vue组件的HMR,提供开箱即用的体验;**React**开发中,之前是使用React Hot Loader来实现的HMR,目前已经改成使用react-refresh实时调整react组件了。上面也有简单提到。<br />./src/title.js:
  2. ```css
  3. module.exports = 'hello webpack hmr'

这个文件只是导出一个简单的内容,进行更新测试。

#效果如下:

更改前: 热更新原理解析 - 图1 热更新原理解析 - 图2
更改后: 热更新原理解析 - 图3 热更新原理解析 - 图4
当我们在输入框中输入了666,这个时候修改title.js中的内容,会发现之前小写的hello hmr变成了大写的 HELLO HMR,但是输入框中的值还依然保留着,这就是 HMR 的强大魔力之处,只更新变化的部分,保留页面状态,不禁让人直呼666~,这背后的原理我们接下来一一剖析。

关于webpack编译打包

在开始讲HMR整体运行流程之前,我们有必要说下关于webpack编译打包后的文件和相搭配的插件帮我们做的事
webpack watch:使用监控模式开始启动webpack编译,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,每次编译都会产生一个唯一的hash值

编译(打包)文件

首先,我们简单聊下webpack编译后的js文件
在上面的测试代码,我们使用的都是commonjs语法,我们知道,浏览器是不识别这种语法的,现在浏览器可以跑起来,那么肯定是webpack帮我们处理了这种语法的编译转换
所以我们可以看下编译后的js文件大体长啥样子

  1. (function(modules) {
  2. var parentHotUpdateCallback = window["webpackHotUpdate"];
  3. window["webpackHotUpdate"] = // eslint-disable-next-line no-unused-vars
  4. function webpackHotUpdateCallback(chunkId, moreModules) {
  5. hotAddUpdateChunk(chunkId, moreModules);
  6. if (parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
  7. } ;
  8. // eslint-disable-next-line no-unused-vars
  9. function hotDownloadUpdateChunk(chunkId) { }
  10. // eslint-disable-next-line no-unused-vars
  11. function hotDownloadManifest(requestTimeout) { }
  12. // eslint-disable-next-line no-unused-vars
  13. function hotCreateRequire(moduleId) { }
  14. // eslint-disable-next-line no-unused-vars
  15. function hotCreateModule(moduleId) { }
  16. function hotCheck(apply) { }
  17. // eslint-disable-next-line no-unused-vars
  18. function hotAddUpdateChunk(chunkId, moreModules) { }
  19. function hotUpdateDownloaded() { }
  20. function hotApply(options) { }
  21. // The module cache
  22. var installedModules = {};
  23. // The require function
  24. function __webpack_require__(moduleId) {
  25. // Check if module is in cache
  26. if(installedModules[moduleId]) {
  27. return installedModules[moduleId].exports;
  28. }
  29. // Create a new module (and put it into the cache)
  30. var module = installedModules[moduleId] = {
  31. i: moduleId,
  32. l: false,
  33. exports: {},
  34. hot: hotCreateModule(moduleId),
  35. parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
  36. children: []
  37. };
  38. // Execute the module function
  39. modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));
  40. // Flag the module as loaded
  41. module.l = true;
  42. // Return the exports of the module
  43. return module.exports;
  44. }
  45. // expose the modules object (__webpack_modules__)
  46. __webpack_require__.m = modules;
  47. // expose the module cache
  48. __webpack_require__.c = installedModules;
  49. // Load entry module and return exports
  50. return hotCreateRequire("./src/index.js")(__webpack_require__.s = "./src/index.js");
  51. })
  52. ({
  53. "./src/index.js": (function(module, exports, __webpack_require__) {
  54. const input = document.createElement('input')
  55. document.body.appendChild(input)
  56. const div = document.createElement('div')
  57. document.body.appendChild(div)
  58. const render = () => {
  59. const title = __webpack_require__(/*! ./title.js */ "./src/title.js")
  60. div.innerHTML = title
  61. }
  62. render()
  63. if (true) {
  64. module.hot.accept([/*! ./title.js */ "./src/title.js"], () => {
  65. console.log('更新了。。。。。。');
  66. render()
  67. })
  68. }
  69. }),
  70. "./src/title.js": (function(module, exports) {
  71. module.exports = 'HELLO HMR'
  72. })
  73. });
  74. //# sourceMappingURL=main.js.map

这段代码就是使用的 HotModuleReplacementPlugin 插件编译生成的chunk文件,我截取了其中相关的核心逻辑代码,大家可以先整体了解下这个文件生成的主要结构,后面我们会逐个剖析并且去手写核心函数的运行逻辑
首先,整体是一个自执行函数,参数是一个对象,不难发现,这个对象的key是一个模块路径,我们称为moduleId,value是一个函数定义,函数体就是我们编写的所有代码
接着看到,里面有很多函数, 这些函数部分是通过 HotModuleReplacement 这个插件注入的 HMR runtime 运行时代码,接下来我们会说这个插件的作用
然后重点需要注意的是,高亮的代码有一个webpack_require函数,这个函数是webpack自己实现的require函数,来polyfill我们的commonjs语法,使浏览器可以识别,我们稍后还会重点来讲,大家可以先有个印象

HotModuleReplacement插件

这个插件在我们使用HMR的时候,有看到过在配置文件里使用,其实在webpack5版本之前需要我们手动去new webpack.HotModuleReplacementPlugin实例来使用热更新,在webpack5版本开始,devServer的hot属性配置为true后,webpack会自动帮我们启用这个插件,不需要我们额外手动去new了。那么这个插件的用途是啥呢

  1. 生成两个补丁文件
  • manifest(JSON): 上次编译生成的hash.hot.update.json(如:8a95f5a5ce338ff54c5a.hot-update.json)

热更新原理解析 - 图5

  • updated chunk(JS): chunk名.上次编译生成的hash.hot-update.js(如:main.8a95f5a5ce338ff54c5a.hot-update.js)

热更新原理解析 - 图6
这里需要注意下,这个文件返回的是一个webpackHotUpdate函数调用,后面会用jsonp的方式调用这个文件

  1. 在chunk文件中注入HMR runtime运行时代码

上面我们也简单提到,在打包后生成的chunk文件中,有大量的函数是通过 HotModuleReplacement插件注入进来的,比如:我们的热更新客户端主要逻辑(拉取新模块代码、执行新模块代码、执行accept的回调实现局部更新)都是这个插件把函数注入到我们的chunk文件中来实现的
这些了解之后,我们就可以对照编译后的main.js,开始自己写一下能在浏览器里运行且带有热更新功能的js代码了。这个过程我会按照下面的目录和流程图来详细讲解,lets go~

HMR 的项目整体结构

  1. ├─package.json
  2. ├─webpack.config.js # webpack配置文件
  3. ├─webpack-dev-server
  4. | ├─index.js # 服务入口文件
  5. | ├─lib
  6. | | ├─utils
  7. | | | updateCompiler.js
  8. | | ├─server # 静态服务器、websocket服务器
  9. | | | Server.js
  10. | ├─client # hotModuleReplacementPlugin插件提供的客户端通信js文件
  11. | | index.js # 等价于源码中的webpack-dev-server/client/index.js
  12. ├─webpack
  13. | ├─hot
  14. | | dev-server.js # 等价于源码中的webpack/hot/dev-server.js 和 HMR runtime
  15. ├─src # 源文件
  16. | ├─index.js
  17. | title.js

目录结构是参照webpack-dev-server源码划分,具体可见webpack-dev-server(opens new window)

HMR 的工作原理图解

热更新原理解析 - 图7
在开始探寻 HMR 的底层奥秘之前,我们可以先思考这么几个问题:

  1. webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?
  2. 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?
  3. 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?
  4. 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?
  5. 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

然后带着这些问题,来一步一步揭开 HMR 的神秘面纱。

服务端实现

流程第一步,我们先通过express创建一个服务器Server类,这也是webpack-dev-server做的最基本的事情,
对应文件./webpack-dev-server/lib/server/Server.js:

  1. class Server {
  2. constructor(compiler) {
  3. this.compiler = compiler
  4. }
  5. setupApp() {
  6. }
  7. createServer() {
  8. }
  9. listen(port, host, callback) {
  10. }
  11. }
  12. module.exports = Server

在入口文件./webpack-dev-server/index.js引入,并把webpack生成的实例comiler传入Server类,启动监听端口9090,第一、二、三步完成

  1. const webpack = require('webpack')
  2. const config = require('../webpack.config')
  3. const Server = require('./lib/server/Server')
  4. const compiler = webpack(config)
  5. const server = new Server(compiler)
  6. server.listen('9090', 'localhost', () => {
  7. console.log('HMR Project is running at http://localhost:9090/');
  8. })

当我们在通过webpack-dev-server启动服务的时候,可以留意下启动控制台的信息
热更新原理解析 - 图8
可以看到,在我们启服务时,这里webpack的入口是多入口(multi)启动,大家肯定会有疑惑,明明我们在webpack.config.js配置文件中配置的是单入口啊,为什么启服务后变成了多入口,这是因为webpack-dev-server内部通过一个方法把我们的入口改了
也就是我们接下来要讲的第4步,通过updateCompiler更改config的entry属性,向entry中分别注入了webpack-dev-server/client/index.js和webpack/hot/dev-server.js,这两个文件具体是干啥的,我们后面会具体讲解。
对应文件./webpack-dev-server/lib/utils/updateCompiler.js

  1. const path = require('path')
  2. module.exports = (compiler) => {
  3. const config = compiler.options
  4. config.entry = {
  5. main: [
  6. path.resolve(__dirname, '../client/index.js'),
  7. path.resolve(__dirname, '../../../webpack/hot/dev-server'),
  8. config.entry
  9. ]
  10. }
  11. }

这里比较简单,大家都可以看懂,第4步完成
接下来我们要在compiler编译完成的钩子上注册一个事件,这个api是webpack实例提供给我们的,这个事件主要做了一件事情,每当新一次编译完成后都会向所有的websocket客户端发送消息,发射两个事件,通知浏览器来拉取最新代码
当然,浏览器端会监听这两个事件,收到消息会去拉取上次编译生成的hash.hot-update.json,具体的逻辑我们会在下面的客户端章节详细讲解

  1. setupHooks() {
  2. const { compiler } = this
  3. compiler.hooks.done.tap('webpack-dev-server', (stats) => {
  4. this.currentHash = stats.hash
  5. // 遍历通知浏览器
  6. this.clientSocketList.forEach(socket => {
  7. socket.emit('hash', stats.hash)
  8. socket.emit('ok')
  9. })
  10. })
  11. }

第5、6步完成
在第7步,创建express的app实例,方便通过node原生的http模块创建一个可维护的server实例。

  1. setupApp() {
  2. this.app = express()
  3. }

第7步完成
在第8步,我们需要讲解下webpack-dev-middleware这个中间件,当然,这个中间件我们会自己实现他的逻辑,先了解下这个中间件做了哪些事情
我们知道,webpack-dev-server核心是做准备工作(更改entry、监听webpack done事件等)、创建webserver服务器和websocket服务器让浏览器和服务端建立通信
而编译和编译文件相关的操作(如重新编译、输出)都抽离到webpack-dev-middleware
那么webpack-dev-middleware做了哪几件事情呢?

  1. 本地文件的监听、启动webpack编译;使用监控模式开始启动webpack编译在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包
  2. 设置文件系统为内存文件系统MemoryFs(让编译输出到内存中)
  3. 实现了一个express中间件,将编译的文件返回 ```css const express = require(‘express’) const http = require(‘http’) const path = require(‘path’) const updateCompiler = require(‘../utils/updateCompiler’) const MemoryFs = require(‘memory-fs’) // 内存文件系统,主要目的就是将编译后的文件输出到内存 const mime = require(‘mime’) // 可以根据文件后缀,生成相应的Content-Type类型

class Server { constructor(compiler) { this.compiler = compiler // 将webpack实例挂载到this实例上 updateCompiler(this.compiler) // 【3】entry增加 websocket客户端的两个文件,让其一同打包到chunk中 this.setupApp() // 启动app程序 this.setupHooks() this.setupDevMiddleware() // 编译中间件 this.routes() // 路由中间件 } routes() { const { compiler } = this const config = compiler.options this.app.use(this.middleware(config.output.path)) } setupDevMiddleware() { this.middleware = this.webpackDevMiddleware() } webpackDevMiddleware() { const { compiler } = this compiler.watch({}, () => { console.log(‘文件编译成功!’); }) const fs = new MemoryFs() this.fs = compiler.outputFileSystem = fs return (staticDir) => { // 中间件 return (req, res, next) => { let { url } = req if (url === ‘/favicon.ico’) { return res.sendStatus(404) } url === ‘/‘ ? (url = ‘/index.html’) : null // 得到要访问的静态资源路径 let filePath = path.join(staticDir, url) try { // 返回此路径上的文件描述对象,文件不存在,抛出异常 const statObj = this.fs.statSync(filePath) if (statObj.isFile()) { const content = this.fs.readFileSync(filePath) res.setHeader(‘Content-Type’, mime.lookup(filePath)) res.send(content) } } catch(err) { return res.sendStatus(404) } } } } setupHooks() { const { compiler } = this compiler.hooks.done.tap(‘webpack-dev-server’, (stats) => { this.currentHash = stats.hash // 遍历通知浏览器 this.clientSocketList.forEach(socket => { socket.emit(‘hash’, stats.hash) socket.emit(‘ok’) }) }) } setupApp() { this.app = express() } listen(port, host, callback) { } }

  1. 我们再简单对上面的代码做个剖析,首先setupDevMiddleware函数只是把webpackDevMiddleware函数返回结果绑定到this实例的属性middleware上<br />重点看这个webpackDevMiddleware函数,1. 通过webpack实例的watch方法实时监听文件变化,重新编译,2. 编译成功输出到内存文件系统,(**注意这里每次编译成功后,都会触发在钩子函数中注册的事件**)3. 最后返回一个具有express中间件的闭包函数,正好和我们上面所说的webpack-dev-middleware做的3件事情一一呼应。<br />关于这个express中间件做的事情就比较简单了,首先通过req对象获取到当前访问路径,然后通过path.join拼接一个html资源的完整路径(**这也是为什么我们通过当前域名/能访问到我们编译后的资源**),接着根据fs.statSync拿到此路径上的文件描述对象,判断如果是文件类型,读取内容返回,否则返回404状态<br />ok, 到这里,中间件也有了,那么我们就需要通过express实例来使用我们的中间件了,可以看到routes方法就做了这么一件事情。<br />**第8步完成**<br />这里,我们开始创建webserver服务器,让浏览器可以请求webpack编译后的静态资源<br />这里将原生的httpexpress结合来使用,大家可能会有个疑问?为什么不直接使用expresshttp中的任意一个?理由如下:
  2. - 不直接使用express,是因为我们拿不到server,可以看下express的源码。那为什么要这个server,因为我们要在socket中使用;
  3. - 不直接使用http,想必大家也知道,原生http写逻辑有个硬伤,就是非常不灵活;我们这里只是写了一个简单的static处理逻辑,所以看不出什么,但是源码中还有很多的逻辑,如果全部用原生http写,体验很糟糕;
  4. - 那既然两者都有缺陷,就结合一下呗,我们用原生http创建一个服务,不就拿到了server嘛,这个server的请求逻辑,依然是交给express处理就好了呗,this.server = http.createServer(express());一行代码完美搞定
  5. ```css
  6. createServer() {
  7. this.server = http.createServer(this.app)
  8. }

第9步完成
接着,我们开始创建websocket服务器,使用socket.io库在浏览器端和服务端之间建立一个 websocket 长连接,文件变动会主动推消息给客户端

  1. createSocketServer() {
  2. // socket.io+http服务 实现一个websocket
  3. const io = socketIO(this.server)
  4. io.on('connection', (socket) => {
  5. // 把所有的websocket客户端存起来,以便编译完成后向每个websocket客户端发送消息(实现双向通信的关键)
  6. this.clientSocketList.push(socket)
  7. // 向客户端发送最新编译的hash
  8. socket.emit('hash', this.currentHash)
  9. // 再向客户端发送一个ok
  10. socket.emit('ok')
  11. // 每当有客户端断开时,移除当前这个websocket客户端
  12. socket.on('disconnect', () => {
  13. const idx = this.clientSocketList.indexOf(socket)
  14. this.clientSocketList.splice(idx, 1)
  15. })
  16. })
  17. }

创建websocket服务实现逻辑都在注释里了,也比较简单
第10步完成
到这里,服务端的逻辑基本就搞定了,但是最终在浏览器中运行的代码是编译后的文件代码,我们还没开始写,不过在上面我们也提到了,能在浏览器里运行我们写的commonjs语法的代码,是webpack自己实现了一个webpack_require函数帮我们做了转换,那我们现在就来剖析这个函数的实现逻辑
对应的文件是写在我们编译目录(build)下,起名为:hmr.js,这个文件就是我们接下来要手写实现热更新逻辑的客户端文件,是对照webpack自己编译出来的main.js文件

  1. (function(modules) {
  2. const installedModules = {}
  3. // 维护父子关系
  4. function hotCreateRequire(parentModuleId) {
  5. // 通过缓存得到父模块
  6. const parentModule = installedModules[parentModuleId]
  7. if (!parentModule) return __webpack_require__
  8. const fn = function(childModuleId) {
  9. __webpack_require__(childModuleId)
  10. const childModule = installedModules[childModuleId]
  11. parentModule.children.push(childModule)
  12. childModule.parents.push(parentModule)
  13. // console.log(childModule, 'childModule');
  14. return childModule.exports
  15. }
  16. return fn
  17. }
  18. function __webpack_require__(moduleId) {
  19. if (installedModules[moduleId]) {
  20. return installedModules[moduleId]
  21. }
  22. const module = installedModules[moduleId] = {
  23. i: moduleId,
  24. l: false,
  25. exports: {},
  26. parents: [],
  27. children: []
  28. }
  29. modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId))
  30. module.l = true
  31. return module.exports
  32. }
  33. __webpack_require__.c = installedModules
  34. return hotCreateRequire('./src/index.js')('./src/index.js')
  35. })({
  36. './src/index.js': function (module, exports, __webpack_require__) {
  37. const title = __webpack_require__('./src/title.js')
  38. },
  39. './src/title.js': function (module, exports, __webpack_require__) { }
  40. })

分析如下:

  1. webpack_require函数: 这个函数主要是通过模块id找到对应的模块依赖并执行

首先,通过缓存installedModules对象找对应的模块,如果有就从缓存中取了,缓存中没有,会定一个对象,分别赋值给module和缓存对象,这个对象中的exports属性就是我们在commonjs语法中常用的module.exports,最后会返回,这个属性的值会由递归的依赖模块分配,默认为对象。
最后根据对应模块id去执行代码,相当于是执行传入进来的模块函数定义:

  1. './src/index.js': function (module, exports, __webpack_require__) {
  2. const title = __webpack_require__('./src/title.js')
  3. }
  1. hotCreateRequire函数:这个函数主要是维护了父子模块关系

首先通过缓存取,取不到返回webpack_require函数,一旦执行过这个函数,那么缓存中就会有记录。最后返回一个fn函数,给子模块调用,维护父子模块依赖关系
在package.json文件中新增一条服务启动命令myDev:

  1. "scripts": {
  2. "build": "webpack",
  3. "dev": "webpack-dev-server",
  4. "myDev": "node webpack-dev-server"
  5. }

最后,执行npm run myDev启动webserver服务,监听端口

  1. listen(port, host, callback) {
  2. this.server.listen(port, host, callback)
  3. }

访问http://localhost:9090就可以看到我们写的效果啦~
热更新原理解析 - 图9
到这里,基本服务就可以跑起来了,接下来开始重点实现客户端逻辑的热更新功能,大家加油~

客户端实现

还记得在第3步的时候,webpack-dev-server内部有一个updateCompiler方法把我们配置的webpack单入口改成了多入口的事吗,这个方法是不是向我们的入口注入了两个文件,分别是webpack-dev-server/client/index.js和webpack/hot/dev-server.js(赶快回忆下~)
那么也就是说,只要是配置在entry入口的文件,webapck都会打包到一个chunk文件中,形成webpack的依赖关系图,里面的代码都会被执行。
最终打包的多入口chunk文件大概长这样:
热更新原理解析 - 图10
那重点是注入进来的这两个文件的作用是啥呢,这里就开始详细讲解

关于webpack-dev-server/client/index.js

这个文件主要负责websocket客户端hash和ok事件的监听,这里对照我们流程图客户端模块第一步开始
连接websocket服务器,socket监听事件,发射webpackHotUpdate事件

  1. const io = require("socket.io-client/dist/socket.io"); // websocket客户端
  2. let currentHash;// 最新编译的hash
  3. const socket = io('/')
  4. socket.on('hash', (hash) => {
  5. currentHash = hash
  6. })
  7. socket.on('ok', () => {
  8. reloadApp()
  9. })
  10. function reloadApp() {
  11. hotEmitter.emit('webpackHotUpdate')
  12. }

可以看到,这里我们的hash事件只是存储了当前最新编译的hash值,后续会用到;ok事件只是发射了一个webpackHotUpdate事件,这个文件的核心就这两件事。 第1、2、3、4步完成
这里用到了一个发布订阅模式EventEmitter,是和在webpack/hot/dev-server.js中监听ok发射的事件做共享,同时为了更好的解耦,实现如下:

  1. class EventEmitter {
  2. constructor() {
  3. this.events = {}
  4. }
  5. on (eventName, fn) {
  6. this.events[eventName] = fn
  7. }
  8. emit (eventName, ...args) {
  9. this.events[eventName](...args)
  10. }
  11. }
  12. const hotEmitter = new EventEmitter()

关于webpack/hot/dev-server.js

这个文件主要是订阅了一个webpackHotUpdate事件,用来负责监听socket的ok事件派发过来的消息,做一些事情:

  1. 初始第一次hash
  2. 调用hot.check方法向服务器检查更新并且拉取最新代码

    1. './webpack/hot/dev-server.js': function(module, exports) {
    2. hotEmitter.on('webpackHotUpdate', () => {
    3. if (!lastHash) {
    4. lastHash = currentHash
    5. return
    6. }
    7. // 调用hot.check方法向服务器检查更新并且拉取最新代码
    8. module.hot.check()
    9. })
    10. }

    这里我们看到,这个函数做的第2件事情是在module对象上调用了hot.check方法,在源码中,这个module对象上的hot属性是一个函数的返回值

    1. const module = installedModules[moduleId] = {
    2. i: moduleId,
    3. l: false,
    4. exports: {},
    5. parents: [],
    6. children: [],
    7. hot: hotCreateModule()
    8. }

    hotCreateModule函数实现:

    1. function hotCreateModule() {
    2. const hot = {
    3. _acceptedDependencies: {},
    4. accept(deps, callback) {
    5. deps.forEach(dep => this._acceptedDependencies[dep] = callback)
    6. },
    7. check: hotCheck
    8. }
    9. return hot
    10. }

    我们知道,这个函数最终是给module.hot属性干活的,定义的对象上具备两个方法accept和check,这里看到accept方法是不很熟悉,没错,它就是我们再开始使用 HMR 的时候来指定哪个模块具备热更新的关键,原理其实很简单
    相当于也是一个简单的发布订阅模式,将用户指定的模块文件遍历后,模块路径作为key,回调函数作为value存到_acceptedDependencies这个对象中,订阅起来就可以了。
    如果只指定一个模块文件进行热更新,其实这里也可以传入一个字符串,字符串情况的话再加个判断就可以了,我们这里就只考虑数组的情况了
    然后就是check方法,这个方法主要是负责向服务器发送请求检查更新,拉取最新代码的
    到这里,大家也许就能明白,其实真正的客户端热更新的逻辑都是HotModuleReplacementPlugin.runtime运行时代码干的,通过module.hot.check=hotCheck把 webpack/hot/dev-server.js 和 HotModuleReplacementPlugin在chunk文件中注入的hotCheck等代码 架起一座桥梁
    接下来,我们会把 HotModuleReplacementPlugin.runtime运行时代码注入的核心方法 自己实现一遍,有助于我们对 HMR 底层实现的理解
    第5、6、7步完成
    刚刚说到,在hot/dev-server.js中调用了hot.check方法来向服务器检查更新并拉取最新编译代码,这里分为两步

  3. 通过ajax向服务器发送请求,获取变化的chunkId

  4. 通过jsonp的方式向服务器请求最新编译代码 ```css function hotCheck() { hotDownloadManifest().then(res => { const chunkIds = Object.keys(res.c) chunkIds.forEach(chunkId => { hotDownloadUpdateChunk(chunkId) }) lastHash = currentHash }).catch((err) => { window.location.reload() }) }

// 下载更改后的json文件 function hotDownloadManifest () { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() const url = ${lastHash}.hot-update.json xhr.open(‘get’, url) xhr.responseType = ‘json’ xhr.onload = function() { resolve(xhr.response) } xhr.send() }) }

// 通过jsonp的方式请求更改的文件 function hotDownloadUpdateChunk (chunkId) { const script = document.createElement(‘script’) script.src = ${chunkId}.${lastHash}.hot-update.js document.head.appendChild(script) }

  1. 上文中,我们也提到,HotModuleReplacementPlugin插件会给我们生成两个补丁文件,那么在这里<br />我们先会在hotCheck方法中,通过hotDownloadManifest异步获取第一个补丁文件(json)的返回结果,这个结果就是一个最新编译的hash值和变化的chunk标识,称为chunkId<br />![](https://cdn.nlark.com/yuque/0/2021/png/2832855/1634118738756-8ed885ee-3ce9-482d-bd86-6ba788ee73a5.png#clientId=uc1aac6de-ad5b-4&from=paste&id=u7ae48339&margin=%5Bobject%20Object%5D&originHeight=270&originWidth=1272&originalType=url&ratio=1&status=done&style=none&taskId=ud3bbcc4d-0979-4551-ad69-a5214429d52)<br />获取到chunkId后,在hotDownloadUpdateChunk方法中通过jsonp的方式请求第二个补丁文件(js),我们这里为啥要用jsonp的方式呢,上文中我们也提到,因为这个文件返回的是一个符合javascript语法的函数调用,那必然在全局会挂载一个这样的方法来处理调用结果<br />代码如下:
  2. ```css
  3. window.webpackHotUpdate = function (chunkId, moreModules) {
  4. hotAddUpdateChunk(chunkId, moreModules)
  5. }

这个方法的参数分别是变化的chunkId,也就是main, moreModules,也就是变化的模块代码
热更新原理解析 - 图11
内部又调用了hotAddUpdateChunk方法来继续处理,代码如下:

  1. let hotUpdate = {}
  2. function hotAddUpdateChunk (chunkId, moreModules) {
  3. for (let moduleId in moreModules) {
  4. modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId]
  5. }
  6. hotApply()
  7. }

这个方法内部做的事情很简单,遍历moreModules,替换原有的模块Id对应的函数定义,同时,向hotUpdate动态添加了一份最新模块函数定义数据,后续会用到。最后调用了hotApply方法,代码如下:

  1. function hotApply() {
  2. for (let moduleId in hotUpdate) {
  3. const oldModule = installedModules[moduleId]
  4. delete installedModules[moduleId]
  5. oldModule.parents.forEach(parentModule => {
  6. const cb = parentModule.hot._acceptedDependencies[moduleId]
  7. cb && cb()
  8. })
  9. }
  10. }

这个方法做了3件事:

  1. 遍历最新模块代码数据(hotUpdate)
  2. 从缓存中删除旧模块
  3. 执行accept回调(cb)

这个cb的值就是我们在hotCreateModule方法中添加进来的,也就是accept方法的第2个参数

  1. if (module.hot) {
  2. module.hot.accept(['./src/title.js'], () => {
  3. console.log('更新了。。。。。。');
  4. render()
  5. })
  6. }

这样,当我们改变了title.js模块的代码,就会再次执行这个传入的回调,页面显示最新的结果,达到了热更新的效果
至此,从服务端到客户端,我们就实现完了整个 热更新(HMR) 运行的核心逻辑了。同时,在我们开始探寻HMR底层奥秘之前的那几个问题,我相信现在你心里也有答案了。

总结

其实,热更新技术在前端已经不是啥新鲜玩意儿了,使用上确实帮助开发者提高了开发效率和开发体验。我在项目使用的过程中,也对这个底层实现比较好奇,正好最近抽空复盘webpack工作流的时候,决定一探它的底层原理,于是就有了这篇文章。这篇文章基本把webpack的热更新核心流程和核心思路讲到了,如果你也对HMR底层原理感兴趣,相信看完一定会有所收获~