HotModulePlacement

服务器部分

  • 启动webpack-dev-server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173

  • 创建 Webpack 实例,源代码地址@webpack-dev-server/webpack-dev-server.js#L89

    const compiler = webpack(config)
    
  • 创建 Server 服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L107

    class Server {
      constructor(compiler) {
        this.compiler = compiler
      }
      listen(port) {
        this.server.listen(port, () => {
          console.log(`服务器已经在${port}端口上启动了`)
        })
      }
    }
    let server = new Server(compiler)
    server.listen(8000)
    
  • 添加 Webpack 的done事件回调,源代码地址@webpack-dev-server/Server.js#L122

      constructor(compiler) {
        let sockets = []
        let lasthash
        compiler.hooks.done.tap('webpack-dev-server', (stats) => {
          lasthash = stats.hash
          // 每当新一个编译完成后都会向客户端发送消息
          sockets.forEach(socket => {
            socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
            socket.emit('ok') // 再向客户端发送一个ok
          })
        })
      }
    

    Webpack 编译后提供提供了一系列钩子函数,以供插件能访问到它的各个生命周期节点,并对其打包内容做修改。compiler.hooks.done则是插件能修改其内容的最后一个节点。

    编译完成通过 Socket 向客户端发送消息,推送每次编译产生的 hash。另外如果是热更新的话,还会产出二个补丁文件, 里面描述了从上一次结果到这一次结果都有哪些 chunk 和模块发生了变化。

    使用let sockets = []数组去存放当打开了多个Tab时每个Tab的 socket实例。

  • 创建express应用app,源代码地址@webpack-dev-server/Server.js#L123

    let app = new express()

  • 设置文件系统为内存文件系统,源代码地址@webpack-dev-middleware/fs.js#L115

    let fs = new MemoryFileSystem()

    使用MemoryFileSystemcompiler的产出文件打包到内存中

  • 添加webpack-dev-middleware中间件,中间件负责返回生成的文件,源代码地址@webpack-dev-server/Server.js#L125

      function middleware(req, res, next) {
        if (req.url === '/favicon.ico') {
          return res.sendStatus(404)
        }
        // /index.html   dist/index.html
        let filename = path.join(config.output.path, req.url.slice(1))
        let stat = fs.statSync(filename)
        if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
          let content = fs.readFileSync(filename)
          let contentType = mime.getType(filename)
          res.setHeader('Content-Type', contentType)
          res.statusCode = res.statusCode || 200
          res.send(content)
        } else {
          return res.sendStatus(404)
        }
      }
      app.use(middleware)
    

    使用express启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件

  • 启动webpack编译,源代码地址@webpack-dev-middleware/index.js#L51, 编译完成向客户端发送消息@webpack-dev-server/Server.js#L184

     compiler.watch({}, err => {
        console.log('又一次编译任务成功完成了')
      })
    

    以监控的模式启动一次webpack编译,当编译成功之后执行回调

  • 创建http服务器并启动服务,源代码地址@webpack-dev-server/Server.js#L135

      constructor(compiler) {
        // ...
        this.server = require('http').createServer(app)
        // ...
      }
      listen(port) {
        this.server.listen(port, () => {
          console.log(`服务器已经在${port}端口上启动了`)
        })
      }
    
  • 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745

    constructor(compiler) {
        // ...
        this.server = require('http').createServer(app)
        let io = require('socket.io')(this.server)
        io.on('connection', (socket) => {
          sockets.push(socket)
          socket.emit('hash', lastHash)
          socket.emit('ok')
        })
      }
    
  • 创建socket服务器,源代码地址@webpack-dev-server/SockJSServer.js#L34

    启动一个 websocketwebpack-dev-middleware服务器,然后等待连接来到,连接到来之后存进sockets池

    当有文件改动,webpack重新编译时,向客户端推送hashok两个事件

