Skip to content

HMR 热更新

一、热更新功能

开发过程中针对于指定模块可以实现实时热更新

1.1 基本配置

  • webpack.config.js 配置
js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { HotModuleReplacementPlugin } = require('webpack')

module.exports = {
  devtool: false,
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].js',
    path: path.resolve('dist')
  },
  devServer: {
    port: 3000,
    hot: true,
    writeToDisk: true,
    contentBase: path.resolve(__dirname, 'static')
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    }),
    new HotModuleReplacementPlugin()
  ]
}
  • html 界面内容
html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>热更新功能</title>
</head>

<body>
  <input type="text" value="热更新功能,头疼">
  <div id="root"></div>
</body>

</html>

1.2 热更新本质

  • 热更新类似于局部刷新,因此本质就是想办法拿到那些发生改变了的代码 ,然后重新打包到界面上
  • webpack 内部实现了 HotModuleReplacement 插件,它可以生成二个文件,一个是 json , 一个是 js
  • 第一次打包时不存在热更新,正常走打包逻辑,后续以 watch 的模式监听文件系统当中的文件变化
  • 一旦某个文件发生了变更之后就会重新执行打包动作产出新的内容,然后再将内容变更通知到浏览器
  • 浏览器获取到变更信息之后就会想办法来获取到实际发生了变更的文件内容,然后执行回调展示在界面上

总结:

  1. 新文件产出之后如何通知到浏览器(基于 websocket 通信)
  2. 通信至少会产生两端:服务端(自己写)与客户端(浏览器)
  3. 服务端发送消息给客户端: ok hash
  4. 客户端监听相应事件的触发,请求 json文件,确认发生改变的文件
  5. 客户端请求 js 文件,拿到发生改变的内容,然后执行回调实现更新(http请求)

1.3 入口调试

  1. 以 npm run 的方式执行调试 webpack serve
  2. 在包安装候会将当前包里的 package.json 文件中 bin 键所对应的值自动的添加到 node_modules 下的 .bin 目录
  3. 后续执行 npm run XXX 的时候会自动将 node_modules/.bin 添加至系统环境变量当中
  4. 所以会找到 webpack.cmd ,最终会执行 webpack/bin/webpack.js 文件
  5. 执行 webpack.js 的时候最终找到了 webpack-cli/bin/cli.js 文件执行
  6. 执行 cli.js 文件时会 new webpackCli 实例,然后执行 run 方法,在内部完成 parse 和 compiler 操作
  7. 强行在 getCompiler 处理添加断点,通过调用栈可以找到调用处是 @webpack-cli/serve/index.js
  8. 在上述的 index.js 中导入了 startDevServer.js , 这个模块核心的作用就是返回一个 server 用于处理消息

开启服务

3.1 startDevServer.js

  1. 实现 startDevServer 方法,调用后能开启 http和socket服务端
  2. 当前文件中只负责实现调用,具体的实现在 webpack-dev-server 下 lib/Server.js 实现
js
const webpack = require('webpack')
const config = require('./webpack.config')
const Server = require('./webpack-dev-server/lib/Server')

//! 01 创建 compiler 
const compiler = webpack(config)

//! 02 启动服务器
function startDevServer(compiler, config) {
  const devServerArgs = config.devServer || {}
  const { port = 3000, host = 'localhost' } = devServerArgs
  const server = new Server(compiler, devServerArgs)
  server.listen(port, host, (err) => {
    console.log(`project is running at http://${host}:${port}`)
  })
}

startDevServer(compiler, config)
module.exports = startDevServer

3.2 启动 Http 服务

js
const express = require('express')
const http = require('http')

class Server {
  constructor(compiler, devServerArgs) {
    this.compiler = compiler
    this.devServerArgs = devServerArgs
    this.setupHooks()  // 监听打包,成功后执行编译 
    this.setupApp() // 初始化 express app用于执行中间件 
    this.routes() // 处理路由 
    this.createServer()  // 创建http服务
  }

  setupHooks() {
    // 监听编译成功,完成一次编译后触发done钩子回调 
    this.compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      console.log('编译完成,hash值为', stats.hash)

      // 保存编译后产出的内容清单
      this._stats = stats
    })
  }

  setupApp() {
    // 这里的 app 只是一个路由中间件
    this.app = express()
  }

  routes() {
    // 如果设置了 contentBase 则定为静态目录
    if (this.devServerArgs.devServer.contentBase) {
      this.app.use(express.static(this.devServerArgs.devServer.contentBase))
    }
  }

  createServer() {
    this.server = http.createServer(this.app)
  }

  listen(port, host, callback) {
    this.server.listen(port, host, callback)
  }
}

module.exports = Server

//! 利用 express 开启 http 服务端,同时创建 websocket 服务用于将来通信

