深入Webpack的HMR机制

9/16/2021 webpack

# 前言

在我刚学习 vue 的时候,接触vue-cli的时候,在开发是被其提供的热加载功能深深的吸引住了。很好奇它是如何将我们所修改的代码通过不刷新方式,来更新 web 应用,与此同时还能保持应用的一些状态,这在我们平时日常开发之中是非常有用。后来再知道提供浙一功能的是 webpack 的一个插件模块 hotModuleReplace 所提供的能力,本文就带大家一起来探究一下它是如何实现的。

# 抛出疑问

对于我们不熟悉或者压根没有接触的东西,通过抛出疑问,自己解决这些疑问可以很好帮我们去提升自己的能力,这是很好成长的机会。

  1. 通过 webpack 打包之后的文件存放在哪里?在没有 writeToDisk: true 这个配置项时。

  2. 打包器在工作时是如何和 client 进行交互的?

  3. 新生成的模块是如何作用于浏览器应用的。

一开始的时候我们抛出的问题的颗粒度比较粗,没有关系,随着我们一步步的探索,我们又会遇到一些细节的问题,把这细节问题梳理清楚了,从而对整个问题就有了更深的理解了。

# HMR 流程图解

image

上图描述了一个完整HMR流程,我们来看看它是如何运转的。

  1. webpack 的监听模式下,在我们所编写的文件保存之后,会触发其重新编译,完成之后的bundle保存在内存之中。在编译打包时,webpackProgressPlugin这个插件将编译进度通过 websocket 推送到浏览器端。

  2. webpack-dev-serverwebpack之间主要是由 webpack-dev-middleware来进行交互的,这里它主要做了两件事:一是,通过 webpack-dev-middleware 调用webpack生成的compiler的相关api,告诉其将打包之后的文件是写入内存之后,还是写到硬盘上,依据writeToDisk配置项。二是,webpack-dev-middleware调用返回的是一个express标准的中间件,向app上注册路由,拦截 HTTP 收到的请求,根据请求路径响应对应的文件内容;

  3. 这一步,浏览器主要是通过 sockjs 这个库与服务器端的 websocket服务建立长连接通信,将 webpack编译打包的各个阶段推送浏览器端,依此会有不同的操作,最为核心的就是服务端推送新hash时,浏览器端会依据这个hash来进行后续的热更新逻辑。

  4. 当服务端推送的消息typeok时,webpack-dev-server/client会执行reloadApp方法,主要是为了触发webpackHotUpdate事件,在webpack/hot/dev-server依据相关的配置和传递的信息决定是否要刷新页面还是要热更新,这两者联系的纽带是webpack/hot/emitter,依赖的是nodeevents模块,在浏览器也能生效,说明其是和平台无关的。

  5. webpack/hot/dev-server中会通过调用module.hot.check方法映射到HotModuleReplacement.runtime中的hotCheck方法,这些方法是通过 HotModuleReplacementPlugin注入到bendle之中。

  6. hotCheck 然后会调用JsonpMainTemplate.runtime中的hotDownloadManifesthotDownloadUpdateChunk方法。

  7. hotDownloadManifest携带上推送的 hash发起ajax请求,获取最新的文件列表。

  8. hotDownloadUpdateChunk 依据文件列表发起jsonp请求相应的最新的代码块,继续后续代码更新的逻辑。

  9. 代码更新时,会进行新旧模块的对比,决定是否要更新。如果要更新,将梳理清楚模块之间的依赖,然后更新模块以及相关的依赖;如果更新失败了,就会通过刷新的方式来进行兜底操作。

# 运行Demo

下面我们通过一个简单的例子来说明。来说明一些其中的一些细节问题,以大家最为熟悉的 vue为例。

我们来初始化一个基本vue项目,相关目录以及核心配置如下:

Demo
├─ main.js
├─ package.json
├─ public
│    └─ index.html
├─ test.vue
├─ webpack.config.js
1
2
3
4
5
6
7

webpack.config.js 的配置如下,

