热更新也叫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的一个中间件。主要的工作是编译和编译文件相关的操作,主要干了下面三件事情
(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); }
概述
下图是网上的一张关于热更新的流程图
细节&流程 具体流程分为客户端和服务端两方面。 服务端
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 许可协议。转载请注明出处!