3.3 websocket 服务

http 服务用于提供静态资源访问,让用户可以浏览打包后的资源

websoket 服务用于通信,让用户可以知道并获取新的打包资源

js
const express = require('express')
const http = require('http')
const WebSocketIo = require('socket.io')

class Server {
  constructor(compiler, devServerArgs) {
    this.sockets = [] //* + 保存客户端
    this.compiler = compiler
    this.devServerArgs = devServerArgs
    this.setupHooks()  // 监听打包,成功后执行编译 
    this.setupApp() // 初始化 express app用于执行中间件 
    this.routes() // 处理路由 
    this.createServer()  // 创建http服务
    this.createSocketServer() //* + 启动 socket 服务
  }

  setupHooks() {
    // 监听编译成功,完成一次编译后触发done钩子回调 
    this.compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      console.log('编译完成,hash值为', stats.hash)

      //? 每一次新的编译成功之后,都要向客户端发送最新的hash和ok
      // this.sockets.forEach(socket => {
      //   socket.emit('hash', stats.hash)
      //   socket.emit('ok')
      // })

      // 保存编译后产出的内容清单
      this._stats = stats
    })
  }

  setupApp() {
    // 这里的 app 只是一个路由中间件
    this.app = express()
  }

  routes() {
    // 如果设置了 contentBase 则定为静态目录
    if (this.devServerArgs.devServer.contentBase) {
      this.app.use(express.static(this.devServerArgs.devServer.contentBase))
    }
  }

  createServer() {
    this.server = http.createServer(this.app)
  }

  listen(port, host, callback) {
    this.server.listen(port, host, callback)
  }

  createSocketServer() {
    // socket通信之前需要先握手,因此也需要http服务 
    const websocketServer = WebSocketIo(this.server)

    // 监听客户端的连接 
    websocketServer.on('connection', (socket) => {
      console.log('新的websocket客户端连上了')
      // 将新连接的客户端存起来 
      this.sockets.push(socket)

      // 监控客户端断开事件
      socket.on('disconnect', () => {
        let index = this.sockets.indexOf(socket)
        this.sockets.splice(index, 1)
      })
      // 如果之前已经存在编译结果,就将上次 hash 和 ok 发送客户端
      if (this._stats) {
        socket.emit('hash', this._stats.hash)
        socket.emit('ok')
      }
    })
  }
}

module.exports = Server

//! 利用 express 开启 http 服务端,同时创建 websocket 服务用于将来通信

四、编译前修改配置

开启热更新功能之后会对入口文件进行修改

需要加载 webpack-dev-server\client\index.js | webpack\hot\dev-server.js | HotModuleReplacement.runtime

这个操作单独定义为功能函数实现,放置在 updateCompiler 模块中

js

function updateCompiler(compiler) {
  const options = compiler.options
  // 添加浏览器里的 socket 客户端,接收服务端消息 
  options.entry.main.import.unshift(
    require.resolve('../../client/index.js')
  )

  // 添加文件用于处理浏览器客户端接收到的消息 
  options.entry.main.import.unshift(
    require.resolve('../../../webpack/hot/dev-server.js')
  )

  //* 测试 
  console.log(options.entry, '~~~~')

  //*3 触发钩子,通知 webpack 按新入口进行编译 
  compiler.hooks.entryOption.call(options.context, options.entry)
}

module.exports = updateCompiler

//! 修改入口,将热更新需要的文件也打包进来

五、中间件watch文件

流程:

  • 启动服务(http服务 + websocket 服务) 同时创建 compiler 执行打包构建
  • 创建 Server 服务时需要更改 config 的 entry 属性,向其中注入二个新的入口文件
  • compiler 执行构建,此时需要让 server 参与到构建中,添加一个中间件

5.1 setupDevMiddleware

js
const webpackDevMiddleware = require('../../webpack-dev-middleware')

this.setupDevMiddleware()  //* +3.1 初始化中间件处理打包文件

setupDevMiddleware() {
	this.middleware = webpackDevMiddleware(this.compiler)
    this.app.use(this.middleware)
}

5.2 webpack-dev-middle

js
//! 以监听的模式启动 webpack 编译 
//! 返回 express 中间件,用来查看新产出的资源文件 
const MemoryFileSystem = require('memory-fs')
const memoryFileSystem = new MemoryFileSystem()
const middleware = require('./middleware')
const fs = require('fs')

function webpackDevMiddleware(compiler) {
  // 让 webpack 执行打包操作
  compiler.watch({}, () => {
    console.log(`监听到文件变化,webpack重新开始编译`)
  })

  // 产出文件默认写在内存系统中,此处为了演示,放在硬盘里
  // let fs = compiler.outputFileSystem = memoryFileSystem
  return middleware({
    fs,
    outputPath: compiler.options.output.path
  })
}

