编辑
2023-02-24
前端
0
请注意,本文编写于 601 天前,最后修改于 80 天前,其中某些信息可能已经过时。

目录

前言
入门
储备知识
原理

前言

热更新也叫Hot Module Replacement,是指当我们对代码修改并保存后,webpack 将会对代码进行重新打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,以实现在不刷新浏览器的前提下更新页面。热更新也是webpack原生提供的功能之一。

入门

演示

这里用浏览器作为客户端,写了一个基于webpack的demo。

不使用HRM

点击按钮把数字从1增加到5,修改代码以后,浏览器会进行刷新,数字恢复成1,这意味着之前页面保留的状态也会被清空。

使用HMR

点击按钮把数字从1增加到5,修改代码以后,浏览器只会局部刷新修改的部分,数字5不会改变,这意味着页面之前的状态会保留。

注意 在 webpack 编译过后,在控制台会打印关于本次编译的hash值,这个值会在浏览器第一次请求的时候,服务器通过socket把这个消息发送给浏览器,浏览器在接收到后,会保存下来。在下次发生热更新的时候,服务器会把这个hash作为静态资源文件名字前缀的一部分,而浏览器则会按照约定的命名格式去请求这部分,而这种做法的原因在我看来是因为文件内容hash的唯一性。

代码 webpack配置如下 主要的配置部分在devServer的hot属性开启true和plugin中添加new webpack.HotModuleReplacementPlugin

js
// 只保留核心部分 const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const webpack = require('webpack'); module.exports = { mode: 'development', entry: { main: './src/index' }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].bundle.js', chunkFilename: '[name].chunk.js' }, devServer: { host: 'localhost', port: 9090, hot: true }, module: { rules: [ { test: /\.js$/, include: path.resolve('src'), loader: 'babel-loader', } ] }, plugins: [ new webpack.HotModuleReplacementPlugin(), new HtmlWebpackPlugin({ filename: 'index.html', template: './index.html' }), ] } 核心代码如下 // index.js import content from './content'; /* renderCounter */ function renderCounter() { const counterEl = document.createElement('div'); const btnEl = document.createElement('button'); btnEl.innerHTML = 'ADD'; counterEl.appendChild(btnEl); const spanEl = document.createElement('span'); spanEl.innerHTML = 1; btnEl.onclick = function() { spanEl.innerHTML++; }; spanEl.style = 'color:red;margin-left:20px'; counterEl.appendChild(spanEl); counterEl.style = 'margin-top:20px;margin-bottom:20px'; document.body.appendChild(counterEl); } renderCounter(); /* renderContent */ let divEl = document.createElement("div"); let renderContent = divEl => { divEl.innerHTML = content; } document.body.appendChild(divEl); renderContent(divEl); if (module.hot) { module.hot.accept(['./content.js'], () => { renderContent(divEl); }) } // content.js const content = '修改以后'; export default content;

储备知识

为了更加清楚得解释下面的热更新过程,如果了解下面的概念会更好。 理解module、chunk、bundle的概念 module 是我们写的每个js文件,也就是模块的概念,所有的资源都可能是个module,包括js、css、图片等等。 chunk 是webpack打包过程中被分割的组,webpack根据module的依赖关系和配置文件,生成一个个chunk,它包括若干个module。 bundle就是webpack打包出的文件,一般而言,一个chunk对应一个bundle,如果配置了sourceMap,一个chunk就会对应多个bundle。 webpack-dev-middleware 它是webpack-dev-server的一个中间件。主要的工作是编译和编译文件相关的操作,主要干了下面三件事情

  • 本地文件的监听、启动webpack编译;启动watch模式,根据配置文件对模块重新编译打包
  • 设置文件系统为内存文件系统,即让webpack的编译输出到内存中,而不是写入磁盘
  • 实现了一个express中间件,将编译的文件返回 为什么要如此设计,是因为webpack-dev-server的核心工作是创建server服务器和websocket服务器,编译文件的相关的操作都抽离到webpack-dev-middleware这个中间件中去做了。 HotModuleReplacementPlugin 主要作用有下面两个。
  • manifest json:上一次编译生成的hash.hot-update.json
  • updated chunk:chunk名字.上一次编译生成的hash.hot-update.js

  1. 在chunk文件中会注入HMR runtime运行时代码,浏览器的热更新逻辑主要都是这个插件的作用。 webpack打包 在没有配置热更代码的情况下,打包出来的bundle文件会是下面这样的
