webpack的热更新原理你了解多少?(webpack热更新实现原理)
一、前言
Hot Module Replacement
,简称HMR
,无需完全刷新整个页面的同时,更新模块。好处就是优化了开发体验。
如果你配置过 Webpack
,想必你也不陌生,HotModuleReplacementPlugin
和 devServer
中hot:true
来开启HMR
功能。
二、了解下webpack的日常构建
当我们开启HMR
通过devServer
构建我们项目的时候,我们可以打开控制台network
观察到,它会生成一个hash值41e99be0fd82c43bc67d
,并且引入了main.41e99be0fd82c43bc67d.hot-update.js
这个文件。
同时产生了热更新标识文件41e99be0fd82c43bc67d.update.json
然后我们修改我们的代码重新编译,我们可以在控制台中观察到:
它会产生一个新的hash值(这个hash值是webpack
自动为我们生成的),并且会产生新的js文件
和一个hot-update.json
热更新标识文件。
我们可以发现上一次输出的hash
值会作文新生成文件文件名的前缀。依次类推我们再次修改文件成触发更新会产生新的文件,文件名以这一次的生成hash
为前缀。
具体看下文件的内容。
h
:生成的新hash值c
:当前热更新的是那个模块(我这里是入口文件main
)
那么最后,浏览器是怎么知道我们本地的代码发生改变的呢,并且引入了新的文件?让我们具体来了解下吧!
三、hot热更新实现
热更新hot
是结合dev-server
实现的,所以我们会从webpack-dev-server
开始一步一步实现其中的原理,(^▽^)这里和大家说明文本的代码是我根据blibli
上大佬视频和webpack
源码实现的核心代码。
1. webpack-dev-server启动本地服务
const webpack = require('webpack');//引入webpack const config = require('../webpack.config');//这个就是我们配置的webpack.config.js文件 const Server = require('./server/Server'); const compiler = webpack(config);//编译器对象 const server = new Server(compiler); server.listen(9090,'localhost',()=>{ console.log("服务在9090端口"); })复制代码
Server.js服务代码
class Server { constructor(compiler) { this.compiler = compiler; this.setupApp(); this.createServer();//创建http服务器,以app作为路由 } setupApp() { this.app = express();//本地服务还是依赖于express } createServer() { this.server = http.createServer(this.app); } listen(port, host, callback) {//监听服务 this.server.listen(port, host, callback); } }复制代码
这里的代码的工作内容:
引入
webpack
,并且通过webpack.config.js
生成compiler
实例(这里不了解的,大家可以先去了解下webpack
的一个大致构建流程)。依赖
express
框架来启动本地服务。
2.websocket
如何让浏览器知道我们的本地代码发生改变呢?源码里是依据websocket
来实现的。
const socketIO = require('socket.io'); constructor(compiler) { this.currentHash;//当前hash this.clientSocketList = [];//websocket 连接列表 this.createSocketServer();//创建socket服务器 } createSocketServer() { const io = socketIO(this.server);//websocker协议握手需要依赖http io.on("connection", (socket) => {//socket和客户端的链接对象 console.log("新的客户端socket"); this.clientSocketList.push(socket); socket.emit("hash", this.currentHash);//服务端主动发送hash socket.emit("ok");//发送 ok 字段 socket.on("disconnect", () => {//监听断开 let index = this.clientSocketList.indexOf(socket); this.clientSocketList.splice(index, 1);//断开需要从列表中删除 }) }) }复制代码
这里的代码的工作内容:
启动
websocket
服务,可以建立服务器和浏览器之间的双向通信。当监听到本地代码发生改变时,主动向浏览器发送新hash
以及ok
字段。并且往clientSocketList
链接集合中加入当前socket
,同时监听链接断开事件。
3.监听webpack编译完成
监听webpack
编译结束,主要是通过挂载webpack
中生命周期钩子函数done
,为每个socket
都发送hash
和ok
。
constructor(compiler) { this.setupHooks();//建立钩子 } setupHooks() { let { compiler } = this;//拿到compiler对象 //监听webpack编译完成事件 compiler.hooks.done.tap('webpack-dev-server', (state) => { //state是一个描述对象,里面可以拿到hash值 console.log('hash', state.hash); this.currentHash = state.hash; this.clientSocketList.forEach(socket => { socket.emit("hash", this.currentHash); socket.emit("ok");//给客户端发一个ok }) }) }复制代码
我们可以看下done
回调函数返回的state
:
4.静态资源访问中间件
在通过webpack-dev-server
来运行我们的项目时,webpack
会在本地启动一个服务,并且我们可以访问到我们打包输出的html
页面,这里主要是通过一个中间件来完成(中间件是express
中的一个概念,这里不做过多解释)。
const MemoryFs = require("memory-fs"); const mime = require('mime'); constructor(compiler) { this.setupDevMiddleware();//创建中间件 } setupDevMiddleware() { this.middleware = this.webpackDevMiddleware(); } webpackDevMiddleware() {//返回一个express中间件 let { compiler } = this; // let fs = new MemoryFs();//内存文件系统实例 this.fs = compiler.outputFileSystem = fs; return (staticDir) => {//静态文件跟目录,他其实就是输出目录 return (req, res, next) => { let { url } = req; if (url === '/favicon.ico') { return res.sendStatus(404); } url === '/' ? url = '/index.html' : null; let fs = new MemoryFs();//内存文件系统实例 let filepath = path.join(staticDir, url);//得到访问的静态路径 try { let stateObj = this.fs.statSync(filepath);//返回此路径上的描述对象 if (stateObj.isFile()) {//如果是一个文件 let content = this.fs.readFileSync(filepath); res.setHeader('Content-Type', mime.getType(filepath));// res.send(content); } else { return res.sendStatus(404); } } catch (err) { return res.sendStatus(404); } } } }复制代码
主要完成了对我们本地服务对静态文件的访问,其实就是为了能够本地能够访问我们打包后dist
目录下的html
文件。
5.配置路由
constructor(compiler) { this.routes();//配置路由 } routes() { let { compiler } = this; let config = compiler.options; this.app.use(this.middleware(config.output.path));//使用我们上一步生成的静态文件中间件 }复制代码
这里主要是使用我们上一步生成的静态文件中间件,我们可以看到它的目录其实就是我们webpack.config.js
文件中配置的输出目录,即dist
。
到此我们webpack-dev-server
的功能已经完成了,我们可以启用node
来跑一下,是否能够访问到页面。
四、打包后文件浏览器端
1.浏览器端websocket
以上我们的服务端已经完成了,当监听到webpack
重新编译,服务端就发送最新的hahs
值,那么客户端也应该有对应的websocket
来响应。响应期间客户端代码又做了什么呢?
以下代码都会被webpack
进行打包,所以会出现在html
文件的引入文件中。
var currentHash;//当前hash var lastHash; const socket = window.io('/'); class EventEmitter { constructor() { this.events = {}; } on(eventName, fn) { this.events[eventName] = fn; } emit(eventName, ...args) { this.events[eventName](...args); } } var hotEmitter = new EventEmitter(); socket.on('hash', (hash) => {//监听到服务端重新编译发送hash console.log(hash) currentHash = hash; }) socket.on("ok", () => {//监听到服务端重新编译发送ok console.log("ok"); reloadApp(); }) function reloadApp() { hotEmitter.emit('webpackHotUpdate');//发出webpackHotUpdate消息 }复制代码
客户端websocket
注册和服务端一样注册了两个事件:
hash
:webpack
重新编辑打包后的新hash
值 。ok
: 进行reloadApp
热更新检查 。
热更新检查是调用了reloadApp
方法,期间还调用了一层发布订阅模式 EventEmitter
,利用EventEmitter
派发一个webpackHotUpdate
事件进行检查。
2.webpackHotUpdate
首先你可以对比下,配置热更新和不配置时bundle.js
的区别。内存中看不到?因为webpack-dev-server
中使用的是内存文件系统memory-fs
,所以我们在dist
目录下看不到我们打包的文件,我这里给它改成了fs-extra
,这样就能看到了。
配置热更新的
没有配置热更新的
对比我们发现配置热更新的module
中多了hot
,我这里也写了一个hotCreateModule
function hotCreateModule() {//热创建模块 let hot = { _acceptDependencies: {}, accept(deps, callBack) { deps.forEach(dep => { hot._acceptDependencies[dep] = callBack; }) }, check: hotCheck } return hot; }复制代码
现在我们知道module.hot.check
方法从哪里来了吧!其实这些都是HotModuleReplacementPlugin
插件的功能,它给我们打包出来的代码添了不少。你也可以直接在浏览器Sources
下阅读这些代码,也方便调试。
hotEmitter.on("webpackHotUpdate", () => { if (!lastHash) {//如果没有 就是第一次渲染 lastHash = currentHash; return; } console.log(lastHash, currentHash); module.hot.check();//module.hot.check方法检查 })复制代码
3.hotCheck
function hotCheck() {//热更新检查 hotDownloadManifest().then(update => {//下载热更新标识文件 let chunkIds = Object.keys(update.c); chunkIds.forEach(chunkId => {//遍历发送改变的模块 hotDownloadUpdateChunk(chunkId); }) lastHash = currentHash; }).catch(() => { window.location.reload(); }) } function hotDownloadManifest() {//下载更新文件 return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); let url = `${lastHash}.hot-update.json`;//通过ajax去下载热更新标识文件 json xhr.open('get', url); xhr.responseType = "json"; xhr.onload = function () { resolve(xhr.response); } xhr.send(); }) }复制代码
利用上一次保存的
hash
值,调用hotDownloadManifest
发送xxx/hash.hot-update.json
的ajax
请求。保存下次热更新的
hash
标识。
function hotDownloadUpdateChunk(chunkId) {//将新代码通过script加载到html let script = document.createElement('script'); script.src = `${chunkId}.${lastHash}.hot-update.js`; document.head.appendChild(script); }复制代码
这里通过
jsonp
的方式发送xxx/hash.hot-update.js
请求,获取最新的代码
这里可以看下里面的代码长什么样:
可以看到里面是我改动最新的代码的源代码,是可以直接执行的,内部调用了一个webpackHotUpdate
函数,所以我们来看下这个方法:
window.webpackHotUpdate = function (chunkId, moreModules) {//chunk名字,更新代码的模块 hotAddUpdateChunk(chunkId, moreModules) } let hotUpdate = {}; function hotAddUpdateChunk(chunkId, moreModules) { for (let moduleId in moreModules) { modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];//更新新模块 } hotApply(); }复制代码
webpack
在打包后会将代码整合成一个个module
的形式
上面代码就是将图中eval
(第6
行)代码给替换了,替换成我们最新的可执行代码。
4.hotApply
热更新的核心逻辑就在hotApply
方法了。
function hotApply() { for (let moduleId in hotUpdate) { let oldModule = installedModules[moduleId];//从缓存中找到老模块 let { parents, children } = oldModule;//获取老模块的父模块数组和子模块数组 let module = installedModules[moduleId] = {//模块缓存替换 i: moduleId, l: false, exports: {}, parents: parents, children: children, hot: hotCreateModule() } modules[moduleId].call(module.exports, module, module.exports, hostCreateRequire(moduleId));//执行模块 module.l = true;//该模块已经执行 oldModule.parents.forEach(parentModule => {//遍历所有的父亲模块执行回调 let cb = parentModule.hot._acceptDependencies[moduleId]; cb && cb(); }) } }
作者:zxl樑樑
链接:https://juejin.cn/post/7038767740059926565