使用自动刷新
在开发阶段,修改源码是不可避免的操作。对于开发网页来说,要想看到修改后的效果,需要刷新浏览器让其重新运行最新的代码才行。借助自动化的手段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。Webpack把这些功能都内置了,并且还提供多种方案可选。
文件监听
文件监听是在发现源码文件发生变化时,自动重新构建出新的输出文件。
Webpack官方提供了两大模块,一个是核心的webpack,一个是webpack-dev-server扩展模块。 而文件监听功能是webpack模块提供的。
Webpack支持文件监听相关的配置项如下:
module.export = {
// 只有在开启监听模式时,watchOptions 才有意义
// 默认为 false,也就是不开启
watch: true,
// 监听模式运行时的参数
// 在开启监听模式时,才有意义
watchOptions: {
// 不监听的文件或文件夹,支持正则匹配
// 默认为空
ignored: /node_modules/,
// 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
// 默认为 300ms
aggregateTimeout: 300,
// 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
// 默认每隔1000毫秒询问一次
poll: 1000
}
}
要让Webpack开启监听模式,有两种方式:
- 在配置文件
webpack.config.js
中设置watch: true
。 - 在执行启动Webpack命令时,带上
--watch
参数,完整命令是webpack --watch
。
文件监听工作原理
在 Webpack 中监听一个文件发生变化的原理是定时的去获取这个文件的最后编辑时间,每次都存下最新的最后编辑时间,如果发现当前获取的和最后一次保存的最后编辑时间不一致,就认为该文件发生了变化。 配置项中的watchOptions.poll
就是用于控制定时检查的周期,具体含义是每隔多少毫秒检查一次。
当发现某个文件发生了变化时,并不会立刻告诉监听者,而是先缓存起来,收集一段时间的变化后,再一次性告诉监听者。 配置项中的watchOptions.aggregateTimeout
就是用于配置这个等待时间。这样做的目的是因为我们在编辑代码的过程中可能会高频的输入文字导致文件变化的事件高频的发生,如果每次都重新执行构建就会让构建卡死。
对于多个文件来说,原理相似,只不过会对列表中的每一个文件都定时的执行检查。 但是这个需要监听的文件列表是怎么确定的呢?默认情况下Webpack会从配置的Entry
文件出发,递归解析出Entry
文件所依赖的文件,把这些依赖的文件都加入到监听列表中去。
由于保存文件的路径和最后编辑时间需要占用内存,定时检查周期检查需要占用CPU以及文件I/O,所以最好减少需要监听的文件数量和降低检查频率。
优化文件监听性能
开启监听模式时,默认情况下会监听配置的Entry
文件和所有其递归依赖的文件。 在这些文件中会有很多存在于node_modules
下。 在大多数情况下我们都不可能去编辑node_modules
下的文件,而是编辑自己建立的源码文件。所以一个很大的优化点就是忽略掉node_modules
下的文件,不监听它们。相关配置如下:
module.export = {
watchOptions: {
// 不监听的 node_modules 目录下的文件
ignored: /node_modules/,
}
}
有时你可能会觉得node_modules
目录下的第三方模块有bug,想修改第三方模块的文件,然后在自己的项目中试试。 在这种情况下如果使用了以上优化方法,我们需要重启构建以看到最新效果。 但这种情况毕竟是非常少见的。
除了忽略掉部分文件的优化外,还有如下两种方法:
-
watchOptions.aggregateTimeout
值越大性能越好,因为这能降低重新构建的频率。 -
watchOptions.poll
值越大越好,因为这能降低检查的频率。
但两种优化方法的后果是会让你感觉到监听模式的反应和灵敏度降低了。
自动刷新浏览器
监听到文件更新后的下一步是去刷新浏览器,webpack模块负责监听文件,webpack-dev-server模块则负责刷新浏览器。 在使用webpack-dev-server 模块去启动webpack模块时,webpack模块的监听模式默认会被开启。 webpack模块会在文件发生变化时告诉webpack-dev-server模块。
自动刷新的原理
控制浏览器刷新有三种方法:
- 借助浏览器扩展去通过浏览器提供的接口刷新,WebStorm IDE的LiveEdit功能就是这样实现的。
- 往要开发的网页中注入代理客户端代码,通过代理客户端去刷新整个页面。
- 把要开发的网页装进一个
iframe
中,通过刷新iframe
去看到最新效果。
DevServer支持第2、3种方法,第2种是DevServer默认采用的刷新方法。
通过DevServer启动构建后,你会看到如下日志:
> webpack-dev-server
Project is running at http://localhost:8080/
webpack output is served from /
Hash: e4e2f9508ac286037e71
Version: webpack 3.5.5
Time: 1566ms
Asset Size Chunks Chunk Names
bundle.js 1.07 MB 0 [emitted] [big] main
bundle.js.map 1.27 MB 0 [emitted] main
[115] multi (webpack)-dev-server/client?http://localhost:8080 ./main.js 40 bytes {0} [built]
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[159] ./node_modules/ansi-html/index.js 4.26 kB {0} [built]
[163] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[165] (webpack)/hot/emitter.js 77 bytes {0} [built]
[167] ./main.js 2.28 kB {0} [built]
+ 255 hidden modules
细心的你会观察到输出的bundle.js
中包含了以下七个模块:
[116] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[117] ./node_modules/url/url.js 23.3 kB {0} [built]
[120] ./node_modules/querystring-es3/index.js 127 bytes {0} [built]
[123] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[125] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[126] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[158] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
这七个模块就是代理客户端的代码,它们被打包进了要开发的网页代码中。
在浏览器中打开网址http://localhost:8080/
后,在浏览器的开发者工具中你会发现由代理客户端向DevServer发起的WebSocket连接:
优化自动刷新的性能
devServer.inline
是用来控制是否往Chunk
中注入代理客户端的,默认会注入。 事实上,在开启inline
时,DevServer会为每个输出的Chunk
中注入代理客户端的代码,当你的项目需要输出的Chunk
有很多个时,这会导致你的构建缓慢。 其实要完成自动刷新,一个页面只需要一个代理客户端就行了,DevServer之所以粗暴的为每个Chunk
都注入,是因为它不知道某个网页依赖哪几个Chunk
,索性就全部都注入一个代理客户端。 网页只要依赖了其中任何一个Chunk
,代理客户端就被注入到网页中去。
这里优化的思路是关闭还不够优雅的inline
模式,只注入一个代理客户端。 为了关闭inline
模式,在启动DevServer时,可通过执行命令webpack-dev-server --inline false
(也可以在配置文件中设置),这时输出的日志如下:
> webpack-dev-server --inline false
Project is running at http://localhost:8080/webpack-dev-server/
webpack output is served from /
Hash: 5a43fc44b5e85f4c2cf1
Version: webpack 3.5.5
Time: 1130ms
Asset Size Chunks Chunk Names
bundle.js 750 kB 0 [emitted] [big] main
bundle.js.map 897 kB 0 [emitted] main
[81] ./main.js 2.29 kB {0} [built]
+ 169 hidden modules
和前面的不同在于
- 入口网址变成了
http://localhost:8080/webpack-dev-server/
-
bundle.js
中不再包含代理客户端的代码了
在浏览器中打开网址http://localhost:8080/webpack-dev-server/
后,你会看到如下效果:
要开发的网页被放进了一个iframe
中,编辑源码后,iframe
会被自动刷新。 同时你会发现构建时间从1566ms减少到了1130ms,说明优化生效了。构建性能提升的效果在要输出的Chunk
数量越多时会显得越突出。
如果你不想通过iframe
的方式去访问,但同时又想让网页保持自动刷新功能,你需要手动往网页中注入代理客户端脚本,往index.html
中插入以下标签:
<!--注入 DevServer 提供的代理客户端脚本,这个服务是 DevServer 内置的-->
<script src="http://localhost:8080/webpack-dev-server.js"></script>
给网页注入以上脚本后,独立打开的网页就能自动刷新了。但是要注意在发布到线上时记得删除掉这段用于开发环境的代码。
开启模块热替换
要做到实时预览,除了使用自动刷新刷新整个网页外,DevServer还支持一种叫做模块热替换(Hot Module Replacement
)的技术可在不刷新整个网页的情况下做到超灵敏的实时预览。 原理是当一个源码发生变化时,只重新编译发生变化的模块,再用新输出的模块替换掉浏览器中对应的老模块。
模块热替换技术的优势有:
- 实时预览反应更快,等待时间更短。
- 不刷新浏览器能保留当前网页的运行状态,例如在使用Redux来管理数据的应用中搭配模块热替换能做到代码更新时Redux中的数据还保持不变。
总的来说模块热替换技术很大程度上的提高了开发效率和体验。
模块热替换的原理
模块热替换的原理和自动刷新原理类似,都需要往要开发的网页中注入一个代理客户端用于连接DevServer和网页, 不同在于模块热替换独特的模块替换机制。
DevServer默认不会开启模块热替换模式,要开启该模式,只需在启动时带上参数--hot
,完整命令是webpack-dev-server --hot
。
除了通过在启动时带上--hot
参数,还可以通过接入Plugin
实现,相关代码如下:
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
module.exports = {
entry:{
// 为每个入口都注入代理客户端
main:['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server','./src/main.js'],
},
plugins: [
// 该插件的作用就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注入该插件,生成 .hot-update.json 文件。
new HotModuleReplacementPlugin(),
],
devServer:{
// 告诉 DevServer 要开启模块热替换模式
hot: true,
}
};
在启动Webpack时带上参数--hot
其实就是自动为你完成以上配置。启动后日志如下:
> webpack-dev-server --hot
Project is running at http://localhost:8080/
webpack output is served from /
webpack: wait until bundle finished: /
webpack: wait until bundle finished: /bundle.js
Hash: fe62ac6b753c1d98961b
Version: webpack 3.5.5
Time: 3563ms
Asset Size Chunks Chunk Names
bundle.js 1.11 MB 0 [emitted] [big] main
bundle.js.map 1.33 MB 0 [emitted] main
[50] (webpack)/hot/log.js 1.04 kB {0} [built]
[118] multi (webpack)-dev-server/client?http://localhost:8080 webpack/hot/dev-server ./main.js 52 bytes {0} [built]
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
[170] ./main.js 2.35 kB {0} [built]
+ 262 hidden modules
可以看出bundle.js
代理客户端相关的代码包含九个文件:
[119] (webpack)-dev-server/client?http://localhost:8080 5.83 kB {0} [built]
[120] ./node_modules/url/url.js 23.3 kB {0} [built]
[126] ./node_modules/strip-ansi/index.js 161 bytes {0} [built]
[128] ./node_modules/loglevel/lib/loglevel.js 6.74 kB {0} [built]
[129] (webpack)-dev-server/client/socket.js 856 bytes {0} [built]
[161] (webpack)-dev-server/client/overlay.js 3.6 kB {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
[168] (webpack)/hot/dev-server.js 1.61 kB {0} [built]
[169] (webpack)/hot/log-apply-result.js 1.31 kB {0} [built]
相比于自动刷新的代理客户端,多出了后三个用于模块热替换的文件,也就是说代理客户端更大了。
修改源码main.css
文件后,新输出了如下日志:
webpack: Compiling...
Hash: 18f81c959118f6230623
Version: webpack 3.5.5
Time: 551ms
Asset Size Chunks Chunk Names
bundle.js 1.11 MB 0 [emitted] [big] main
0.ea11a51f97f2b52bca7d.hot-update.js 353 bytes 0 [emitted] main
ea11a51f97f2b52bca7d.hot-update.json 43 bytes [emitted]
bundle.js.map 1.33 MB 0 [emitted] main
0.ea11a51f97f2b52bca7d.hot-update.js.map 577 bytes 0 [emitted] main
[68] ./node_modules/css-loader!./main.css 217 bytes {0} [built]
[166] (webpack)/hot nonrecursive ^\.\/log$ 170 bytes {0} [built]
+ 275 hidden modules
webpack: Compiled successfully.
DevServer新生成了一个用于替换老模块的补丁文件0.ea11a51f97f2b52bca7d.hot-update.js
,同时在浏览器开发工具中也能看到请求这个补丁的抓包:
可见补丁中包含了main.css
文件新编译出来CSS代码,网页中的样式也立刻变成了源码中描述的那样。
但当你修改main.js
文件时,会发现模块热替换没有生效,而是整个页面被刷新了,为什么修改main.js
文件时会这样呢?
Webpack为了让使用者在使用了模块热替换功能时能灵活地控制老模块被替换时的逻辑,可以在源码中定义一些代码去做相应的处理。
把main.js
文件改为如下:
import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';
import './main.css';
render(<AppComponent/>, window.document.getElementById('app'));
// 只有当开启了模块热替换时 module.hot 才存在
if (module.hot) {
// accept 函数的第一个参数指出当前文件接受哪些子模块的替换,这里表示只接受 ./AppComponent 这个子模块
// 第2个参数用于在新的子模块加载完毕后需要执行的逻辑
module.hot.accept(['./AppComponent'], () => {
// 新的 AppComponent 加载成功后重新执行下组建渲染逻辑
render(<AppComponent/>, window.document.getElementById('app'));
});
}
其中的module.hot
是当开启模块热替换后注入到全局的API,用于控制模块热替换的逻辑。
现在修改AppComponent.js
文件,把Hello,Webpack
改成Hello,World
,你会发现模块热替换生效了。 但是当你编辑main.js
时,你会发现整个网页被刷新了。为什么修改这两个文件会有不一样的表现呢?
当子模块发生更新时,更新事件会一层层往上传递,也就是从AppComponent.js
文件传递到main.js
文件, 直到有某层的文件接受了当前变化的模块,也就是main.js
文件中定义的module.hot.accept(['./AppComponent'], callback)
,这时就会调用callback
函数去执行自定义逻辑。如果事件一直往上抛到最外层都没有文件接受它,就会直接刷新网页。
那为什么没有地方接受过.css
文件,但是修改所有的.css
文件都会触发模块热替换呢?原因在于style-loader
会注入用于接受CSS的代码。
请不要把模块热替换技术用于线上环境,它是专门为提升开发效率生的。
优化模块热替换
在发生模块热替换时,你会在浏览器的控制台中看到类似这样的日志:
其中的Updated modules: 68
是指ID为68的模块被替换了,这对开发者来说很不友好,因为开发者不知道ID和模块之间的对应关系,最好是把替换了的模块的名称输出出来。Webpack内置的NamedModulesPlugin
插件可以解决该问题,修改Webpack配置文件接入该插件:
const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');
module.exports = {
plugins: [
// 显示出被替换模块的名称
new NamedModulesPlugin(),
],
};
重启构建后你会发现浏览器中的日志更加友好了:
除此之外,模块热替换还面临着和自动刷新一样的性能问题,因为它们都需要监听文件变化和注入客户端。 要优化模块热替换的构建性能,思路是:监听更少的文件,忽略掉node_modules
目录下的文件。 但是其中提到的关闭默认的inline
模式手动注入代理客户端的优化方法不能用于在使用模块热替换的情况下, 原因在于模块热替换的运行依赖在每个Chunk
中都包含代理客户端的代码。