webpack 模块热更新原理
什么是
模块热更新
?
模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。
- 核心问题:
- HMR 是怎样实现自动编译的?
- 模块内容的变更浏览器是如何感知的?
- 新产生的两个文件是干嘛的?
- 局部更新是如何做到的?
下面让我们带着这些疑问,一起来探索模块热更新的原理。
模块热更新的配置
在学习原理前,来回顾一下配置流程,这样更有助于对源码的理解。
第一步:安装 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
。可以说 HMR
是 webpack-dev-server
和 HotModuleReplacementPlugin
共同的功劳。
热更新原理
Webpack-dev-server 都做了哪些事?
- 开启本地服务
首先通过 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
- 监听编译完成
仅仅在建立 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'); }) }) }
- 监听文件修改
要想在代码修改的时候,触发重新编译,那么就需要对代码的变动进行监听。这一步,源码是通过 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
- 向浏览器中插入客户端代码
前面提到要想实现浏览器和本地服务的通信,那么就需要浏览器接入到本地开启的 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
为模块添加 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 }
请求补丁文件 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() }) }
先看一眼这两个文件长什么样
- d04feccfa446b174bc10.hot-update.json,告知浏览器新的 hash 值,并且是哪个 chunk 发生了改变
- 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) }
模块内容替换 当 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,实质就是执行了我们当初在配置模块热更新第二步中的回调事件,从而实现了页面内容的局部刷新。