客户端部分

  • webpack-dev-server/client端会监听到此hash消息,源代码地址@webpack-dev-server/index.js#L54

    <script src="/socket.io/socket.io.js"></script>

    let socket = io('/')
    socket.on('connect', onConnected)
    const onConnected = () => {
      console.log('客户端连接成功')
    }
    let hotCurrentHash // lastHash 上一次 hash值 
    let currentHash // 这一次的hash值
    socket.on('hash', (hash) => {
      currentHash = hash
    })
    
  • 客户端收到ok的消息后会执行reloadApp方法进行更新,源代码地址index.js#L101

    socket.on('ok', () => {
      reloadApp(true)
    })
    
  • reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器,源代码地址reloadApp.js#L7

    // 当收到ok事件后,会重新刷新app
    function reloadApp(hot) {
      if (hot) { // 如果hot为true 走热更新的逻辑
        hotEmitter.emit('webpackHotUpdate')
      } else { // 如果不支持热更新,则直接重新加载
        window.location.reload()
      }
    }
    
  • webpack/hot/dev-server.js会监听webpackHotUpdate事件,源代码地址dev-server.js#L55

    首先需要一个发布订阅去绑定事件并在合适的时机触发

    class Emitter {
      constructor() {
        this.listeners = {}
      }
      on(type, listener) {
        this.listeners[type] = listener
      }
      emit(type) {
        this.listeners[type] && this.listeners[type]()
      }
    }
    let hotEmitter = new Emitter()
    hotEmitter.on('webpackHotUpdate', () => {
      if (!hotCurrentHash || hotCurrentHash == currentHash) {
        return hotCurrentHash = currentHash
      }
      hotCheck()
    })
    

    会判断是否为第一次进入页面和代码是否有更新。

  • check方法里会调用module.hot.check方法,源代码地址dev-server.js#L13

    function hotCheck() {
      hotDownloadManifest().then(update => {
        let chunkIds = Object.keys(update.c)
        chunkIds.forEach(chunkId => {
          hotDownloadUpdateChunk(chunkId)
        })
      })
    }
    
    
  • HotModuleReplacement.runtime请求Manifest,源代码地址HotModuleReplacement.runtime.js#L180

  • 它通过调用 JsonpMainTemplate.runtimehotDownloadManifest方法,源代码地址JsonpMainTemplate.runtime.js#L23

    上面也提到过webpack每次编译都会产生hash值、已改动模块的json文件、已改动模块代码的js文件,

    此时先使用ajax请求Manifest即服务器这一次编译相对于上一次编译改变了哪些modulechunk

    然后再通过jsonp获取这些已改动的modulechunk的代码。

  • 调用JsonpMainTemplate.runtimehotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码,源代码地址JsonpMainTemplate.runtime.js#L14

    function hotDownloadUpdateChunk(chunkId) {
    let script = document.createElement('script')
    script.charset = 'utf-8'
    // /main.xxxx.hot-update.js
    script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
    document.head.appendChild(script)
    }
    

    这里解释下为什么使用JSONP获取而不直接利用socket获取最新代码?主要是因为JSONP获取的代码可以直接执行

  • 补丁JS取回来后会调用JsonpMainTemplate.runtime.jswebpackHotUpdate方法,源代码地址JsonpMainTemplate.runtime.js#L8

    window.webpackHotUpdate = function (chunkId, moreModules) {
      // 循环新拉来的模块
      for (let moduleId in moreModules) {
        // 从模块缓存中取到老的模块定义
        let oldModule = __webpack_require__.c[moduleId]
        // parents哪些模块引用这个模块 children这个模块引用了哪些模块
        // parents=['./src/index.js']
        let {
          parents,
          children
        } = oldModule
        // 更新缓存为最新代码 缓存进行更新
        let module = __webpack_require__.c[moduleId] = {
          i: moduleId,
          l: false,
          exports: {},
          parents,
          children,
          hot: window.hotCreateModule(moduleId)
        }
        moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
        module.l = true // 状态变为加载就是给module.exports 赋值了
        parents.forEach(parent => {
          // parents=['./src/index.js']
          let parentModule = __webpack_require__.c[parent]
          // _acceptedDependencies={'./src/title.js',render}
          parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
        })
        hotCurrentHash = currentHash
      }
    }
    
  • 然后会调用HotModuleReplacement.runtime.jshotAddUpdateChunk方法动态更新模块代码,源代码地址HotModuleReplacement.runtime.js#L222

  • 然后调用hotApply方法进行热更新,源代码地址HotModuleReplacement.runtime.js#L257HotModuleReplacement.runtime.js#L278

