随着前端代码须要处理的业务愈来愈繁重,咱们不得不面临的一个问题是前端的代码体积也变得愈来愈庞大。这形成不管是在调式仍是在上线时都须要花长时间等待编译完成,而且用户也不得不花额外的时间和带宽下载更大致积的脚本文件。javascript
然而仔细想一想这彻底是能够避免的:在开发时难道一行代码的修改也要从新打包整个脚本?用户只是粗略浏览页面也须要将整个站点的脚本所有下载下来?因此趋势必然是按需的、有策略性的将代码拆分和提供给用户。最近流行的微前端某种意义上来讲也是遵循了这样的原则(但也并非彻底基于这样的缘由)css
幸运的是,咱们目前已有的工具已经彻底赋予咱们实现以上需求的能力。例如 Webpack 容许咱们在打包时将脚本分块;利用浏览器缓存咱们可以有的放矢的加载资源。前端
在探寻最佳实践的过程当中,最让我疑惑的不是咱们能不能作,而是咱们应该如何作:咱们因该采起什么样的特征拆分脚本?咱们应该使用什么样的缓存策略?使用懒加载和分块是否有殊途同归之妙?拆分以后究竟能带来多大的性能提高?最重要的是,在面多诸多的方案和工具以及不肯定的因素时,咱们应该如何开始?这篇文章就是对以上问题的梳理和回答。文章的内容大致分为两个方面,一方面在思路制定模块分离的策略,另外一方面从技术上对方案进行落地。java
本文的主要内容翻译自 The 100% correct way to split your chunks with Webpack。 这篇文章按部就班的引导开发者步步为营的对代码进行拆分优化,因此它是做为本文的线索存在。同时在它的基础上,我会对 Webpack 及其余的知识点作纵向扩展,对方案进行落地。node
如下开始正文react
根据 Webpack 术语表,存在两类文件的分离。这些名词听起来是能够互换的,但实际上不行:webpack
第二种策略听起来更吸引人是否是?事实上许多的文章也假定认为这才是惟一值得将 JavaScript 文件进行小文件拆分的场景。git
可是我在这里告诉你第一种策略对许多的站点来讲才更有价值,而且应该是你首先为页面作的事github
让咱们来深刻理解web
在正式开始编码以前,咱们仍是要明确一些概念。例如咱们贯穿全文的“块”(chunk) ,以及它和咱们经常提到的“包”(bundle)以及“模块”(module) 到底有什么区别。
遗憾的事情是即便在查阅了不少资料以后,我仍然无法获得一个确切的标准答案,因此这里我选择我我的比较承认的定义在这里作一个分享,重要的仍是但愿能起到统一口径的做用
首先对于“模块”(module)的概念相信你们都没有异议,它指的就是咱们在编码过程当中有意识的封装和组织起来的代码片断。狭义上咱们首先联想到的是碎片化的 React 组件,或者是 CommonJS 模块又或者是 ES6 模块,可是对 Webpack 和 Loader 而言,广义上的模块还包括样式和图片,甚至说是不一样类型的文件
而“包”(bundle) 就是把相关代码都打包进入的单个文件。若是你不想把全部的代码都放入一个包中,你能够把它们划分为多个包,也就是“块”(chunk) 中。从这个角度上看,“块”等于“包”,它们都是对代码再一层的组织和封装。若是必需要给一个区分的话,一般咱们在讨论时,bundle 指的是全部模块都打包进入的单个文件,而 chunk 指的是按照某种规则的模块集合,chunk 的体积大于单个模块,同时小于整个 bundle
(但若是要仔细的深究,Chunk是 Webpack 用于管理打包流程中的技术术语,甚至能划分为不一样类型的 chunk。我想咱们不用从这个角度理解。只须要记住上一段的定义便可)
打包分离背后的思想很是简单。若是你有一个体积巨大的文件,而且只改了一行代码,用户仍然须要从新下载整个文件。可是若是你把它分为了两个文件,那么用户只须要下载那个被修改的文件,而浏览器则能够从缓存中加载另外一个文件。
值得注意的是由于打包分离与缓存相关,因此对站点的首次访问者来讲没有区别
(我认为太多的性能讨论都是关于站点的首次访问。或许部分缘由是由于第一映像很重要,另外一部分由于这部分性能测量起来简单和讨巧)
当谈论到频繁访问者时,量化性能提高会稍有棘手,可是咱们必须量化!
这将须要一张表格,咱们将每一种场景与每一种策略的组合结果都记录下来
咱们假设一个场景:
固然包括我在内的部分人但愿场景尽量的逼真。但其实可有可无,咱们随后会解释为何。
假设咱们的 JavaScript 打包后的整体积为 400KB, 将它命名为 main.js
,而后以单文件的形式加载它
咱们有一个相似以下的 Webpack 配置(我已经移除了无关的配置项):
const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
};
复制代码
当只有单个入口时,Webpack 会自动把结果命名为main.js
(对那些刚接触缓知识的人我解释一下:每当我我说起main.js
的时候,我其实是在说相似于main.xMePWxHo.js
这种包含一堆带有文件内容哈希字符串的东西。这意味着当你应用代码发生更改时新的文件名会生成,这样就能迫使浏览器下载新的文件)
因此当每周我向站点发布新的变动时,包的contenthash
就会发生更改。以致于每周 Alice 访问咱们站点时不得不下载一个全新的 400KB 大小的文件
连续十周也就是 4.12MB
咱们能作的更好
不知道你是否真的理解上面的表述。有几点须要在这里澄清:
contenthash
?若是把contenthash
替换成hash
或者chunkhash
有什么影响?为了每次访问时不让浏览器都从新下载同一个文件,咱们一般会把这个文件返回的 HTTP 头中的Cache-Control
设置为max-age=31536000
,也就是一年(秒数的)时间。这样以来,在一年以内用户访问这个文件时,都不会再次向服务器发送请求而是直接从缓存中读取,直到或者手动清除了缓存。
若是我中途修改了文件内容必须让用户从新下载怎么办?修改文件名就行了,不一样的文件(名)对应不一样的缓存策略。而一个哈希字符串就是根据文件内容产生的“签名”,每当文件内容发生更改时,哈希串也就发生了更改,文件名也就随之更改。这样一来,旧版本文件的缓存策略就会失效,浏览器就会从新加载新版本的该文件。固然这只是其中一种最基础的缓存策略,更复杂的场景请参考我以前的一篇文章:设计一个无懈可击的浏览器缓存方案:关于思路,细节,ServiceWorker,以及HTTP/2
因此在 Webpack 中配置的 filename: [name]:[contenthash].js
就是为了每次发布时自动生成新的文件名。
然而若是你对 Webpack 稍有了解的话,你应该知道 Webpack 还提供了另外两种哈希算法供开发者使用:hash
和chunkhash
。那么为何不使用它们而是使用contenthash
?这要从它们的区别提及。原则上来讲,它们是为不一样目的服务的,但在实际操做中,也能够交替使用。
为了便于说明,咱们先准备如下这段很是简单的 Webpack 配置,它拥有两个打包入口,同时额外提取出 css 文件,最终生成三个文件。filename
配置中咱们使用的是hash
标识符、在 MinCssExtractPlugin
中咱们使用的是contenthash
,为何会这样稍后会解释。
const CleanWebpackPlugin = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: {
module_a: "./src/module_a.js",
module_b: "./src/module_b.js"
},
output: {
filename: "[name].[hash].js"
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"
})
]
};
复制代码
hash
hash
针对的是每一次构建(build)而言,每一次构建以后生成的文件所带的哈希都是一致的。它关心的是总体项目的变化,只要有任意文件内容发生了更改,那么构建以后其余文件的哈希也会发生更改。
很显然这不是咱们须要的,若是module_a
文件内容发生了更改,module_a
的打包文件的哈希应该发生变化,可是module_b
不该该。这会致使用户不得不从新下载没有发生变化的module_b
打包文件
chunkhash
chunkhash
基于的是每个 chunk 内容的改变,若是是该 chunk 所属的内容发生了变化,那么只有该 chunk 的输出文件的哈希会发生变化,其它的不会。这听上去符合咱们的需求。
在以前咱们对 chunk 进行过定义,便是小单位的代码聚合形式。在上面的例子中以entry
入口体现,也就是说每个入口对应的文件就是一个 chunk。在后面的例子中咱们会看到更复杂的例子
contenthash
顾名思义,该哈希根据的是文件的内容。从这个角度上说,它和chunkhash
是可以相互代替的。因此在“性能基线”代码中做者使用了contenthash
不过特殊之处是,或者说我读到的关于它的使用说明中,都指示若是你想在ExtractTextWebpackPlugin
或者MiniCssExtractPlugin
中用到哈希标识,你应该使用contenthash
。但就我我的的测试而言,使用hash
或者chunkhash
也都没有问题(也许是由于 extract 插件是严格基于 content 的?但难道 chunk 不是吗?)
让咱们把打包文件划分为main.js
和vendor.js
很简单,相似于:
const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
optimization: {
splitChunks: {
chunks: 'all',
},
},
};
复制代码
在你没有告诉它你想如何拆分打包文件的状况下, Webpack 4 在尽它最大的努力把这件事作的最好
这就致使一些声音在说:“太惊人了,Webpack 作的真不错!”
而另外一些声音在说:“你对个人打包文件作了什么!”
不管如何,添加optimization.splitChunks.chunks = 'all'
配置也就是在说:“把全部node_modules
里的东西都放到vendors~main.js
的文件中去”
在实现基本的打包分离条件后,Alice 在每次访问时仍然须要下载 200KB 大小的 main.js
文件, 可是只须要在第一周、第五周、第八周下载 200KB 的 vendors.js
脚本
也就是 2.64MB
体积减小了 36%。对于配置里新增的五行代码来讲结果还不错。在继续阅读以前你能够马上就去试试。若是你须要将 Webpack 3 升级到 4,也不要着急,升级不会带来痛苦(并且是免费的!)
咱们的 vendors.js
承受着和开始 main.js
文件一样的问题——部分的修改会意味着从新下载全部的文件
因此为何不把每个 npm 包都分割为单独的文件?作起来很是简单
让咱们把咱们的react
,lodash
,redux
,moment
等分离为不一样的文件
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
plugins: [
new webpack.HashedModuleIdsPlugin(), // so that file hashes don't change unexpectedly
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
},
},
},
},
};
复制代码
这份文档 很是好的解释了这里作的事情,可是我仍然须要解释一下其中精妙的部分,由于它们花了我至关长的时间才搞明白
cacheGroups
是咱们用来制定规则告诉 Webpack 应该如何组织 chunks 到打包输出文件的地方。我在这里对全部加载自node_modules
里的 module 制定了一条名为 "vendor" 的规则。一般状况下,你只须要为你的输出文件的 name
定义一个字符串。可是我把name
定义为了一个函数(当文件被解析时会被调用)。在函数中我会根据 module 的路径返回包的名称。结果就是,对于每个包我都会获得一个单独的文件,好比npm.react-dom.899sadfhj4.js
encodeURI
对包的名词进行转义处理。可是我遇到一个问题是.NET服务器不会给名称中包含@
的文件提供文件服务,因此我在代码片断中进行了替换Alice 每周都要从新下载 200KB 的 main.js
文件,而且再她首次访问时仍然须要下载 200KB 的 npm 包文件,可是她不再用重复的下载同一个包两次
也就是2.24MB
相对于基线减小了 44%,这是一段你可以从文章里粘贴复制的很是酷的代码。
我好奇咱们能超越 50%?
那不是很棒吗
此时你的疑惑多是,optimization 选项里的配置怎么就把 vendor 代码分离出来了?
接下来的这一小节会针对 Webpack 的 Optimization 选项作讲解。我我的并不是 Webpack 的专家,配置和对应的描述功能也并不是一一通过验证,也并不是所有都覆盖到,若是有纰漏的地方还请你们谅解。
optimization
配置如其名所示,是为优化代码而生。若是你再仔细观察,大部分配置又在splitChunk
字段下,由于它间接使用 SplitChunkPlugin 实现对块的拆分功能(这些都是在 Webpack 4 中引入的新的机制。在 Webpack 3 中使用的是 CommonsChunkPlugin,在 4 中已经再也不使用了。因此这里咱们也主要关注的是 SplitChunkPlugin 的配置)从总体上看,SplitChunksPlugin 的功能只有一个,就是split——把代码分离出来。分离是相对于把全部模块都打包成一个文件而言,把单个大文件分离为多个小文件。
在最初分离 vendor 代码时,咱们只使用了一个配置
splitChunks: {
chunks: 'all',
},
复制代码
chunks
有三个选项:initial
、async
和all
。它指示应该优先分离同步(initial)、异步(async)仍是全部的代码模块。这里的异步指的是经过动态加载方式(import()
)加载的模块。
这里的重点是优先二字。以async
为例,假如你有两个模块 a 和 b,二者都引用了 jQuery,可是 a 模块还经过动态加载的方式引入了 lodash。那么在 async
模式下,插件在打包时会分离出lodash~for~a.js
的 chunk 模块,而 a 和 b 的公共模块 jQuery 并不会被(优化)分离出来,因此它可能还同时存在于打包后的a.bundle.js
和b.bundle.js
文件中。由于async
告诉插件优先考虑的是动态加载的模块
接下来聚焦第二段分离每一个 npm 包的 Webpack 配置中
maxInitialRequests
和minSize
确实就是插件自做多情的杰做了。插件自带一些分离 chunk 的规则:若是即将分离的 chunk 文件体积小于 30KB 的话,那么就不会将该 chunk 分离出来;而且限制并行下载的 chunk 最大请求个数为 3 个。经过覆盖 minSize
和 maxInitialRequests
配置就可以重写这两个参数。注意这里的maxInitialRequests
和minSize
是在splitChunks
根目录中的,咱们暂且称它为全局配置
cacheGroups
配置才是最重要,它容许自定义规则分离 chunk。而且每条cacheGroups
规则下都容许定义上面提到的chunks
和minSize
字段用于覆盖全局配置(又或者将cacheGroups
规则中enforce
参数设为true
来忽略全局配置)
cacheGroups
里默认自带vendors
配置来分离node_modules
里的类库模块,它的默认配置以下:
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
复制代码
若是你不想使用它的配置,你能够把它设为false
又或者重写它。这里我选择重写,而且加入了额外的配置name
和enforce
:
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
enforce: true,
},
复制代码
最后介绍以上并无出现可是仍然经常使用的两个配置:priority
和reuseExistingChunk
reuseExistingChunk
: 该选项只会出如今cacheGroups
的分离规则中,意味重复利用现有的 chunk。例如 chunk 1 拥有模块 A、B、C;chunk 2 拥有模块 B、C。若是 reuseExistingChunk
为 false
的状况下,在打包时插件会为咱们单首创建一个 chunk 名为 common~for~1~2
,它包含公共模块 B 和 C。而若是该值为true
的话,由于 chunk 2 中已经拥有公共模块 B 和 C,因此插件就不会再为咱们建立新的模块
priority
: 很容易想象到咱们会在cacheGroups
中配置多个 chunk 分离规则。若是同一个模块同时匹配多个规则怎么办,priority
解决的这个问题。注意全部默认配置的priority
都为负数,因此自定义的priority
必须大于等于0才行
截至目前为止,咱们已经看出了一套分离代码的模式:
本文也同时发布在个人知乎专栏,欢迎你们关注