本文深入解析Webpack前端性能优化实战,涵盖利用externals分离第三方库、通过CommonsChunkPlugin配置实现持久化缓存,以及解决vendor哈希值变动问题。结合vue-cli配置与CDN策略,展示如何将首屏加载时间从3800ms缩短至1600ms,并提供完整的代码配置与原理分析。
前端开发规范化与构建工具概述
前言

过去,我们常采用直接引入JS资源的方式开发,如今这种模式已较少见。近年来,Web前端开发逐渐走向规范化,主要体现在两个方面:
1、采用MVC研发架构。其优势在于逻辑清晰,注重数据与表现分离,代码可读性强,有助于规避和排查问题。
2、构建工具不断涌现。其优势在于提升团队协作效率及工程运维水平,避免人工处理繁琐重复的工作。
- 模块化开发
- 将前端性能优化理论落地,包括代码压缩、合并、缓存控制以及提取公共代码等。
- 此外,还可以使用ES 6或CoffeeScript编写源码,再通过构建工具转换为浏览器支持的ES 5。
因此,前端开发充满乐趣。若项目仍未实现前后端分离,则显得过于守旧。
主流构建工具
市面上存在多种构建工具,如Grunt、Gulp、browserify等,它们与WebPack同属打包工具。但WebPack还具备以下特点:
与Grunt相比,WebPack不仅插件丰富,还内置了一套加载(Loader)系统。它支持多种规范的加载方式,包括ES 6、CommonJS、AMD等,这是Grunt和Gulp所不具备的。
从代码混淆的角度来看,WebPack的处理更为极致。
代码分片以处理单元(而非文件)为单位,使得文件分片更加灵活。
注:此处仅作简单比较,不评判优劣。实际上,任何工具都能满足需求,关键在于如何使用,而工具的使用背后反映的是对前端性能优化的理解程度。
Webpack首屏性能优化实战
引言
近期,我使用Webpack优化首屏加载性能,通过几种插件的配置,上线后的速度比优化前提升了一倍。在此简要分享,先展示优化前后首屏渲染的对比图。

可以看到,总下载时间从3800ms缩短到了1600ms。
在使用Webpack时,我们通常会选择多入口文件,目的是将源码与第三方库代码分离。这是之前的代码配置:
entry: {
entry: './src/main.js',
vendor: ['vue', 'vue-router', 'vuex', 'element-ui','echarts']
},
output: {
path: config.build.assetsRoot,
filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
}
由于ech体积较大,打包后的vendor.js约为1.2MB(经过gzip压缩后)。此外,首页并未使用echarts,因此我后续使用了externals将第三方库改为通过CDN引入。以下是优化后的代码:
entry: {
entry: './src/main.js',
vendor: ['vue', 'vue-router', 'vuex', 'element-ui']
},
// 这里的output为base中的output,不是生产的output
output: {
path: config.build.assetsRoot,
filename: '[name].js',
libraryTarget: "umd",
publicPath: process.env.NODE_ENV === 'production' ?
config.build.assetsPublicPath : config.dev.assetsPublicPath
},
externals: {
echarts: 'echarts',
_: 'lodash'
},


这就是优化前后的对比结果。
随后,我们需要在HTML中以script标签的形式引入externals中的CDN资源。之后便可在相应文件中通过import引入,其好处是无论你在多少个vue文件中引用多少次,它都不会被打包到所有的trunk(这里trunk指按需加载,稍后详细说明)中。这是使用webpack-bundle-analyzer插件展示的效果。
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
new BundleAnalyzerPlugin({
// 可以是`server`,`static`或`disabled`。
// 在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。
// 在“静态”模式下,会生成带有报告的单个HTML文件。
// 在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。
analyzerMode: 'server',
// 将在“服务器”模式下使用的主机启动HTTP服务器。
analyzerHost: '127.0.0.1',
// 将在“服务器”模式下使用的端口启动HTTP服务器。
analyzerPort: 8888,
// 路径捆绑,将在`static`模式下生成的报告文件。
// 相对于捆绑输出目录。
reportFilename: 'report.html',
// 模块大小默认显示在报告中。
// 应该是`stat`,`parsed`或者`gzip`中的一个。
// 有关更多信息,请参见“定义”一节。
defaultSizes: 'parsed',
// 在默认浏览器中自动打开报告
openAnalyzer: true,
// 如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成
generateStatsFile: false,
// 如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。
// 相对于捆绑输出目录。
statsFilename: 'stats.json',
// stats.toJson()方法的选项。
// 例如,您可以使用`source:false`选项排除统计文件中模块的来源。
// 在这里查看更多选项:https: //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
statsOptions: null,
logLevel: 'info' //日志级别。可以是'信息','警告','错误'或'沉默'。
})


我们可以看到,未使用externals和使用externals后,所有js文件中均不再出现类似echarts和lodash的库(即使你import一万次,它也不会被打包一次,效果显著)。
关于externals,还需补充两点:
1.externals中的key对应import中使用的名称。
import lodash from "_";
import echarts from "echarts";
2.externals中的value对应window下调用的名称。