{
  devtool: 'source-map',
  mode: 'development',
  entry: path.resolve(__dirname, './main.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  devServer: process.env.NODE_ENV == 'production' ? {}:{
    contentBase: path.join(__dirname, 'dist'),
    hot: true,
    writeToDisk: true,
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: ['vue-loader']
      },
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './public/index.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

package.json 的配置如下,

"scripts": {
  "dev": "rm -rf ./dist && NODE_ENV=development webpack-dev-server --config webpack.config.js --open --inline --progress"
}
1
2
3

test.vue中的文件大家看心情来就好,main.js就是正常的vue启动入口,这里我就不多赘述了。

当我们修改的test.vue时,

# 第一步:webpack将打包之后的文件放入内存之中

webpack-dev-middleware 通过调用 webpackcompilerapi将打包之后的文件放到内存之中。核心代码如下:

// webpack-dev-middleware/index.js
if (!options.lazy) {
  context.watching = compiler.watch(options.watchOptions, (err) => {
    // ...
  })
}
1
2
3
4
5
6

在配置了 writeToDisk 这个配置项时,打包之后的文件会写入到相应的文件夹中。但是在没有这个配置的时候,那么文件有存放在哪里呢?这里我们提到webpack-dev-middleware中的一个依赖memory-fs,看这个名字我们大概就能猜到他是干什么的了。在 webpackcompiler.outputFileSystem会被挂在上memory-fs的实例。核心代码如下:

// webpack-dev-middleware/lib/fs.js
const isMemoryFs = !isConfiguredFs && !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if (isConfiguredFs) {
  const { fs } = context.options;
  compiler.outputFileSystem = fs;
  fileSystem = fs;
} else if (isMemoryFs) {
  fileSystem = compiler.outputFileSystem;
} else {
  fileSystem = new MemoryFileSystem();
  compiler.outputFileSystem = fileSystem;
}
1
2
3
4
5
6
7
8
9
10
11
12

如果当前 compiler.outputFileSystem 不是 MemoryFileSystem,那就用 MemoryFileSystem 替换,这样打包的文件就存放在 MemoryFileSystem 所构建的文件系统之中,加快了访问读取的速度了。

# 第二步:devServer在文件编译通知浏览器端

webpackcompiler会添加ProgressPlugin插件,统计编译打包的进度,然后通过sockjs与服务端 websocket 通信,将打包的各个状态推送到客户端。此时,还需要知道打包完成的状态,在 compiler.hooks 中监听了 done 事件,然后通过调用 _sendStats 将打包之后的 stats.hash推送浏览器端。核心代码如下:

// webpack-dev-server/lib/Server.js
new webpack.ProgressPlugin((percent, msg, addInfo) => {
  // ... 使用 ProgressPlugin 插件
  this.sockWrite(this.sockets, 'progress-update', { percent, msg });
  // ...
}).apply(this.compiler);

setupHooks() {
  // ...
  const addHooks = (compiler) => {
    const { compile, invalid, done } = compiler.hooks;
    // ...
    // 添加done事件监听
    done.tap('webpack-dev-server', (stats) => {
      this._sendStats(this.sockets, this.getStats(stats));
      this._stats = stats;
    });
  };
  if (this.compiler.compilers) {
    this.compiler.compilers.forEach(addHooks);
  } else {
    addHooks(this.compiler);
  }
}
_sendStats(sockets, stats, force) {
  // ...
  // 推送新的 hash 到浏览器端
  this.sockWrite(sockets, 'hash', stats.hash);
  // ...
}
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

# 第三步:webpack-dev-server/client 接受和处理服务端推送的信息

在一开始梳理这逻辑的时候,我有点好奇 webpack-dev-server/client 这部分的处理代码是如何打到 bundle之中的?其实在 webpack-dev-server 启动的时候,回去更新 webpackcompiler, 然后通过 addEntries 这个插件将这部分的代码注入到客户端之中。核心代码如下:

// webpack-dev-server/lib/Server.js
updateCompiler(this.compiler, this.options)
// webpack-dev-server/lib/utils/updateCompiler.js
addEntries(webpackConfig, options);
compilers.forEach((compiler) => {
// ...
const providePlugin = new webpack.ProvidePlugin({
  __webpack_dev_server_client__: getSocketClientPath(options),
});
providePlugin.apply(compiler);
});
1
2
3
4
5
6
7
8
9
10
11

webpack-dev-server/client在处理 websocket消息会依据不同的 type 来处理消息。当收到 typehash时记录下传递过来的hash值,当前消息的typeok时,会调用 reloadApp。如下图所示:

image

reloadApp 这个函数中,主要是通过webpack/hot/emitter触发webpackHotUpdate事件,并将最新的 hash作为参数传递过去,如果没有配置hot参数,则会在宏任务中来进行页面刷新的操作。 核心代码如下:

// webpack-dev-server/client-src/default/index.js
const onSocketMessage = {
  hash(hash) {
    status.currentHash = hash;
  },
  ok() {
    reloadApp(options, status);
  }
}
function reloadApp(
  { hotReload, hot, liveReload },
  { isUnloading, currentHash }
) {
  if (hot) {
    const hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
  } else if (liveReload) {
    // ...
    self.setInterval(() => {
      rootWindow.location.reload()
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 第四步:webpack接受到最新的hash验证以及更新模块

在上一步当中我们派发了 webpackHotUpdate 事件,要看在哪里消费了这个事件。在 webpack/hot/dev-server中消费的回调函数中主要是调用了 module.hot.check 方法,

module.hot这个对象是通过HotModuleReplacementPlugin插件将HotModuleReplacement.runtime之中的代码注入。核心代码如下:

// webpack/hot/dev-server.js
hotEmitter.on("webpackHotUpdate", function(currentHash) {
  //...
  module.hot.check(true).then(() => {
    //...
  })
});
// webpack/lib/HotModuleReplacementPlugin.js
const Template = require("./Template")
const hotInitCode = Template.getFunctionContent(
	require("./HotModuleReplacement.runtime")
)
mainTemplate.hooks.moduleObj.tap(
  "HotModuleReplacementPlugin",
  (source, chunk, hash, varModuleId) => {
    return Template.asString([
      `${source},`,
      `hot: hotCreateModule(${varModuleId}),`, // 将 hmr.runtime 中的 api 注入到 module 之中
      "parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),",
      "children: []"
    ]);
  }
);
// webpack/lib/HotModuleReplacement.runtime.js
function hotCreateModule(moduleId) {
		var hot = {
			check: hotCheck,
      // other api
		};
		return hot;
	}
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

hotCheck方法会调用 JsonpMainTemplate.runtime中的两个方法 hotDownloadManifesthotDownloadUpdateChunk,前者通过上一步传入的hash发起ajax请求服务端获取文件更新列表,如图所示;后者依据文件列表发起jsonp请求相应的最新的代码块,继续后续代码更新的逻辑。

image

hotDownloadManifest方法获取更新文件列表

image

hotDownloadUpdateChunk获取到更新的新模块代码。

在这里我一直有一个疑问,并且我自己想了一段时间也没有想通用的问题,就是为什么要通过上述的这种方式来更新。从技术来说,当服务端推送消息typeok时,可以吧更新之后的代码块也一并推送过来,但是为什么没有这么做呢?看了其他的解释是,模块解耦dev-server/client 只负责消息的传递,HotModuleReplacement来负责代码模块的的更新,这样复合单一设计的原则,而且 webpack 在不使用 dev-server的情况下,可以配合 webpack-hot-middleware 来实现热更新功能,webpack-hot-middleware使用的协议并不是websocket而是SSE协议,这种协议在flutter-devtools也有使用到,可以很好的把模块的功能进行解耦。

# 第五步:HotModuleReplacement.runtime 更新模块

在上述 jsonp 请求中我们看到其调用了 webpackHotUpdate 这个方法,通过打包出来的 bundle.js可以看到它又调用了JsonpMainTemplate.runtime中的webpackHotUpdateCallback,这其中主要是调用了 HotModuleReplacement.runtimehotAddUpdateChunk,后面一系列的调用就不一一详述了,我们来看一下最为核心的方法hotApply。由于这个方法实现的代码较多,我们可以追踪hotStatus的状态变化来梳理更新的逻辑。

  • "idle" => "check",这个阶段 module.hot.check 方法。
  • "check" => "prepare",这个阶段通过上述的 hotDownloadManifest获取到了最新的文件列表。
  • "prepare" => "ready",这个阶段,将新的模块存贮到了 hotUpdate 之中。
  • "ready" => "dispose",这个阶段经历了两个循环,在第一个循环中,先通过 getAffectedStuff 方法找到当前这个新模块所有相关的模块outdatedModules依赖outdatedDependenciesresult返回值如下,如果没有找到的话,将 result.type 表示为 disposed。正常返回 result.typeaccepted
let result = {
  "type":"accepted",
  "moduleId":"./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&",
  "outdatedModules":[
      "./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&",
      "./test.vue?vue&type=script&lang=js&",
      "./test.vue"
  ],
  "outdatedDependencies":{}
}
1
2
3
4
5
6
7
8
9
10

在第二个循环中,找出outdatedModules中,并且已经被使用的模块中 hot._selfAcceptedtrue的模块,这些模块通过都自己调用的 module.hot.accep这个方法,对于 vue组件来说,这部分的代码是在vue-loader中注入的,核心代码如下:

if (
  installedModules[moduleId] &&
  installedModules[moduleId].hot._selfAccepted &&
  // removed self-accepted modules should not be required 不需要移除自我接受的模块
  appliedUpdate[moduleId] !== warnUnexpectedRequire
) {
  outdatedSelfAcceptedModules.push({
    module: moduleId,
    errorHandler: installedModules[moduleId].hot._selfAccepted
  });
}
1
2
3
4
5
6
7
8
9
10
11
  • "dispose" => "apply",在这个阶段,遍历outdatedModules,从installedModules已经安装的模块中删除过期模块以及被该模块引用的子模块,还要删除删除依赖,核心代码如下:
var queue = outdatedModules.slice();
while (queue.length > 0) {
  moduleId = queue.pop();
  module = installedModules[moduleId];
  if (!module) continue;
  // remove module from cache
  delete installedModules[moduleId];
  // when disposing there is no need to call dispose handler
  delete outdatedDependencies[moduleId];
  // remove "parents" references from all children
  for (j = 0; j < module.children.length; j++) {
    var child = installedModules[module.children[j]];
    if (!child) continue;
    idx = child.parents.indexOf(moduleId);
    if (idx >= 0) {
      child.parents.splice(idx, 1);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  • "apply" => "fail" or "idle",在这个阶段主要是更新模块,先将可被更新的模块appliedUpdate挂载到全局的modules上,对于 outdatedSelfAcceptedModules中的模块,会调用 $require$(moduleId),这里的$require$就是打包出来的 __webpack_require__函数,主要是用来加载模块的。

# 第六步:对 vue 组件是如何自定更新的

test.vue这个模块在被vue-loader解析时,会通过vue-loader/codegen/hotReload.js中的genHotReloadCode方法添加如下代码,其中实现不刷新页面来更新vue组件的核心逻辑在vue2都是由vue-hot-reload-api这个库提供的。

// vue-loader/codegen/hotReload.js genHotReloadCode
if (module.hot) {
  var api = require("xxx/node_modules/vue-hot-reload-api/dist/index.js")
  api.install(require('vue'))
  if (api.compatible) {
    module.hot.accept()
    if (!api.isRecorded('13429420')) {
      api.createRecord('13429420', component.options)
    } else {
      api.reload('13429420', component.options)
    }
    module.hot.accept("./test.vue?vue&type=template&id=13429420&scoped=true&", function () {
      api.rerender('13429420', {
        render: render,
        staticRenderFns: staticRenderFns
      })
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这里 vue-hot-reload-api 为了支持更热新,它的核心实现逻辑是,在组件的生命周期的钩子函数中,插入保存组件上下文的对象的函数,初次渲染是保存当前的组件上下文;当第二次热更新的,先替换组件的 options, 然后调用组件的 $forceUpdate方法来重新渲染。

# 总结

  1. 通过本文大概了解 webpack hmr整个工作的流程,还有很多的细节没有捕捉到位,比如 webpack-dev-server/client 代码的插入,hotapply里面的弯弯绕绕,以及整个webpack的模块机制的实现,欢迎小伙伴积极探讨。
  2. 本文参考了 Webpack HMR 原理解析 (opens new window),感谢大佬的付出,觉得这样的行文思路非常值得借鉴。

# 参考文献

上次更新: 2/15/2025, 2:29:28 PM