Webpack 热更新原理

HMR 的 WebSocket 通信与模块替换机制

问题

Webpack 热更新 (HMR) 是如何实现的?WebSocket 和 manifest 在其中扮演什么角色?

解答

HMR 整体流程

文件修改 → Webpack 编译 → 生成更新文件 → WebSocket 通知 → 浏览器拉取更新 → 替换模块

1. 建立 WebSocket 连接

webpack-dev-server 启动时,会在浏览器和服务器之间建立 WebSocket 连接:

// webpack-dev-server 简化实现
const WebSocket = require('ws');

class HMRServer {
  constructor(compiler) {
    this.sockets = [];
    
    // 监听 webpack 编译完成
    compiler.hooks.done.tap('HMRServer', (stats) => {
      // 向所有客户端发送更新通知
      this.sockets.forEach(socket => {
        socket.send(JSON.stringify({
          type: 'hash',
          data: stats.hash  // 本次编译的 hash 值
        }));
        socket.send(JSON.stringify({
          type: 'ok'  // 通知客户端可以更新
        }));
      });
    });
  }

  // 创建 WebSocket 服务
  createServer(server) {
    const wss = new WebSocket.Server({ server });
    wss.on('connection', (socket) => {
      this.sockets.push(socket);
    });
  }
}

2. 客户端接收更新通知

浏览器端的 HMR runtime 监听 WebSocket 消息:

// HMR 客户端代码(简化版)
const socket = new WebSocket('ws://localhost:8080');
let currentHash = __webpack_hash__;  // 当前模块的 hash

socket.onmessage = (event) => {
  const message = JSON.parse(event.data);
  
  if (message.type === 'hash') {
    // 保存最新的 hash
    currentHash = message.data;
  }
  
  if (message.type === 'ok') {
    // 检查是否需要更新
    if (currentHash !== __webpack_hash__) {
      hotCheck();
    }
  }
};

// 检查并应用更新
async function hotCheck() {
  try {
    // 拉取 manifest 文件
    const update = await module.hot.check(true);
    if (update) {
      console.log('HMR 更新成功:', update);
    }
  } catch (err) {
    // 更新失败,刷新页面
    window.location.reload();
  }
}

3. Manifest 和更新文件

Webpack 编译后生成两个关键文件:

// [hash].hot-update.json (manifest 文件)
// 描述哪些 chunk 需要更新
{
  "c": { "main": true },  // 需要更新的 chunk
  "r": [],                // 需要移除的 chunk
  "m": []                 // 需要移除的模块
}

// [chunkId].[hash].hot-update.js (更新的模块代码)
self["webpackHotUpdate"]("main", {
  "./src/app.js": (module, exports, __webpack_require__) => {
    // 新的模块代码
    module.exports = function() {
      console.log('Updated!');
    };
  }
});

4. 模块替换过程

// HMR runtime 核心逻辑(简化版)
const installedModules = {};  // 已安装的模块缓存

function hotApply(options) {
  // 1. 找出过期模块
  const outdatedModules = getOutdatedModules();
  
  // 2. 从缓存中移除过期模块
  outdatedModules.forEach(moduleId => {
    delete installedModules[moduleId];
  });
  
  // 3. 执行新模块代码
  for (const moduleId in updatedModules) {
    // 将新模块添加到 modules 对象
    modules[moduleId] = updatedModules[moduleId];
  }
  
  // 4. 重新执行父模块,触发依赖更新
  outdatedModules.forEach(moduleId => {
    const module = installedModules[moduleId];
    if (module && module.hot && module.hot._acceptedDependencies[moduleId]) {
      // 执行 accept 回调
      module.hot._acceptedDependencies[moduleId]();
    }
  });
}

5. 业务代码中使用 HMR

// src/app.js
import { render } from './render';

render();

// 声明该模块支持热更新
if (module.hot) {
  module.hot.accept('./render', () => {
    // 当 render.js 更新时,重新执行
    render();
  });
}

完整流程图

┌─────────────┐    文件变化    ┌─────────────┐
│   文件系统   │ ───────────→ │   Webpack   │
└─────────────┘               └──────┬──────┘
                                     │ 编译

                              ┌─────────────┐
                              │ 生成更新文件 │
                              │ - manifest  │
                              │ - update.js │
                              └──────┬──────┘

        WebSocket 推送 hash          │
┌─────────────┐ ←────────────────────┘
│   浏览器    │
│ HMR Runtime │
└──────┬──────┘
       │ JSONP 请求 manifest

┌─────────────┐
│ 拉取更新文件 │
└──────┬──────┘
       │ 替换模块

┌─────────────┐
│ 执行 accept │
│   回调函数   │
└─────────────┘

关键点

  • WebSocket 通信:用于服务器向浏览器推送更新通知,只传递 hash 值,不传输代码
  • Manifest 文件:JSON 格式,描述哪些 chunk/模块需要更新
  • JSONP 拉取:浏览器通过 JSONP 方式请求更新的模块代码
  • 模块缓存替换:删除旧模块缓存,执行新模块代码,触发 accept 回调
  • 降级处理:如果模块没有声明 accept,或更新失败,会冒泡到入口模块,最终刷新页面