TOP ⬆

Webpack HMR 更新原理解析

概念

HMR 即 hot module replacement ,模块热更新。一句话原理解释就是:

  1. 开发服务器监测到文件变更后,只把变化了的模块编译成增量补丁发给浏览器;
  2. 浏览器的「HMR 运行时」通过 module.hot 方法将旧模块替换成新模块;
  3. 更新完执行/卸载回调,回调由用户手动编写或通过常见的插件添加;
  4. 通过回调中的代码让页面更新。

各个流程简要分析

  1. 文件变更导致的编译增量 bundle。 webpack-dev-server 监测到文件变更,仅构建受影响的模块,并通过 web-socket 推送更新信息给浏览器

  2. 浏览器加载更新包。 更新信息包含哪些模块更新,以及如何拉去更新包的元信息,HMR 运行时通过代码请求下载并更新模块

  3. 替换模块并执行回调。 检查模块或者父级模块是否使用了 module.hot.accept(...) ,运行时会执行该回调,回调里通常包含 re-require/render 等方法;如果没有使用处理回调,运行时直接触发整个页面的更新

  4. 样式的更新链路。style-loader 会把 css 直接注入到 style 标签中,更新时也会直接替换当前的内容,浏览器自动应用新样式,无需 JS 挂载

React 如何在 HMR 自动执行新代码

因为在入口文件中的某一个位置存在代码为 module.hot.accept('./App',()=>{ ... re-render ...}) 注册了更新的回调函数。

常见的插件如 react-refresh 或者 react-hot-loader 更新还能尽量保留组件本地的 state ,不需要整条链路卸载重新挂载。

手动实现一下 react-refresh 插件

原理要点:

代码示例:

// src/index.jsx
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);

function render(Component) {
  root.render(<Component />);
}

render(App);

// 手动 HMR 接受 App 更新(webpack 提供 module.hot)
if (module.hot) {
  module.hot.accept('./App', () => {
    // 这里使用 require 获取最新的模块实现(CommonJS),注意 webpack 会把 ES 模块打包成可兼容的形式
    const NextApp = require('./App').default;
    render(NextApp);
  });
}
// src/App.jsx
import React, { useState, useEffect } from 'react';

/**
 * 演示如何在纯手动 HMR 下手动保留组件 state:
 * - 在 effect 里注册 module.hot.dispose,把当前 state 存到 data 上
 * - 初始化时从 module.hot.data 读取(若存在)
 */
export default function App() {
  // 若上一个 module 在 dispose 时保存了数据,会挂到 module.hot.data
  const prev = (module.hot && module.hot.data && module.hot.data.saved) || {};
  const [count, setCount] = useState(prev.count || 0);

  useEffect(() => {
    if (module.hot) {
      // 在模块替换之前运行,把要保留的数据写入 data
      module.hot.dispose((data) => {
        data.saved = { count };
      });
    }
  }, [count]);

  return (
    <div style={{ padding: 20 }}>
      <p>修改这个文件并保存,HMR 会替换模块并触发 accept 回调。</p>
    </div>
  );
}

为什么 React 会“执行”新代码?

如何保留 state(手动)?

why react-refresh

为什么用 react-refresh?

要做的代码级改动

  1. 在 Babel 配置里加 react-refresh/babel 插件(仅 dev)。

  2. 插件会在编译后生成一些运行时代码(层面上是注入标识和注册函数)。

  3. 在 webpack dev 环境中加入 @pmmmwh/react-refresh-webpack-plugin,它会注入 client runtime 支持、在模块热替换时调用 react-refresh 的 runtime。

  4. 代码上你不需要写 module.hot.accept(插件会自动处理大多数模块边界),但仍可以在特殊场景用手动 accept

代码层面简要说明

何时回退(fallback)?

CSS 样式的自动更新

核心点

直观理解

HMR 运行时的替换逻辑揭秘

1. Webpack HMR 的运行时核心

在打包后的产物里,Webpack 会构造一个运行时(runtime),它维护了一个「模块系统」,大概类似下面这样(精简版):

// 伪代码
var __webpack_modules__ = {
  "./src/index.js": function(module, exports, __webpack_require__) {
    // 具体的模块代码
  },
  "./src/App.js": function(module, exports, __webpack_require__) {
    // 具体的模块代码
  }
};

var __webpack_module_cache__ = {}; // 已加载模块的缓存

function __webpack_require__(moduleId) {
  if (__webpack_module_cache__[moduleId]) {
    return __webpack_module_cache__[moduleId].exports;
  }
  var module = (__webpack_module_cache__[moduleId] = {
    exports: {}
  });
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  return module.exports;
}

这里:

2. HMR 的更新逻辑

当某个文件改动后(例如 App.js),webpack-dev-server 会:

  1. 重新编译变动过的模块,生成新的 App.js 工厂函数。
  2. 通过 websocket 通知浏览器。
  3. 浏览器端 HMR runtime 收到更新后,替换掉 __webpack_modules__ 里的对应模块函数

大概伪代码:

// 收到新的模块代码
function hotUpdate(newModules) {
  for (var moduleId in newModules) {
    // 替换掉 moduleMap 里旧的模块工厂
    __webpack_modules__[moduleId] = newModules[moduleId];
  }

  // 找到依赖此模块的上层模块,重新执行
  applyUpdate(moduleId);
}

function applyUpdate(moduleId) {
  var oldModule = __webpack_module_cache__[moduleId];
  if (oldModule) {
    // 让旧模块失效
    delete __webpack_module_cache__[moduleId];
  }

  // 重新 require,就会调用新的工厂函数
  __webpack_require__(moduleId);
}

这样一来: