构建工具03 Webpack模块热重载(HMR)
使用webpack-dev-server
实现的Hot Moudle Replacement(HMR)让我们在开发时修改代码并保存后,不必手动刷新浏览器,而是让浏览器通过新的模块替换老的模块。这样可以让我们在保证当前页面状态的前提下,让新的代码生效,就如同在Chrome的控制台修改CSS样式一样。
使用
安装webpack-dev-server
1 | npm install webpack-dev-server --save-dev |
在webpack.config.js
中进行配置
1 | devServer: { |
其中:
contentBase
:服务器基本运行路径host
:服务器运行地址compress
:服务器压缩式,一般为true
port
:服务运行端口
在package.json
中定义相关命令:
1 | "scripts": { |
然后执行npm run dev
就可以开启webpack的服务,并且实现模块热重载,并且自动打开浏览器。
增加--open
属性可以自动打开浏览器。
原理解析
原来只是在各种Cli工具中使用了模块热重载,知道是利用了Webpack的HMR特性,但是它是怎么实现的却不了解。今天在清理收藏夹攒的知识时看到了饿了么前端专栏的这篇文章Webpack HMR 原理解析,写的非常好,简单易懂,把道理也说的很明白。
上图展示了从修改代码到模块热更新完成的一个周期:
第一步:Webpack在watch
模式下打包更改的文件到内存中(对应图中的①②③)
Webpack-dev-middleware调用Webpack的API对文件系统watch,监听到文件变化时,根据配置文件对模块重新编译打包,将打包后的代码以JavaScript对象的形式保存在内存中。
1 | // webpack-dev-middleware/lib/Shared.js |
Webpack会将打包的文件保存在内存中,而不是打包到output.path
目录下,是因为访问内存中的代码比访问文件系统中的代码更快,也减少了写入文件的开销。这个过程利用了memory-fs这个库,它提供了一个简单的基于内存的文件系统,所有数据都保存在JavaScript对象中。
图中的第③步也是对文件变化的监控,只不过这一步监听的不是代码,而是在配置文件制定的静态文件目录下的静态文件的变化(当配置文件中配置了devServer.watchContentBase
为true
的时候),当静态文件发生变化时通知浏览器对应用进行刷新(注意是浏览器刷新,而非HRM)
第二步:webpack-dev-Server通知浏览器端文件发生变化(对应④)
浏览器端和服务端之间是通过Websocket长连接进行通信的,利用的是sockjs建立的。通过Websocket长连接,webpack-dev-Server将编译打包的各个阶段状态告知浏览器(包括第③步中监听的静态文件的变化)。
同时webpack-dev-Server调用Webpack的API监听complie的done
事件,在编译完成后,webpack-dev-Server通过_sendStatus
方法将编译打包后的新模块的hash值发送给浏览器,后面的步骤都会利用这个hash值来进行模块热替换。
1 | // webpack-dev-server/lib/Server.js |
第三步:webpack-dev-server/client接收到服务端消息做出响应(对应⑤⑪)
webpack-dev-server/client端并不能够请求更新的代码,也不会执行热更模块操作,而是在接收到通过长连接收到的服务端的消息后,对信息进行处理,而具体的更新操作又交回给了Webpack。
webpack/hot/dev-server的工作就是根据webpack-dev-server/client传给它的信息以及dev-server的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
我们并没有在业务代码里添加Websocket客户端的代码,也没有在webpack.config.js
中的entry
属性中添加新的入口文件,那么bundle.js
中的接受Websocket信息的代码是从哪来的呢?答案是webpack-dev-server会自动修改Webpack配置中的entry
属性,在里面添加了webpck-dev-client的代码。
具体来看,webpack-dev-server/client接收到type
为hash
的消息后会将hash
保存起来,接收到type
为ok
的消息后会执行relooad
操作,在reload
操作中会根据hot
的配置是刷新浏览器还是执行热更新(HMR):
1 | // webpack-dev-server/client/index.js |
在上面的代码中,webpack-dev-server/client首先将接收到的hash
值存储到currentHash
变量中,当接收到ok
消息后调用reloadApp
方法,在其内部根据hot
配置,决定是调用webpack/hot/emitter
将最新的hash
值发送给Webpack执行热更新,还是直接调用location.reload
刷新页面。
第四步:Webpack接收新的hash
值并请求模块代码(对应⑥⑦⑧⑨)
首先webpack/hot/dev-server监听上一步webpack-dev-server/client发送的webpackHotUpdate
消息,然后调用webpack/lib/HotModuleReplacement.runtime(简称HMR runtime),HMR runtime是客户端HMR的中枢,它首先通过JsonpMainTemplate.runtime调用hotDownloadManifest
方法向server端发送JSONP请求,检查是否有更新的文件,如果有的话服务端返回一个JSON响应,包含了所有要更新的模块的hash值。
获取到更新列表后,该模块通过hotDownloadUpdateChunk
再次发送JSONP请求,获取到最新的模块代码,并返回给HMR runtime。
上面为了获取最新的Hash值和最新的代码,HMR runtime向服务端发送了两次Ajax请求,为什么不在第三步的Websocket长连接中发送给浏览器呢?可能的原因:
(1)包括了功能模块的解耦,webpack-dev-server/client只负责消息的传递而不负责新模块的拉取,HRM runtime来负责获取新代码
(2)可以使用webpack-hot-middleware来代替webpack-dev-server实现HMR,webpack-hot-middleware没有使用Websocket,而是使用EventSource来实现客户端与服务端通信。
第五步:HMR runtime对模块进行热更新(对应⑩)
HMR runtime会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
这一切都发生在HMR runtime的hotApply
方法中:
1 | // webpack/lib/HotModuleReplacement.runtime |
hotApply
方法主要分为了三个阶段:
- 找出陈旧的模块
outdatedModules
和依赖outdatedDependencies
- 从缓存中删除过期的模块和依赖
- 将新的模块和依赖添加到
moudles
中,当下次调用_webpack_require
方法时就获取到新的代码
如果HMR失败后,回退到live reload
操作,也就是进行浏览器刷新来获取最新打包代码,相关的代码在dev-server中:
1 | module.hot.check(true).then(function(updatedModules) { |
第六步:业务代码改造
当新的模块代替老的模块后,旧的业务代码并不能知道代码发生变化,所以需要在业务代码的入口调用HMR的accept
方法,添加模块更新后的处理函数:
1 | // index.js |