(function (modules) { // 模块缓存 var installedModules = {}; function __webpack_require__(moduleId) { // 判断是否有缓存 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 没有缓存则创建一个模块对象并将其放入缓存 var module = installedModules[moduleId] = { i: moduleId, l: false, // 是否已加载 exports: {} }; // 执行模块函数 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 将状态置为已加载 module.l = true; // 返回模块对象 return module.exports; } // ... // 加载入口文件 return __webpack_require__(__webpack_require__.s = "./src/index.js"); }) ({ "./src/content.js": (function (module, exports) { eval(...); }), "./src/index.js": (function (module, exports, __webpack_require__) { eval(...); }) }); 打包后会产出一个自执行函数。在函数体内,webpack自己实现了一套commonjs规范,实现了自己的一套模块化。函数参数为一个对象,每个对象的key是相对于根路径的相对路径,value是module的内容。 而在配置了热更代码的情况下,插件会往bundle文件里注入runtime代码。 (function (modules) { //(HMR runtime代码) module.hot属性就是hotCreateModule函数的执行结果,所有hot属性有accept、check等属性 function hotCreateModule() { var hot = { accept: function (dep, callback) { for (var i = 0; i < dep.length; i++) hot._acceptedDependencies[dep[i]] = callback; }, check: hotCheck,//在webpack/hot/dev-server.js中调用module.hot.accept就是hotCheck函数】 }; return hot; } //(HMR runtime代码) 以下几个方法是 拉取更新模块的代码 function hotCheck(apply) {} function hotDownloadUpdateChunk(chunkId) {} function hotDownloadManifest(requestTimeout) {} //(HMR runtime代码) 以下几个方法是执行新代码并执行accept回调 window["webpackHotUpdate"] = function webpackHotUpdateCallback(chunkId, moreModules) { hotAddUpdateChunk(chunkId, moreModules); }; function hotAddUpdateChunk(chunkId, moreModules) {hotUpdateDownloaded();} function hotUpdateDownloaded() {hotApply()} function hotApply(options) {} function hotCreateRequire(moduleId) { var fn = function(request) { return __webpack_require__(request); }; return fn; } // 模块缓存对象 var installedModules = {}; // 实现了一个 require 方法 function __webpack_require__(moduleId) { // 判断这个模块是否在 installedModules 缓存中 if (installedModules[moduleId]) { // 在缓存中,直接返回 installedModules缓存 中该 模块的导出对象 return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { i: moduleId, l: false, // 模块是否加载 exports: {}, // 模块的导出对象 hot: hotCreateModule(moduleId), parents: [], // 这个模块被哪些模块引用了 children: [] // 这个模块引用了哪些模块 }; // (HMR runtime代码) 执行模块的代码,传入参数 modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId)); // 设置模块已加载 module.l = true; // 返回模块的导出对象 return module.exports; } // 暴露模块的缓存 __webpack_require__.c = installedModules; // 加载入口模块并且返回导出对象 return hotCreateRequire(0)(__webpack_require__.s = 0); })( { "./src/content.js": (function (module, __webpack_exports__, __webpack_require__) {}), "./src/index.js": (function (module, exports, __webpack_require__) {}) } );

可以看到module对象相比上面,多了hot、parents、children属性,hot属性是由 hotCreateModule 方法返回的对象,其中accept函数,它的作用就是往 _acceptedDependencies 对象存入局部更新回调函数, 当模块文件改变的时候,我们会调用 _acceptedDependencies 中的回调。

var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {}, hot: hotCreateModule(moduleId), parents: [], children: [] }; function hotCreateModule() { var hot = { accept: function (dep, callback) { for (var i = 0; i < dep.length; i++) hot._acceptedDependencies[dep[i]] = callback; }, check: hotCheck, }; return hot; } // 如果要实现热更新,我们必须像这样,把路径和回调作为参数传给accpet函数。 // 等价于module.hot._acceptedDependencies["./content.js"] = render // content.js模块改变时,他的父模块index.js通过_acceptedDependencies知道执行什么方法 if (module.hot) { module.hot.accept(["./content.js"], render); }

原理

概述

  • webpack-dev-server本地启动 express 服务
  • 服务端和客户端建立 websocket 长连接
  • webpack 监听源码的变化,当源码发生改变会触发 webpack 的重新编译。
    • 每次编译会生产该次编译的hash值、本地改动模块的json文件、js文件等
    • 编译完成后通过socket向客户端推送当前编译的hash戳
  • 客户端的websocket监听到文件改动推送来的hash戳,会和上一次对比
    • 一致则走缓存
    • 不一致则通过ajax和jsonp向服务端获取最新资源
  • 客户端执行变更逻辑

下图是网上的一张关于热更新的流程图

细节&流程 具体流程分为客户端和服务端两方面。 服务端

1. 启动server服务器 2. const webpack = require("webpack"); const Server = require("./lib/server/Server"); const config = require("../../webpack.config"); // 创建webpack实例 const compiler = webpack(config); // 创建Server类,这个类里面包含了webpack-dev-server服务端的主要逻辑 // /src/lib/server/Server.js const server = new Server(compiler); // 启动webserver服务器 server.listen(3000, "localhost", () => {}) 更改webpack的entry属性 在进行webpack编译前,调用了updateCompiler(compiler)方法,在entry中加入了两个文件,lib/client/client.js和lib/client/hot-dev-server.js。这两个文件主要是为了实现websocket双向通信的客户端和服务端。每次代码发生改动的时候webpack会进行重新编译,生成新的编译文件,服务端会通过websocket下发消息告知客户端(浏览器)来拉取新的代码,webpack-dev-server给提供了客户端和服务端通信的相关代码。 3. let updateCompiler = (compiler) => { const config = compiler.options; config.entry = { main: [ path.resolve(__dirname, "../client/index.js"), path.resolve(__dirname, "../client/hot-dev-server.js"), config.entry ] } compiler.hooks.entryOption.call(config.context, config.entry); } 添加webpack的done事件回调 往 compiler.hooks.done 钩子(webpack编译完成后会触发)上注册事件,添加回调,每当一次新的编译产生后都会向客户端发hash和ok消息 4. // /src/lib/server/Server.js setupHooks() { let { compiler } = this; compiler.hooks.done.tap("webpack-dev-server", (stats) => { //每次编译都会产生一个唯一的hash值 this.currentHash = stats.hash; //每当新一个编译完成后都会向所有的websocket客户端发送消息 this.clientSocketList.forEach(socket => { //先向客户端发送最新的hash值 socket.emit("hash", this.currentHash); //再向客户端发送一个ok socket.emit("ok"); }); }); } 创建express实例app 创建 express 服务,设置文件系统为内存文件系统,添加webpack-dev-middleware中间件,中间件负责返回生成的文件。 5. // 创建express setupApp() { this.app = new express(); } // 设置webpack-dev-middleware为中间件函数 // /src/lib/server/Server.js setupDevMiddleware() { let { compiler } = this; // 会监控文件的变化,每当有文件改变的时候都会重新编译打包 compiler.watch({}, () => {}); // 设置文件系统为内存文件系统 let fs = new MemoryFileSystem(); this.fs = compiler.outputFileSystem = fs; // express中间件,将编译的文件返回 // 为什么不直接使用express的static中间件,是因为我们要读取的文件在内存中,所以自己实现一款简易版的static中间件 let staticMiddleWare = (fileDir) => { return (req, res, next) => { let { url } = req; if (url === "/favicon.ico") { return res.sendStatus(404); } url === "/" ? url = "/index.html" : null; let filePath = path.join(fileDir, url); try { let statObj = this.fs.statSync(filePath); if (statObj.isFile()) { let content = this.fs.readFileSync(filePath); res.setHeader("Content-Type", mime.getType(filePath)); res.send(content); } else { res.sendStatus(404); } } catch (error) { res.sendStatus(404); } } } this.middleware = staticMiddleWare;// 将中间件挂载在this实例上,以便app使用 } routes() { let { compiler } = this; let config = compiler.options;// 经过webpack(config),会将 webpack.config.js导出的对象 挂在compiler.options上 this.app.use(this.middleware(config.output.path)); } 创建server服务器 使用原始的http结合express是为了拿到server实例。 6. // /src/lib/server/Server.js createServer() { this.server = http.createServer(this.app); } 创建websocket服务器 // /src/lib/server/Server.js createSocketServer() { // socket.io实现一个websocket const io = socket(this.server); io.on("connection", (socket) => { console.log("a new client connect server"); // 把所有的websocket客户端存起来,以便编译完成后向这个websocket客户端发送消息(实现双向通信的关键) this.clientSocketList.push(socket); // 每当有客户端断开时,移除这个websocket客户端 socket.on("disconnect", () => { let num = this.clientSocketList.indexOf(socket); this.clientSocketList = this.clientSocketList.splice(num, 1); }); // 向客户端发送最新的一个编译hash socket.emit('hash', this.currentHash); // 再向客户端发送一个ok socket.emit('ok'); }); } 客户端 1. 创建websocket客户端 连接websoket服务器,监听 hash 和 ok 事件 2. 执行reloadApp 客户端收到ok的消息以后,执行reloadApp方法,判断是否支持热更新,如果不支持则会直接刷新浏览器,如果支持会触发webpackHotUpdate事件 3. // reloadApp中发射webpackHotUpdate事件 let reloadApp = () => { let hot = true; // 会进行判断,是否支持热更新 if (hot) { // 事件通知:如果支持的话发射webpackHotUpdate事件 hotEmitter.emit("webpackHotUpdate", currentHash); } else { // 直接刷新:如果不支持则直接刷新浏览器 window.location.reload(); } } 监听webpackHotUpdate事件,执行check方法 webpack/hot/dev-server.js中监听webpackHotUpdate事件,这个js文件是会被插件加入到webpack的entry中,并最后被一起打包到bundle文件中,而这个文件会通过module.hot.check把它和HotModuleReplacementPlugin的runtime代码发生关联,从而执行客户端的热更新HotModuleReplacementPlugin插入的代码是热更新客户端的核心。 4. if (module.hot) { var lastHash; var check = function check() { // 这个方法module.hot.check就是hotCheck函数 module.hot .check(true) .then(function(updatedModules) { if (upToDate()) { log("info", "[HMR] App is up to date."); } }) }; // 和client/index.js共用一个EventEmitter实例,这里用于监听事件 var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; check(); }); log("info", "[HMR] Waiting for update signal from WDS..."); } else { throw new Error("[HMR] Hot Module Replacement is disabled."); } 拉取补丁文件 这部分其实就是通过hotCheck方法去服务端获取两个新的补丁文件,其中js文件是以jsonp的形式 ,因为我们需要立即执行这段更新的js代码。 5. // src/HotModuleReplacement.runtime.js check: hotCheck // 调用hotCheck拉取两个补丁文件 let hotCheck = () => { hotDownloadManifest().then(hotUpdate => { hotDownloadUpdateChunk(chunkID); }) } // 拉取lashhash.hot-update.json,发送ajax请求Manifest,该文件包含了本次编译hash值和更新模块的chunk名 let hotDownloadManifest = () => { return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); let hotUpdatePath = `${lastHash}.hot-update.json` xhr.open("get", hotUpdatePath); xhr.onload = () => { let hotUpdate = JSON.parse(xhr.responseText); resolve(hotUpdate); }; xhr.onerror = (error) => { reject(error); } xhr.send(); }) } // 如果文件发生了变化需要执行热更新,拉取更新的模块chunkName.lashhash.hot-update.json,通过JSONP请求获取到更新的模块代码 let hotDownloadUpdateChunk = (chunkID) => { let script = document.createElement("script") script.charset = "utf-8"; script.src = `${chunkID}.${lastHash}.hot-update.js` document.head.appendChild(script); } 执行webpackHotUpdate方法 拉取以后该js会立即执行,调用了一个定义在window上的方法webpackHotUpdate,该方法会调用hotAddUpdateChunk方法动态更新模块代码,然后调用hotApply方法进行热更新,最后执行父模块存储的_acceptedDependencies最后的回调。 webpackHotUpdate("index", { "./src/content.js": (function (module, __webpack_exports__, __webpack_require__) { eval(""); }) }) 扩展延深 在使用react这种框架的前提下,应该如何实现热更新。而react-hot-loader的实现原理是怎么样的。 考虑过使用原生的方法监听文件的变化,然后重新进行局部render。但是在react里是不允许在非根组件的位置进行rerender,也不允许局部卸载组件然后重新插入,因为react推荐的是通过state的差异diff进行渲染,所以这个方式并不能热更新的问题。 if (module.hot) { module.hot.accept(['./counter'], function (result) { console.log(index.dom) var NextApp = require('./counter') ReactDOM.render(<NextApp />, index.dom) // ReactDOM.unmountComponentAtNode(document.getElementById('counter')) }) } class index extends Component { componentDidMount() { index.dom = ReactDOM.findDOMNode(this); } }

本文作者:唐文杰

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!