avatar
原创2018/01/08

Webpack 中 HMR 热替换的实现原理

AI 总结

深入解析 Webpack HMR(Hot Module Replacement)热替换的完整实现链路,从 webpack-dev-server 启动本地服务、WebSocket 建立连接,到模块更新文件的生成与推送,再到浏览器端的模块替换执行,完整还原热更新全过程,并附图示说明关键步骤。

webpack-dev-server 启动本地服务

我们根据 webpack-dev-server 的 package.json 中的 bin 命令,可以找到命令的入口文件 bin/webpack-dev-server.js

// node_modules/webpack-dev-server/bin/webpack-dev-server.js
 
// 生成webpack编译主引擎 compiler
let compiler = webpack(config)
 
// 启动本地服务
let server = new Server(compiler, options, log)
server.listen(options.port, options.host, (err) => {
  if (err) {
    throw err
  }
})

本地服务代码

// node_modules/webpack-dev-server/lib/Server.js
class Server {
  constructor() {
    this.setupApp()
    this.createServer()
  }
 
  setupApp() {
    // 依赖了express
    this.app = new express()
  }
 
  createServer() {
    this.listeningApp = http.createServer(this.app)
  }
 
  listen(port, hostname, fn) {
    return this.listeningApp.listen(port, hostname, (err) => {
      // 启动express服务后,启动websocket服务
      this.createSocketServer()
    })
  }
}

这一小节代码主要做了三件事:

  • 启动 webpack,生成 compiler 实例。
  • 使用 express 框架启动本地 server ,让浏览器可以请求本地的静态资源。
  • 本地 server 启动之后,再去启动 websocket 服务,通过 websocket,可以建立本地服务和浏览器的双向通信。

在启动服务前执行了一些特殊操作,具体在 webpack-dev-server/lib/Server.js

修改 webpack.config.js 的 entry 配置

启动本地服务前,调用了 updateCompiler(this.compiler) 方法。这个方法中有 2 段关键性代码。一个是获取 websocket 客户端代码路径,另一个是根据配置获取 webpack 热更新代码路径。

// 获取websocket客户端代码
const clientEntry = `${require.resolve(
  '../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`
 
// 根据配置获取热更新代码
let hotEntry
if (options.hotOnly) {
  hotEntry = require.resolve('webpack/hot/only-dev-server')
} else if (options.hot) {
  hotEntry = require.resolve('webpack/hot/dev-server')
}

修改后的 webpack 入口配置相当于下面这样:

{
  entry: {
    index: [
      // 上面获取的clientEntry
      'xxx/node_modules/webpack-dev-server/client/index.js?<http://localhost:8080>',
      // 上面获取的hotEntry
      'xxx/node_modules/webpack/hot/dev-server.js',
      // 开发配置的入口
      './src/index.js'
    ]
  }
}

自动在入口增加了 2 个文件:

node_modules/webpack-dev-server/client/index.js

这个文件用于 websocket 的,因为 websoket 是双向通信,我们在第 1 步 webpack-dev-server初始化的过程中,启动的是本地服务端的 websocket。但浏览器还没有和服务端通信的代码,因此需要把 websocket客户端通信代码添加到入口文件中。

node_modules/webpack/hot/dev-server.js

该文件主要是用于检查更新逻辑

监听 webpack 编译结束

修改好入口配置后,调用了 setupHooks 方法。 该方法是用来注册监听事件的,监听每次 webpack 编译完成。

// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
  const {done} = compiler.hooks;
  // 监听webpack的done钩子,tapable提供的监听方法
  done.tap('webpack-dev-server', (stats) => {
    this._sendStats(this.sockets, this.getStats(stats));
    this._stats = stats;
  });
};

当监听到一次 webpack 编译结束,就会调用 _sendStats 方法通过 websocket 给浏览器发送通知,ok和hash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑。

// 通过websoket给客户端发消息
_sendStats() {
  this.sockWrite(sockets, 'hash', stats.hash);
  this.sockWrite(sockets, 'ok');
}

webpack 监听文件变化

每次修改代码,就会触发编译。说明还需要监听本地代码的变化,主要是通过 setupDevMiddleware方法实现的。

这个方法主要使用了webpack-dev-middleware库

// node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) => {
  if (err) {
    /*错误处理*/
  }
})
 
// 通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler)
  • 调用了 compiler.watch方法, 该方法主要做了两件事
    • 对本地文件代码进行编译打包。
    • 编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听。(监听本地文件的变化主要是通过文件的生成时间是否有变化)
  • 执行 setFs 方法,这个方法主要目的就是将编译后的文件打包到内存。使用的 memory-fs 库。

浏览器接收到热更新的通知

当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次 webpack 编译结束,_sendStats方法就通过websoket给浏览器发送通知,检查下是否需要热更新。

浏览器是接收到 websocket 的消息主要是通过一开始编译时添加的入口文件

'xxx/node_modules/webpack-dev-server/client/index.js?<http://localhost:8080>'

这个文件的代码会被打包到 bundle.js 中,运行在浏览器中。来看下这个文件的核心代码吧。