Q&A

webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?

webpack-dev-server使用内存文件系统,来保存打包的文件,利用了memory-fs模块,之所以这么做是因为访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销

通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?

使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件

使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?

  1. 编译完成通过socket向客户端发送消息,推送每次编译产生的hash

  2. 此时先使用ajax请求Manifest即服务器这一次编译相对于上一次编译改变了哪些module和chunk。

  3. 然后再通过jsonp获取这些已改动的module和chunk的代码。

为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?

因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP获取的代码可以直接执行。

浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?

  • 从缓存中删除过期的模块和依赖

    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__ (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了

    for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
        var item = outdatedSelfAcceptedModules[i];
        moduleId = item.module;
        try {
            // 执行最新的代码
            __webpack_require__(moduleId);
        } catch (err) {
            // ...容错处理
        }
    }
    
    

当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

如果替换失败,则 window.location.reload()

为什么编辑main.js时还是刷新整个页面

就如上一个问题所说的。其原因当块更新,更新事件会一层一层向上传递,当传递到了最外层 main.js 中,如果 main.js 中定义的 accept 函数就会被 accept 接收然后执行我们定义的 callback 函数。但是如果事件一直往上拋,到了最外层都没有文件接收它,则会直接刷新页面。

那为什么我们没定义接收 CSS 的地方,可修改 CSS 文件时,并不是刷新页面,而是触发模块热更新呢?

原因在于 style-loader 会注入用于接收 CSS 的代码

  import React from 'react'
  import { render } from 'react-dom'
  import { AppComponent } from 'react-dom'
  import './main.css'

  render(<AppComponent>, window.document.getElementById('app'))
  // 只有当开启了模块热替换时,module.hot才存在
  if(module.hot){
    // module.hot.accept函数的第一个参数指出当前文件接收哪些子模块的替换,这里表示只接受'./AppComponent'这个子模块
    // 第二个参数表示模块更新时要执行的逻辑
    module.hot.accept(['./AppComponent'], () => {
      render(<AppComponent>, window.document.getElementById('app'))
    })
  }

总结

WDS与浏览品之间维护一个 Websocket ,当本地资源发生变化后,WDS 会向浏览器推送更新,推送更新的模块 hash,让客户端与现有资源做 对比。客户端对比差异后,向WDS 发起 AJAX 请求来获取更改内容(文件列表, hash),这样客户端就可以借助这些信息继续向 WDS 发起 jsonp 请求 获取该 chunk的增量更新

后续的部分(拿到增量更新之后如何处理?哪些状态该保留?哪些又需要更新?)由 HotModulePlugin 来完成,提供了相关 API 以供开发者针对自身场景进行处理, react-hot-loadervue-loader 都是借助这些 API 实现 HMR。

其它

在发生模块热替换时,我们会在浏览器的控制台中看到一些输出信息,其中有 Updated modules:[数字]的信息输出,这个 [数字] 表示 ID 为几的模块被替换了,这对开发者不是很友好,因为开发都不知道ID 和模块之间的对应关系,最好是模块名字代替这个 ID 输出。Webpack 内置的 NameModulesPlugin 插件可以解决这个问题,修改 Webpack 配置文件接入该插件:

 const NameModeulesPlugin = require('webpack/lib/NameModulesPlugin')
  module.exports = {
    plugins: [
      // 显示出被替换模块的名称
      new NameModeulesPlugin()
    ]
  }

重新构建后,我们就能发现输出的日志发生了变化

除此之外,模块热替换还面临和自动刷新一样的性能问题,因为它们都需要监听文件的变化和注入客户端。优化模块热替换的构建性能的思路和之前优化自动更新的思路类似:监听更少的文件,忽略 node_mosules 目录下的文件。

但是其中提到的关闭默认的 inline 模式且手动注入代理客户端的优化方法,不能用于使用模块热替换的情况,原因在于模块热替换的运行依赖在每个 Chunk 中都包含代理客户端的代码。

搞懂webpack热更新原理open in new window

Webpack HMR 原理解析open in new window

Last Updated:
Contributors: 156081289@qq.com