作者:李名凯,饿了么前端工程师。热爱开源社区,并热衷于钻研前端技术。
导语:在现代前端工程中,模块化已经成了前端项目组织文件的标配,网站上线前都会把需要的相关模块预先打包、处理一番。然而打包的方式多种多样,如何才能最优雅的分离业务代码和依赖库?如何才能最高效的利用缓存?本文将会和大家分享饿了么前端团队总结的各方案优劣、踩过的坑,以及最终的解决方案。
众所周知,对于一个站点而言,网站的加载时间一直都是一个很重要的指标。网页加载时间的长短直接影响到了站点的访问量。试想,正在看这篇文章的你,会有多少耐心等待一个网页慢悠悠的打开呢?
对于前端而言,缩短网页加载时间的常见方式有:
为了让更改过的文件能够生效,我们还会给每个文件的文件名里加上一段根据文件内容计算出的hash。每当文件内容改变时,这段hash也会随之改变,所以浏览器会通过网络下载更新过的文件,但没有更新过的文件仍然会从缓存里读取,从而缩短加载时间。
同理,在开发一个单页面应用的时候,我们通常会将应用的JavaScript代码打包成两个文件:一个用于存放内容很少更改的第三方依赖库,这部分代码的体积一般会比较大;另一个存放更改比较频繁的业务逻辑代码,但它的体积一般比第三方依赖库小。为了方便描述,我们可以分别称这两个文件为vendor.js与app.js。
有了优化方案,接下来就该选择打包工具了。毫无疑问,时下最流行的就是Webpack了。Webpack在文档里提供了一段简单易懂的配置,用于将项目中的JavaScript代码打包成vendor.js与app.js这两个文件,并分别在它们的文件名里加上一段根据文件内容生成的hash,就像前面说的那样:
const webpack = require('webpack')
module.exports = {
entry: {
vendor: ['jquery', 'other-lib'],
app: './entry'
},
output: {
filename: '[name].[chunkhash].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
})
]
}
但是,几乎所有使用类似配置的人都遇到了一个问题:每当更改了业务逻辑代码时,都会导致vendor.js的hash发生变化。这意味着用户仍然要重新下载vendor.js,即使这部分代码并没有变过。
为此,开源社区里有人给Webpack指出了这个问题,并吸引了很多人一同讨论,一时之间涌出了很多解决的办法,但这些办法既有人说有用,也有人说没用,而官方却迟迟没有给出一个定论。
为了得到一个准确的答案,我们尝试了社区里几乎所有的方案。接下来,本文会依次给大家介绍我们尝试过的种种办法,并在文章的最后给出行之有效的解决方案。
一、使用webpack-md5-hash插件
社区有人提供了这个插件用来替换Webpack生成的chunkhash:
const webpack = require('webpack')
const WebpackMd5Hash = require('webpack-md5-hash')
module.exports = {
entry: {
vendor: ['jquery', 'other-lib'],
app: './entry'
},
output: {
filename: '[name].[chunkhash].js'
},
plugins: [
new WebpackMd5Hash(),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
})
]
}
它的原理是:根据模块打包前的代码内容生成hash,而不是像Webpack那样根据打包后的内容生成hash。经简单测试,在修改业务代码后,它确实能保证vendor.js的hash不被改变,于是我们满心欢喜的将它用到了正式环境,但网站却在上线之后变成了一片空白。
随后,我们对比了两次编译生成的vendor.js,发现代码里的模块id已经变了,但由于 hash没有更新,所以项目上线后,浏览器直接从缓存里读取了上次上线时的旧版 vendor.js文件,但此时新版的app.js里引用的id为41的模块,在旧版里其实是40,从而引用了错误的模块导致发生了错误,中断了代码的运行。
不久之后,社区里也有人提出了这个问题。
二、从vendor.js中抽离出Webpack的运行时代码
有人指出,Webpack的CommonsChunkPlugin会在第一个entry里注入一些运行时代码。按照模块的依赖关系,第一个entry当然就是vendor.js了。这段运行时代码里包含了最终编译出来的app.js的文件名,而app.js的文件名里包含的hash在每次更改业务代码后都会变,所以包含了这段代码的vendor.js的内容也会改变,这才导致它的hash总是不固定。所以,我们需要从vendor.js里抽离出这段运行时代码,才能避免 vendor.js的hash受到影响。
除此之外,我们还需要用到OccurenceOrderPlugin,将模块按照一定的顺序排序,这才能保证每次编译时模块的id都是相同的,否则模块id一旦改变,就会引起文件内容的变化并影响到hash。
最终的Webpack配置就像下面这样:
const webpack = require('webpack')
module.exports = {
entry: {
vendor: ['jquery', 'other-lib'],
app: './entry'
},
output: {
filename: '[name].[chunkhash].js'
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
})
]
}
这个方法确实有效,但我们发现,在删除或新增业务代码中的模块时,vendor.js的hash偶尔还是会受到影响。Webpack的作者也提到了这一点,原文大意如下:
默认情况下,模块的id是这个模块在模块数组中的索引。OccurenceOrderPlugin 会将引用次数多的模块放在前面,在每次编译时模块的顺序都是一致的……如果你修改代码时新增或删除了一些模块,这将会影响到所有模块的id。
所以,这个方案也不能完全保证vendor.js的hash不受到业务代码的影响。
三、使用NamedModulesPlugin
在尝试过第二个解决方案后,我们意识到问题的根源在于Webpack使用模块的引用顺序作为模块的id,这样就不能避免新增或删除模块对其他模块的id产生影响。
不过,Webpack提供了NamedModulesPlugin插件,它使用模块的相对路径作为模块的 id,所以只要我们不重命名一个模块文件,那么它的id就不会变,更不会影响到其它模块了:
const webpack = require('webpack')
module.exports = {
entry: {
vendor: ['jquery', 'other-lib'],
app: './entry'
},
output: {
filename: '[name].[chunkhash].js'
},
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor'
})
]
}
但是,相对路径比数字id要长了很多。
社区对比了使用这个插件后文件的大小,结论是在gzip压缩后,文件并没有大多少。然而我们在项目里实际使用之后,虽然 vendor.js 只比以前大了 1KB,但 app.js 却大了近 15%。
所以,我们对于这个解决方案仍然不是很满意。
本文全部内容,请见《程序员》2017年2月期。