Webpack构建优化—模块热替换和自动刷新

使用自动刷新

在开发阶段,修改源码是不可避免的操作。对于开发网页来说,要想看到修改后的效果,需要刷新浏览器让其重新运行最新的代码才行。借助自动化的手段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。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模块。

自动刷新的原理

控制浏览器刷新有三种方法:

  1. 借助浏览器扩展去通过浏览器提供的接口刷新,WebStorm IDE的LiveEdit功能就是这样实现的。
  2. 往要开发的网页中注入代理客户端代码,通过代理客户端去刷新整个页面。
  3. 把要开发的网页装进一个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中,编辑源码后,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中都包含代理客户端的代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容