module.exports = webpackDevMiddleware

5.3 实现 middleware

js
const path = require('path')
const mime = require('mime')
function wrapper({ fs, outputPath }) {
  return (req, res, next) => {
    let url = req.url
    if (url == '/') url = './index.html'
    let filename = path.join(outputPath, url)
    try {
      let stat = fs.statSync(filename)
      if (stat.isFile()) {
        let content = fs.readFileSync(filename)
        res.setHeader('Content-Type', mime.getType(filename))
      } else {
        res.sendStatus(404)
      }
    } catch (err) {
      res.sendStatus(404)
    }
  }
}

module.exports = wrapper

//! 利用 express 中间件负责提供产出文件的预览 
//! 拉截http请求,查看请求的文件是不是 webpack 打包出来的文件
//! 如果是则从硬盘上读出来,然后返回给客户端

六、连接服务端

上述操作完成之后,服务端准备工作已完成

此后浏览器做为客户端就需要与服务端连接然后依据信息变化处理后续逻辑

客户端文件放在 webpack-dev-server\client\index.js 中

真正的业务处理文件放在 webpack\hot\dev-server.js 中

6.1 环境修改

  1. 启用静态资源服务目录,将 html 与 打包后的 main.js 都拷贝至目录下
  2. 注释配置文件中关于 HotModuleReplacement 插件使用,简化 main.js 输出
  3. 修改 html 文件,添加 socket.io.js 文件引入,方便在浏览器客户端可以直接使用 io 操作
html
<script src="/socket.io/socket.io.js"></script>
<script defer src="hmr.js"></script>
js
"./src/index.js":
((module, exports, __webpack_require__) => {
    let render = () => {
        let title = __webpack_require__("./src/title.js")
        root.innerText = title
    }
    render()
    if (false) { }
})

__webpack_require__('./src/index.js')

6.2 连接服务端

webpack-dev-server\client\index.js 文件充当着浏览器当中的客户端

重新打包后就会通过 socket 服务端发送消息给客户端

此时客户端需要连接服务端接收具体的消息,从而实现对应的业务逻辑

js

// 客户端连接服务端 
const socket = io()

// 定义容器保存 hash 值 
let currentHash

// 获取当前最新的hash值 
socket.on('hash', (hash) => {
  console.log('客户端收到 hash 消息')
  currentHash = hash
})

socket.on('ok', () => {
  console.log(`客户端收到OK收息`)
  // 新构建完成重构 app 
  // reloadApp()
})

function reloadApp() { // 重载 app 

}

七、初始热更新

7.1 触发webpackHotUpdate

热更新会以 watch 模式来运行打包操作,监视文件系统的变更

发生变更之后会重新执行打包构建操作,此时由 websocket 服务端发送消息通知客户端

浏览器客户端接收到服务端消息之后完成 资源重新加载(触发 webpackHotUpdate)

上述的触发操作是由发布订阅模式实现,具体的逻辑代码放置于 webpack\hot\dev-server.js 中实现

js
socket.on('ok', () => {
  console.log(`客户端收到OK收息`)
  // 新构建完成重构 app 
  reloadApp()
})

function reloadApp() { // 重载 app 
  // 重载里需要请求最新的资源,所以这里要发送 hash 值
  hotEmitter.emit('webpackHotUpdate', currentHash)
}

7.2 简版发布订阅

js
class EventEmitter {
  constructor() {
    this.events = {}
  }
  on(eventName, fn) {
    this.events[eventName] = fn
  }
  emit(eventName, ...args) {
    this.events[eventName](...args)
  }
}

module.exports = new EventEmitter()

6.5 重载初始化

js
var hotEmitter = require('./emitter')

hotEmitter.on('webpackHotUpdate', (currentHash) => {
  console.log(`客户端收到了最新的hash值`, currentHash)
  // 进行热更新  
})

八、实现热更新

8.1 修改 module

使用热更新功能时会在 module 的身上添加其它的属性

js
function __webpack_require__(moduleId) {
   	var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
        return cachedModule.exports;
    }
    var module = __webpack_module_cache__[moduleId] = {
        exports: {},
        hot: hotCreateModule(),
        parents: new Set(),
        children: new Set()
    };
    __webpack_modules__[moduleId](module, module.exports, hotCreateRequire(moduleId));
    return module.exports;
}

function hotCreateModule() {
    let hot = {
      // 接收到的依赖对象
      _acceptedDependencies: {},

      // 接收依赖的变化
      accept(deps, callback) {
        for (let i = 0; i < deps.length; i++) {
          hot._acceptedDependencies[deps[i]] = callback
        }
      },
      check(moduleId) {
        let callback = hot._acceptedDependencies[moduleId]
        callback && callback()
      }
    }
    return hot
  }

Released under the MIT License.