webpack 模块热更新原理

什么是 模块热更新

模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。

  • 核心问题:
    1. HMR 是怎样实现自动编译的?
    2. 模块内容的变更浏览器是如何感知的?
    3. 新产生的两个文件是干嘛的?
    4. 局部更新是如何做到的?

下面让我们带着这些疑问,一起来探索模块热更新的原理。

模块热更新的配置

在学习原理前,来回顾一下配置流程,这样更有助于对源码的理解。

  • 第一步:安装 webpack-dev-server

    npm install --save-dev. webpack-dev-server
    
  • 第二步:在父模块中注册 module.hot.accept 事件

    //src/index.js
    let div = document.createElement('div');
    document.body.appendChild(div);
    let input = document.createElement('input');
    document.body.appendChild(input);
    let render = () => {
        let title = require('./title.js')
        div.innerHTML = title;
    }
    render()
    //添加如下内容
    + if (module.hot) {
    +     module.hot.accept(['./title.js'], render)
    + }
    
    // 子模块 src/title.js
    
    module.exports = 'Hello webpack'
    
  • 第三步:在 webpack.config.js 中配置 hot:true

    module.exports = {
      devServer: {
        hot: true,
      },
    }
    

问题:为什么平时修改代码的时候不用监听 module.hot.accept 也能实现热更新?

因为我们使用的 `loader` 已经在幕后帮我们实现了。

webpack-dev-server 提供了实时重加载的功能,但是不能局部刷新。必须配合后两步的配置才能实现局部刷新,这两步的背后其实是借助了 HotModuleReplacementPlugin。可以说 HMRwebpack-dev-serverHotModuleReplacementPlugin 共同的功劳。

