一、前言 - webpack热更新
Hot Module Replacement
,简称HMR
,无需完全刷新整个页面的同时,更新模块。HMR
的好处,在日常开发工作中体会颇深:节省宝贵的开发时间、提升开发体验。
刷新我们一般分为两种:
- 一种是页面刷新,不保留页面状态,就是简单粗暴,直接
window.location.reload()
。 - 另一种是基于
WDS (Webpack-dev-server)
的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态,比如复选框的选中状态、输入框的输入等。
HMR
作为一个Webpack
内置的功能,可以通过HotModuleReplacementPlugin
或--hot
开启。那么,HMR
到底是怎么实现热更新的呢?下面让我们来了解一下吧!
二、webpack的编译构建过程
项目启动后,进行构建打包,控制台会输出构建过程,我们可以观察到生成了一个 Hash值:a93fd735d02d98633356
。
然后,在我们每次修改代码保存后,控制台都会出现 Compiling…
字样,触发新的编译中…可以在控制台中观察到:
- 新的Hash值:
a61bdd6e82294ed06fa3
- 新的json文件:
a93fd735d02d98633356.hot-update.json
- 新的js文件:
index.a93fd735d02d98633356.hot-update.js
首先,我们知道Hash
值代表每一次编译的标识。其次,根据新生成文件名可以发现,上次输出的Hash
值会作为本次编译新生成的文件标识。依次类推,本次输出的Hash
值会被作为下次热更新的标识。
然后看一下,新生成的文件是什么?每次修改代码,紧接着触发重新编译,然后浏览器就会发出 2 次请求。请求的便是本次新生成的 2 个文件。如下:
首先看json
文件,返回的结果中,h
代表本次新生成的Hash
值,用于下次文件热更新请求的前缀。c
表示当前要热更新的文件对应的是index
模块。再看下生成的js
文件,那就是本次修改的代码,重新编译打包后的。
还有一种情况是,如果没有任何代码改动,直接保存文件,控制台也会输出编译打包信息的。
- 新的Hash值:
d2e4208eca62aa1c5389
- 新的json文件:
a61bdd6e82294ed06fa3.hot-update.json
但是我们发现,并没有生成新的js
文件,因为没有改动任何代码,同时浏览器发出的请求,可以看到c
值为空,代表本次没有需要更新的代码。
小声说下,webapck
以前的版本这种情况hash
值是不会变的,后面可能出于什么原因改版了。细节不用在意,了解原理才是真谛!!!
最后思考下?,浏览器是如何知道本地代码重新编译了,并迅速请求了新生成的文件?是谁告知了浏览器?浏览器获得这些文件又是如何热更新成功的?那让我们带着疑问看下热更新的过程,从源码的角度看原理。
三、热更新实现原理
相信大家都会配置webpack-dev-server
热更新,我就不示意例子了。自己网上查下即可。接下来我们就来看下webpack-dev-server
是如何实现热更新的?(源码都是精简过的,第一行会注明代码路径,看完最好结合源码食用一次)。
1. webpack-dev-server启动本地服务
我们根据webpack-dev-server
的package.json
中的bin
命令,可以找到命令的入口文件bin/webpack-dev-server.js
。
1 | // node_modules/webpack-dev-server/bin/webpack-dev-server.js// 生成webpack编译主引擎 compilerlet compiler = webpack(config); |
本地服务代码:
1 | // node_modules/webpack-dev-server/lib/Server.jsclassServer{ |
这一小节代码主要做了三件事:
- 启动
webpack
,生成compiler
实例。compiler
上有很多方法,比如可以启动webpack
所有编译工作,以及监听本地文件的变化。 - 使用
express
框架启动本地server
,让浏览器可以请求本地的静态资源。 - 本地
server
启动之后,再去启动websocket
服务,如果不了解websocket
,建议简单了解一下websocket速成。通过websocket
,可以建立本地服务和浏览器的双向通信。这样就可以实现当本地文件发生变化,立马告知浏览器可以热更新代码啦!
上述代码主要干了三件事,但是源码在启动服务前又做了很多事,接下来便看看webpack-dev-server/lib/Server.js
还做了哪些事?
2. 修改webpack.config.js的entry配置
启动本地服务前,调用了updateCompiler(this.compiler)
方法。这个方法中有 2 段关键性代码。一个是获取websocket
客户端代码路径,另一个是根据配置获取webpack
热更新代码路径。
1 | // 获取websocket客户端代码const clientEntry = `${require.resolve( |
修改后的webpack
入口配置如下:
1 | // 修改后的entry入口 |
为什么要新增了 2 个文件?在入口默默增加了 2 个文件,那就意味会一同打包到bundle
文件中去,也就是线上运行时。
(1)webpack-dev-server/client/index.js
首先这个文件用于websocket
的,因为websoket
是双向通信,如果不了解websocket
,建议简单了解一下websocket速成。我们在第 1 步 webpack-dev-server
初始化 的过程中,启动的是本地服务端的websocket
。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?总不能让开发者去写吧hhhhhh。因此我们需要把websocket
客户端通信代码偷偷塞到我们的代码中。客户端具体的代码后面会在合适的时机细讲哦。
(2)webpack/hot/dev-server.js
这个文件主要是用于检查更新逻辑的,这里大家知道就好,代码后面会在合适的时机(第5步)细讲。
3. 监听webpack编译结束
修改好入口配置后,又调用了setupHooks
方法。这个方法是用来注册监听事件的,监听每次webpack
编译完成。
1 | // node_modules/webpack-dev-server/lib/Server.js// 绑定监听事件 |
当监听到一次webpack
编译结束,就会调用_sendStats
方法通过websocket
给浏览器发送通知,ok
和hash
事件,这样浏览器就可以拿到最新的hash
值了,做检查更新逻辑。
1 | // 通过websoket给客户端发消息 |
4. webpack监听文件变化
每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,主要是通过setupDevMiddleware
方法实现的。
这个方法主要执行了webpack-dev-middleware
库。很多人分不清webpack-dev-middleware
和webpack-dev-server
的区别。其实就是因为webpack-dev-server
只负责启动服务和前置准备工作,所有文件相关的操作都抽离到webpack-dev-middleware
库了,主要是本地文件的编译和输出以及监听,无非就是职责的划分更清晰了。
那我们来看下webpack-dev-middleware
源码里做了什么事:
1 | // node_modules/webpack-dev-middleware/index.js |
(1)调用了compiler.watch
方法,在第 1 步中也提到过,compiler
的强大。这个方法主要就做了 2 件事:
- 首先对本地文件代码进行编译打包,也就是
webpack
的一系列编译流程。 - 其次编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听。
为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于compiler.watch
这个方法了。监听本地文件的变化主要是通过文件的生成时间是否有变化,这里就不细讲了。
(2)执行setFs
方法,这个方法主要目的就是将编译后的文件打包到内存。这就是为什么在开发的过程中,你会发现dist
目录没有打包后的代码,因为都在内存中。原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs
。
5. 浏览器接收到热更新的通知
我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次webpack
编译结束,_sendStats
方法就通过websoket
给浏览器发送通知,检查下是否需要热更新。下面重点讲的就是_sendStats
方法中的ok
和hash
事件都做了什么。
那浏览器是如何接收到websocket
的消息呢?回忆下第 2 步骤增加的入口文件,也就是websocket
客户端代码。
1 | 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080' |
这个文件的代码会被打包到bundle.js
中,运行在浏览器中。来看下这个文件的核心代码吧。
1 | // webpack-dev-server/client/index.jsvar socket = require('./socket'); |
socket
方法建立了websocket
和服务端的连接,并注册了 2 个监听事件。
-
hash
事件,更新最新一次打包后的hash
值。 -
ok
事件,进行热更新检查。
热更新检查事件是调用reloadApp
方法。比较奇怪的是,这个方法又利用node.js
的EventEmitter
,发出webpackHotUpdate
消息。这是为什么?为什么不直接进行检查更新呢?
个人理解就是为了更好的维护代码,以及职责划分的更明确。websocket
仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了webpack
。
那webpack
怎么做的呢?再来回忆下第 2 步。入口文件还有一个文件没有讲到,就是:
1 | 'xxx/node_modules/webpack/hot/dev-server.js' |
这个文件的代码同样会被打包到bundle.js
中,运行在浏览器中。这个文件做了什么就显而易见了吧!先瞄一眼代码:
1 | // node_modules/webpack/hot/dev-server.jsvar check = functioncheck() { |
这里webpack
监听到了webpackHotUpdate
事件,并获取最新了最新的hash
值,然后终于进行检查更新了。检查更新呢调用的是module.hot.check
方法。那么问题又来了,module.hot.check
又是哪里冒出来了的!答案是HotModuleReplacementPlugin
搞得鬼。这里留个疑问,继续往下看。
6. HotModuleReplacementPlugin
前面好像一直是webpack-dev-server
做的事,那HotModuleReplacementPlugin
在热更新过程中又做了什么伟大的事业呢?
首先你可以对比下,配置热更新和不配置时bundle.js
的区别。内存中看不到?直接执行webpack
命令就可以看到生成的bundle.js
文件啦。不要用webpack-dev-server
启动就好了。
(1)没有配置的。
(2)配置了HotModuleReplacementPlugin
或--hot
的。
哦~ 我们发现moudle
新增了一个属性为hot
,再看hotCreateModule
方法。这不就找到module.hot.check
是哪里冒出来的。
经过对比打包后的文件,__webpack_require__
中的moudle
以及代码行数的不同。我们都可以发现HotModuleReplacementPlugin
原来也是默默的塞了很多代码到bundle.js
中呀。这和第 2 步骤很是相似哦!为什么,因为检查更新是在浏览器中操作呀。这些代码必须在运行时的环境。
你也可以直接看浏览器Sources
下的代码,会发现webpack
和plugin
偷偷加的代码都在哦。在这里调试也很方便。
HotModuleReplacementPlugin
如何做到的?这里我就不讲了,因为这需要你对tapable
以及plugin
机制有一定了解,可以看下我写的文章Webpack插件机制之Tapable-源码解析。当然你也可以选择跳过,只关心热更新机制即可,毕竟信息量太大。
7. moudle.hot.check 开始热更新
通过第 6 步,我们就可以知道moudle.hot.check
方法是如何来的啦。那都做了什么?之后的源码都是HotModuleReplacementPlugin
塞入到bundle.js
中的哦,我就不写文件路径了。
- 利用上一次保存的
hash
值,调用hotDownloadManifest
发送xxx/hash.hot-update.json
的ajax
请求; - 请求结果获取热更新模块,以及下次热更新的
Hash
标识,并进入热更新准备阶段。
1 | hotAvailableFilesMap = update.c; // 需要更新的文件 |
- 调用
hotDownloadUpdateChunk
发送xxx/hash.hot-update.js
请求,通过JSONP
方式。
1 | functionhotDownloadUpdateChunk(chunkId) { |
这个函数体为什么要单独拿出来,因为这里要解释下为什么使用JSONP
获取最新代码?主要是因为JSONP
获取的代码可以直接执行。为什么要直接执行?我们来回忆下/hash.hot-update.js
的代码格式是怎么样的。
可以发现,新编译后的代码是在一个webpackHotUpdate
函数体内部的。也就是要立即执行webpackHotUpdate
这个方法。
再看下webpackHotUpdate
这个方法。
1 | window["webpackHotUpdate"] = function (chunkId, moreModules) { |
-
hotAddUpdateChunk
方法会把更新的模块moreModules
赋值给全局全量hotUpdate
。 -
hotUpdateDownloaded
方法会调用hotApply
进行代码的替换。
1 | functionhotAddUpdateChunk(chunkId, moreModules) { |
8. hotApply 热更新模块替换
热更新的核心逻辑就在hotApply
方法了。hotApply
代码有将近 400 行,还是挑重点讲了,看哭?
①删除过期的模块,就是需要替换的模块
通过hotUpdate
可以找到旧模块
1 | var queue = outdatedModules.slice(); |
②将新的模块添加到 modules 中
1 | appliedUpdate[moduleId] = hotUpdate[moduleId]; |
③通过__webpack_require__执行相关模块的代码
1 | for (i = 0; i < outdatedSelfAcceptedModules.length; i++) { |
hotApply
的确比较复杂,知道大概流程就好了,这一小节,要求你对webpack打包后的文件如何执行的有一些了解,大家可以自去看下。
四、总结
还是以阅读源码的形式画的图,①-④的小标记,是文件发生变化的一个流程。
写在最后
本次是以阅读源码的方式讲解原理,是因为觉得热更新这块涉及的知识量比较多。所以知识把关键性代码拿出来,因为每一个块细节说起来都能写一篇文章了,大家可以自己对着源码再理解下。
还是建议提前了解以下知识会更好理解热更新:
- websocket:websocket基础知识了解
- 打包后的
bundle
文件如何运行的。 -
webpack
启动流程,webpack
生命周期。 - tapable: Webpack插件机制之Tapable-源码解析
参考链接
- Webpack Hot Module Replacement 的原理解析
- 看完这篇,面试再也不怕被问 Webpack 热更新
参考的文章大家也可以看下,但是由于源码版本不同,所以不要太纠结与细节。