持久化缓存与CommonsChunkPlugin配置
接下来,我们探讨为何output使用trunkhash而非trunk,这是为了实现持久化缓存。简要说明两者的区别:
trunk:每次build后的版本,即所有build后的文件hash值一致。例如,仅修改一个文件,最终所有文件的hash都会变化,导致所有文件无法命中缓存,缓存失去意义。
trunkhash:根据每个文件生成不同的hash值,当文件变化时,仅相应文件的hash会改变。
接着,我们肯定会用到CommonsChunkPlugin,该插件用于抽取公共代码。基本上99%的配置都是类似以下形式,使用两个不同的CommonsChunkPlugin,但这从某方面来说并未实现真正意义上的持久化缓存,稍后我会通过Webpack打包原理详细解释原因。
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor','manifest']
})
在未使用该插件之前,我们的main.js和vendor.js大致如下:

大家会看到这两个文件存在公共部分,如vue和element-ui,因此我们需要将公共代码抽取到vendor中。我们可以先这样配置:
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
}),
但这样虽然可以提取公共代码,却会将runtime(Webpack运行时的代码,打包原理中会再次提及)也放入vendor中。runtime中维护一个trunk的文件列表,类似如下结构,这意味着修改任意代码,该table中的hash会变化,进而导致vendor的hash也变化:
因此,这并未实现真正的持久化缓存。这个hash table是按需缓存打包出的trunk包,通常通过require.ensure(即vue-router中配置的page对应页面,按需加载)生成。

因此,我们将name改为names,即上述配置。因为使用该插件时,我们会将公共代码抽到第一个name中,将runtime放到最后一个name中,即所谓的“manifest”文件。
并且该文件较小,通常约2kb。因此build后会生成一个script标签,但这会增加一个HTTP请求。我们可以使用另一个插件(InlineManifestWebpackPlugin)将manifest.js内联进去,效果如下:

再回到CommonsChunkPlugin。现在,无论我们修改任何已存在的文件,vendor.js的hash都不会变化。是的,这似乎实现了持久化缓存。但是,当我们新增一个模块,并在入口文件中import时,vendor会与main一起变化。这很奇怪,对吧?我们明明已经将源码与第三方库分离,为何vendor还会变(至今似乎没有博客对此进行详细说明)。下面我将详细解释我的看法,若发现错误,敬请指正。
Webpack打包原理与Vendor哈希变动解析
在解释之前,我们先简单了解Webpack的打包规则。
Webpack中一个entry对应一个bundle,该bundle包括入口文件及其依赖的模块。其他按需加载的模块则打包成其他bundle。还有一个重要文件是manifest,它最先加载,负责打包其他bundle并按需加载和执行。
manifest是一个自执行函数,熟悉Angular的同学看第一行应该很熟悉,因为Angular 1.3版本的源码中启动就是angular.bootstrap。这里同理,其中的modules变量对应模块函数,它是Webpack处理的基本单位,即对应打包前的一个文件。

这是JS源文件:


这是打包后的文件:

所有模块函数的索引都是连续的(每个JS文件生成一个trunkid!!!!!),如/* 4 */对应的就是JS文件,打包后变成了一个个trunkid。仔细看会看到,打包前JS文件里的export和require依赖都会统一转换成Webpack模块。我们所说的webpackJsonp就是除manifest外打包其他文件的函数体。
简要说明main部分,此图的trunkid是连续的,为了在一张图上显示,我截掉了trunk3-7。

这里面一共有三个参数,第一个是当前文件的trunkid,它是唯一标识符,指main的trunkid;第二个是打包的所有文件的模块函数;第三个是我要立即执行的trunkid模块函数。
OK,介绍这些就足够了。
然后我们再回过头来看看为何我们所谓的CommonsChunkPlugin会变。刚才说过,有几个JS就有几个trunkid。
因此,当我们新加一个JS并引入到main入口时,Webpack再次打包,main文件会多一个模块函数。刚才说过trunkid是依次递增且不会重复的。所以对应的vendor的id会+1,正是这种细微变化导致hash变了。



大家仔细看,这两个vendor都是10272行,唯一的不同就是我要自执行这个vendor库,这里我引用的jquery,所以这个文件只有jquery。自执行肯定要有模块函数,trunkid+1,所以hash会变。我们再好好回忆一下,其实这也说明了这个插件的意义,就是要抽出公共的库,OK,这个插件做到了。但是因为Webpack打包机制,不同文件生成不同turnkid,所以这是美中不足的一点。再回想一下,我们一般是不会随便修改main.js的,所以从另一角度上来说这就是实现了持久化缓存。但如果就是想保持vendor的hash不变要怎么办呢?

这段代码就可以实现,没错,如果你对vue-cli了如指掌,这就是vue-cli的官方demo。至于为什么可以,这个我后续会跟大家解释(实在是写不动了。。。)。
CDN策略与静态资源处理
最后再给大家介绍一个超级好用的东西,就是CDN。我们现在的需求是想让图片走CDN,让JS走线上路径,但官方的解释是通过修改config文件做CDN变化。这样做的话,所有输出都会走CDN,那所有的Ajax请求就跨域了呀。

一开始我的解决方案是,在源文件中挨个替换,这样会比较慢。更重要的是,CDN图片也是有hash值的,当我以后替换图片时,还得重新改相应的hash。有什么方法能让他自动去获取hash呢。
没错,我们需要在url-loader中单独配置CDN,做到JS访问线上路径,静态资源使用CDN,两者互不影响。

简单提醒一下,url-loader不能检测到JS中的background,所以我们凡是在JS中引用的地址,必须在外面先import这张图片,url-loader才会解析并打包。
今天就先到这里吧,改天继续。
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值。如果有疑问大家可以留言交流,谢谢大家对本站的支持。