// webpack-dev-server/client/index.js
var socket = require('./socket')
var onSocketMessage = {
  hash: function hash(_hash) {
    // 更新currentHash值
    status.currentHash = _hash
  },
  ok: function ok() {
    sendMessage('Ok')
    // 进行更新检查等操作
    reloadApp(options, status)
  }
}
// 连接服务地址socketUrl,?<http://localhost:8080>,本地服务地址
socket(socketUrl, onSocketMessage)
 
function reloadApp() {
  if (hot) {
    log.info('[WDS] App hot update...')
 
    // hotEmitter是EventEmitter的实例
    var hotEmitter = require('webpack/hot/emitter')
    hotEmitter.emit('webpackHotUpdate', currentHash)
  }
}

socket方法建立了websocket和服务端的连接,并注册了 2 个监听事件。

  • hash事件,更新最新一次打包后的 hash 值。
  • ok事件,进行热更新检查

热更新检查事件是调用reloadApp方法。

这就需要上面提到的第二个入口文件

'xxx/node_modules/webpack/hot/dev-server.js'

文件代码如下

// node_modules/webpack/hot/dev-server.js
var check = function check() {
  module.hot
    .check(true)
    .then(function (updatedModules) {
      // 容错,直接刷新页面
      if (!updatedModules) {
        window.location.reload()
        return
      }
 
      // 热更新结束,打印信息
      if (upToDate()) {
        log('info', '[HMR] App is up to date.')
      }
    })
    .catch(function (err) {
      window.location.reload()
    })
}
 
var hotEmitter = require('./emitter')
hotEmitter.on('webpackHotUpdate', function (currentHash) {
  lastHash = currentHash
  check()
})

这里 webpack 监听到了 webpackHotUpdate 事件,并获取最新的hash值,然后终于进行检查更新了。检查更新调用的是module.hot.check方法。该方法来自于HotModuleReplacementPlugin

HotModuleReplacementPlugin

配置热更新和不配置时 bundle.js的区别。

  • 配置了HotModuleReplacementPlugin或 --hot的

  • 没有配置

可以看到 moudle新增了一个属性为 hot,再看 bundle.js中 hotCreateModule方法

所以上面提到的 module.hot.check其实就是 hotCheck 方法

moudle.hot.check 开始热更新

module.hot.check 方法执行

  • 利用上一次保存的hash值,调用 hotDownloadManifest发送 xxx/hash.hot-update.json的ajax请求;

  • 请求结果获取热更新模块,以及下次热更新的 Hash 标识,并进入热更新准备阶段。

    hotAvailableFilesMap = update.c // 需要更新的文件
    hotUpdateNewHash = update.h // 更新下次热更新hash值
    hotSetStatus('prepare') // 进入热更新准备状态
  • 调用hotDownloadUpdateChunk发送xxx/hash.hot-update.js 请求,通过JSONP方式。

function hotDownloadUpdateChunk(chunkId) {
  var script = document.createElement('script')
  script.charset = 'utf-8'
  script.src =
    __webpack_require__.p +
    '' +
    chunkId +
    '.' +
    hotCurrentHash +
    '.hot-update.js'
  if (null) script.crossOrigin = null
  document.head.appendChild(script)
}

请求到的hash.hot-update.js的如下

可以看到,新编译后的代码是在一个webpackHotUpdate函数体内部的。也就是要立即执行webpackHotUpdate这个方法。

webpackHotUpdate方法

window['webpackHotUpdate'] = function (chunkId, moreModules) {
  hotAddUpdateChunk(chunkId, moreModules)
}
  • hotAddUpdateChunk方法会把更新的模块 moreModules 赋值给全局全量 hotUpdate。
  • hotUpdateDownloaded方法会调用 hotApply 进行代码的替换。
function hotAddUpdateChunk(chunkId, moreModules) {
  // 更新的模块moreModules赋值给全局全量hotUpdate
  for (var moduleId in moreModules) {
    if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      hotUpdate[moduleId] = moreModules[moduleId]
    }
  }
  // 调用hotApply进行模块的替换
  hotUpdateDownloaded()
}

hotApply 热更新模块替换

热更新的核心逻辑就在hotApply方法了。

  • 删除过期的模块
var queue = outdatedModules.slice()
while (queue.length > 0) {
  moduleId = queue.pop()
  // 从缓存中删除过期的模块
  module = installedModules[moduleId]
  // 删除过期的依赖
  delete outdatedDependencies[moduleId]
 
  // 存储了被删掉的模块id,便于更新代码
  outdatedSelfAcceptedModules.push({
    module: moduleId
  })
}
  • 将新的模块添加到 modules 中
appliedUpdate[moduleId] = hotUpdate[moduleId]
for (moduleId in appliedUpdate) {
  if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
    modules[moduleId] = appliedUpdate[moduleId]
  }
}
  • 通过__webpack_require__执行相关模块的代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
  var item = outdatedSelfAcceptedModules[i]
  moduleId = item.module
  try {
    // 执行最新的代码
    __webpack_require__(moduleId)
  } catch (err) {
    // ...容错处理
  }
}

总结

HRM 总流程图

代码详细执行流程图

点击播放