热更新原理

  • Webpack-dev-server 都做了哪些事?

    1. 开启本地服务

    首先通过 webpack 创建了一个 compiler 实例,然后通过创建自定义 server 实例,开启了一个本地服务。

    // node_modules/webpack-dev-server/bin/webpack-dev-server.js
    const webpack = require('webpack')
    const config = require('../../webpack.config')
    const Server = require('../lib/Server')
    const compiler = webpack(config)
    const server = new Server(compiler)
    server.listen(8080, 'localhost', () => {})
    

    这个自定义 Server 不仅是创建了一个 http 服务,它还基于 http 服务创建了一个 websocket 服务,同时监听浏览器的接入,当浏览器成功接入时向它发送 hash 值,从而实现服务端和浏览器间的双向通信。

    // node_modules/webpack-dev-server/lib/Server.js
    class Server {
      constructor() {
        this.setupApp()
        this.createServer()
      }
      // 创建http应用
      setupApp() {
        this.app = express()
      }
      // 创建http服务
      createServer() {
        this.server = http.createServer(this.app)
      }
      // 监听端口号
      listen(port, host, callback) {
        this.server.listen(port, host, callback)
        this.createSocketServer()
      }
      // 基于http服务创建websocket服务,并注册监听事件connection
      createSocketServer() {
        const io = socketIO(this.server)
        io.on('connection', (socket) => {
          this.clientSocketList.push(socket)
          socket.emit('hash', this.currentHash)
          socket.emit('ok')
          socket.on('disconnect', () => {
            let index = this.clientSocketList.indexOf(socket)
            this.clientSocketList.splice(index, 1)
          })
        })
      }
    }
    module.exports = Server
    
    1. 监听编译完成

    仅仅在建立 websocket 连接时,服务端向浏览器发送 hash 和拉取代码的通知还不够,我们还希望当代码改变时,浏览器也可以接到这样的通知。于是,在开启服务前,还需要对编译完成事件进行监听。

    //监听编译完成,当编译完成后通过websocket向浏览器发送广播
      setupHooks() {
          let { compiler } = this;
          compiler.hooks.done.tap('webpack-dev-server', (stats) => {
              this.currentHash = stats.hash;
              this.clientSocketList.forEach((socket) => {
                  socket.emit('hash', this.currentHash);
                  socket.emit('ok');
              })
          })
      }
    
    1. 监听文件修改

    要想在代码修改的时候,触发重新编译,那么就需要对代码的变动进行监听。这一步,源码是通过 webpackDevMiddleware 库实现的。库中使用了 compiler.watch 对文件的修改进行了监听,并且通过 memory-fs 实现了将编译的产物存放到内存中,这也是为什么我们在 dist 目录下看不到变化的内容,放到内存的好处就是为了更快的读写从而提高开发效率。

    // node_modules/webpack-dev-middleware/index.js
    const MemoryFs = require('memory-fs')
    compiler.watch({}, () => {})
    let fs = new MemoryFs()
    this.fs = compiler.outputFileSystem = fs
    
    1. 向浏览器中插入客户端代码

    前面提到要想实现浏览器和本地服务的通信,那么就需要浏览器接入到本地开启的 websocket 服务,然而浏览器本身并不具备这样的能力,这就需要我们自己提供这样的客户端代码将它运行在浏览器。因此自定 Server 在开启 http 服务之前,就调用了 updateCompiler()方法,它修改了 webpack 配置中的 entry,使得插入的两个文件的代码可以一同被打包到 main.js 中,运行在浏览器。

    // node_modules/webpack-dev-server/lib/utils/updateCompiler.js
    const path = require('path')
    function updateCompiler(compiler) {
      compiler.options.entry = {
        main: [
          path.resolve(__dirname, '../../client/index.js'),
          path.resolve(__dirname, '../../../webpack/hot/dev-server.js'),
          config.entry,
        ],
      }
    }
    module.exports = updateCompiler
    

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

    这段代码会放在浏览器作为客户端代码,它用来建立 websocket 连接,当服务端发送 hash 广播时就保存 hash,当服务端发送 ok 广播时就调用 reloadApp()。

    let currentHash
    let hotEmitter = new EventEmitter()
    const socket = window.io('/')
    socket.on('hash', (hash) => {
      currentHash = hash
    })
    socket.on('ok', () => {
      reloadApp()
    })
    function reloadApp() {
      hotEmitter.emit('webpackHotUpdate', currentHash)
    }
    

    webpack/hot/dev-server.js

    reloadApp()继续调用 module.hot.check(),当然第一次加载页面时是不会被调用的。至于这里为啥会分成两个文件,个人理解是为了解藕,每个模块负责不同的分工。

    let lastHash
    hotEmitter.on('webpackHotUpdate', (currentHash) => {
      if (!lastHash) {
        lastHash = currentHash
        return
      }
      module.hot.check()
    })
    
  • HotModuleReplacementPlugin

    1. 为模块添加 hot 属性 前面提到过,当代码发生改动时,服务端会向浏览器发送 ok 消息,浏览器会执行 module.hot.check 进行模块热检查。check 方法就是来源于这里了。

      function hotCreateModule() {
        let hot = {
          _acceptedDependencies: {},
          accept(deps, callback) {
            deps.forEach((dep) => (hot._acceptedDependencies[dep] = callback))
          },
          check: hotCheck,
        }
        return hot
      }
      
    2. 请求补丁文件 module.hot.check()就是调用 hotCheck,此时浏览器会向服务端获取两个补丁文件。

      function hotCheck() {
        hotDownloadManifest()
          .then((update) => {
            //{"h":"eb861ba9f6408c42f1fd","c":{"main":true}}
            let chunkIds = Object.keys(update.c) //['main']
            chunkIds.forEach((chunkId) => {
              hotDownloadUpdateChunk(chunkId)
            })
            lastHash = currentHash
          })
          .catch(() => {
            window.location.reload()
          })
      }
      
      • 先看一眼这两个文件长什么样

        1. d04feccfa446b174bc10.hot-update.json,告知浏览器新的 hash 值,并且是哪个 chunk 发生了改变
        2. main.d04feccfa446b174bc10.hot-update.js 告知浏览器,main 代码块中的/src/title.js 模块变更的内容

        首先是通过 XMLHttpRequest 的方式,利用上一次保存的 hash 值请求 hot-update.json 文件。这个描述文件的作用就是提供了修改的文件所在的 chunkId。

        function hotDownloadManifest() {
          return new Promise(function(resolve, reject) {
            let xhr = new XMLHttpRequest()
            let url = `${lastHash}.hot-update.json`
            xhr.open('get', url)
            xhr.responseType = 'json'
            xhr.onload = function() {
              resolve(xhr.response)
            }
            xhr.send()
          })
        }
        

        然后通过 JSONP 的方式,利用 hot-update.json 返回的 chunkId 及 上一次保存的 hash 拼接文件名进而获取文件内容。

        function hotDownloadUpdateChunk(chunkId) {
          let script = document.createElement('script')
          script.src = `${chunkId}.${lastHash}.hot-update.js`
          document.head.appendChild(script)
        }
        window.webpackHotUpdate = function(chunkId, moreModules) {
          hotAddUpdateChunk(chunkId, moreModules)
        }
        
    3. 模块内容替换 当 hot-update.js 文件加载好后,就会执行 window.webpackHotUpdate,进而调用了 hotApply。hotApply 根据模块 ID 找到旧模块然后将它删除,然后执行父模块中注册的 accept 回调,从而实现模块内容的局部更新。

      window.webpackHotUpdate = function(chunkId, moreModules) {
        hotAddUpdateChunk(chunkId, moreModules)
      }
      let hotUpdate = {}
      function hotAddUpdateChunk(chunkId, moreModules) {
        for (let moduleId in moreModules) {
          modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId]
        }
        hotApply()
      }
      function hotApply() {
        for (let moduleId in hotUpdate) {
          let oldModule = installedModules[moduleId]
          delete installedModules[moduleId]
          oldModule.parents.forEach((parentModule) => {
            let cb = parentModule.hot._acceptedDependencies[moduleId]
            cb && cb()
          })
        }
      }
      

总结

在执行 npm run dev 后,首先会通过 updateCompiler 方法去修改 compiler 的 entry,将两个文件的代码一起打包到 main.js,这两个文件一个是用来与服务端进行通信的,一个是用来调用 module.hot.check 的。接着通过 compiler.hooks.done.tap 来监听编译完成,通过 compiler.watch 监听代码的改动,通过 createSocketServer()开启 http 服务和 websocekt 服务。

当用户访问http://localhost:8080时,浏览器会与服务端建立websocket连接。随后服务端向浏览器发送hash 和 ok ,用来通知浏览器当前最新编译版本的 hash 值和告诉浏览器拉取代码。同时服务端,会根据路由,将内存中的文件返回,此时浏览器保存 hash,页面内容出现。

当修改本地代码时,会触发重新编译,此时 webpackDevMiddleWare 会将编译的产物保存到内存中,这得益于内置模块 memory-fs 的功劳。同时 HotModuleReplacementPlugin 会生成两个补丁包,这两个补丁包一个是用来告诉浏览器哪个 chunk 变更了,一个是用来告诉浏览器变更模块及内容。当重新编译完成,浏览器会保存当前 hash,然后通上一次的 hash 值拼接出要请求的描述文件路径,再根据描述文件返回的内容,拼接出要另一个要请求的补丁包文件。请求成功就开始执行 webpackHotUpdate 了,会继续调用 hotApply,实质就是执行了我们当初在配置模块热更新第二步中的回调事件,从而实现了页面内容的局部刷新。