资讯专栏INFORMATION COLUMN

深入理解 Webpack 打包分块(上)

Rocko / 3703人阅读

摘要:而一个哈希字符串就是根据文件内容产生的签名,每当文件内容发生更改时,哈希串也就发生了更改,文件名也就随之更改。很显然这不是我们需要的,如果文件内容发生了更改,的打包文件的哈希应该发生变化,但是不应该。

前言

随着前端代码需要处理的业务越来越繁重,我们不得不面临的一个问题是前端的代码体积也变得越来越庞大。这造成无论是在调式还是在上线时都需要花长时间等待编译完成,并且用户也不得不花额外的时间和带宽下载更大体积的脚本文件。

然而仔细想想这完全是可以避免的:在开发时难道一行代码的修改也要重新打包整个脚本?用户只是粗略浏览页面也需要将整个站点的脚本全部下载下来?所以趋势必然是按需的、有策略性的将代码拆分和提供给用户。最近流行的微前端某种意义上来说也是遵循了这样的原则(但也并不是完全基于这样的原因)

幸运的是,我们目前已有的工具已经完全赋予我们实现以上需求的能力。例如 Webpack 允许我们在打包时将脚本分块;利用浏览器缓存我们能够有的放矢的加载资源。

在探寻最佳实践的过程中,最让我疑惑的不是我们能不能做,而是我们应该如何做:我们因该采取什么样的特征拆分脚本?我们应该使用什么样的缓存策略?使用懒加载和分块是否有异曲同工之妙?拆分之后究竟能带来多大的性能提升?最重要的是,在面多诸多的方案和工具以及不确定的因素时,我们应该如何开始?这篇文章就是对以上问题的梳理和回答。文章的内容大体分为两个方面,一方面在思路制定模块分离的策略,另一方面从技术上对方案进行落地。

本文的主要内容翻译自 The 100% correct way to split your chunks with Webpack。 这篇文章循序渐进的引导开发者步步为营的对代码进行拆分优化,所以它是作为本文的线索存在。同时在它的基础上,我会对 Webpack 及其他的知识点做纵向扩展,对方案进行落地。

以下开始正文


根据 Webpack 术语表,存在两类文件的分离。这些名词听起来是可以互换的,但实际上不行:

打包分离 (Bundle splitting):为了更好的缓存创建更多、更小的文件(但仍然以每一个文件一个请求的方式进行加载)

代码分离 (Code splitting):动态加载代码,所以用户只需要下载当前他正在浏览站点的这部分代码

第二种策略听起来更吸引人是不是?事实上许多的文章也假定认为这才是唯一值得将 JavaScript 文件进行小文件拆分的场景。

但是我在这里告诉你第一种策略对许多的站点来说才更有价值,并且应该是你首先为页面做的事

让我们来深入理解

Bundle VS Chunk VS Module

在正式开始编码之前,我们还是要明确一些概念。例如我们贯穿全文的“块”(chunk) ,以及它和我们常常提到的“包”(bundle)以及“模块”(module) 到底有什么区别。

遗憾的事情是即使在查阅了很多资料之后,我仍然没法得到一个确切的标准答案,所以这里我选择我个人比较认可的定义在这里做一个分享,重要的还是希望能起到统一口径的作用

首先对于“模块”(module)的概念相信大家都没有异议,它指的就是我们在编码过程中有意识的封装和组织起来的代码片段。狭义上我们首先联想到的是碎片化的 React 组件,或者是 CommonJS 模块又或者是 ES6 模块,但是对 Webpack 和 Loader 而言,广义上的模块还包括样式和图片,甚至说是不同类型的文件

而“包”(bundle) 就是把相关代码都打包进入的单个文件。如果你不想把所有的代码都放入一个包中,你可以把它们划分为多个包,也就是“块”(chunk) 中。从这个角度上看,“块”等于“包”,它们都是对代码再一层的组织和封装。如果必须要给一个区分的话,通常我们在讨论时,bundle 指的是所有模块都打包进入的单个文件,而 chunk 指的是按照某种规则的模块集合,chunk 的体积大于单个模块,同时小于整个 bundle

(但如果要仔细的深究,Chunk是 Webpack 用于管理打包流程中的技术术语,甚至能划分为不同类型的 chunk。我想我们不用从这个角度理解。只需要记住上一段的定义即可)

打包分离 (Bundle splitting)

打包分离背后的思想非常简单。如果你有一个体积巨大的文件,并且只改了一行代码,用户仍然需要重新下载整个文件。但是如果你把它分为了两个文件,那么用户只需要下载那个被修改的文件,而浏览器则可以从缓存中加载另一个文件。

值得注意的是因为打包分离与缓存相关,所以对站点的首次访问者来说没有区别

(我认为太多的性能讨论都是关于站点的首次访问。或许部分原因是因为第一映像很重要,另一部分因为这部分性能测量起来简单和讨巧)

当谈论到频繁访问者时,量化性能提升会稍有棘手,但是我们必须量化!

这将需要一张表格,我们将每一种场景与每一种策略的组合结果都记录下来

我们假设一个场景:

Alice 连续 10 周每周访问站点一次

我们每周更新站点一次

我们每周更新“产品列表”页面

我们也有一个“产品详情”页面,但是目前不需要对它进行更新

在第 5 周的时我们给站点新增了一个 npm 包

在第 8 周时我们更新了现有的一个 npm 包

当然包括我在内的部分人希望场景尽可能的逼真。但其实无关紧要,我们随后会解释为什么。

性能基线

假设我们的 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

我们能做的更好

哈希(hash)与性能

不知道你是否真的理解上面的表述。有几点需要在这里澄清:

    为什么带哈希串的文件名会对浏览器缓存产生影响?

    为什么文件名里的哈希后缀是contenthash?如果把contenthash替换成hash或者chunkhash有什么影响?

为了每次访问时不让浏览器都重新下载同一个文件,我们通常会把这个文件返回的 HTTP 头中的Cache-Control设置为max-age=31536000,也就是一年(秒数的)时间。这样以来,在一年之内用户访问这个文件时,都不会再次向服务器发送请求而是直接从缓存中读取,直到或者手动清除了缓存。

如果我中途修改了文件内容必须让用户重新下载怎么办?修改文件名就好了,不同的文件(名)对应不同的缓存策略。而一个哈希字符串就是根据文件内容产生的“签名”,每当文件内容发生更改时,哈希串也就发生了更改,文件名也就随之更改。这样一来,旧版本文件的缓存策略就会失效,浏览器就会重新加载新版本的该文件。当然这只是其中一种最基础的缓存策略,更复杂的场景请参考我之前的一篇文章:设计一个无懈可击的浏览器缓存方案:关于思路,细节,ServiceWorker,以及HTTP/2

所以在 Webpack 中配置的 filename: [name]:[contenthash].js 就是为了每次发布时自动生成新的文件名。

然而如果你对 Webpack 稍有了解的话,你应该知道 Webpack 还提供了另外两种哈希算法供开发者使用:hashchunkhash。那么为什么不使用它们而是使用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 不是吗?)

分离第三方类库(vendor)类库

让我们把打包文件划分为main.jsvendor.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,也不要着急,升级不会带来痛苦(而且是免费的!)

分离每一个 npm 包

我们的 vendors.js 承受着和开始 main.js 文件同样的问题——部分的修改会意味着重新下载所有的文件

所以为什么不把每一个 npm 包都分割为多带带的文件?做起来非常简单

让我们把我们的reactlodashreduxmoment等分离为不同的文件

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[/](.*");

也就是2.24MB

相对于基线减少了 44%,这是一段你能够从文章里粘贴复制的非常酷的代码。

我好奇我们能超越 50%?

那不是很棒吗

稍等,那段 Webpack 配置代码究竟是怎么回事

此时你的疑惑可能是,optimization 选项里的配置怎么就把 vendor 代码分离出来了?

接下来的这一小节会针对 Webpack 的 Optimization 选项做讲解。我个人并非 Webpack 的专家,配置和对应的描述功能也并非一一经过验证,也并非全部都覆盖到,如果有纰漏的地方还请大家谅解。

optimization配置如其名所示,是为优化代码而生。如果你再仔细观察,大部分配置又在splitChunk字段下,因为它间接使用 SplitChunkPlugin 实现对块的拆分功能(这些都是在 Webpack 4 中引入的新的机制。在 Webpack 3 中使用的是 CommonsChunkPlugin,在 4 中已经不再使用了。所以这里我们也主要关注的是 SplitChunkPlugin 的配置)从整体上看,SplitChunksPlugin 的功能只有一个,就是split——把代码分离出来。分离是相对于把所有模块都打包成一个文件而言,把单个大文件分离为多个小文件。

在最初分离 vendor 代码时,我们只使用了一个配置

splitChunks: { chunks: "all", },

chunks有三个选项:initialasyncall。它指示应该优先分离同步(initial)、异步(async)还是所有的代码模块。这里的异步指的是通过动态加载方式(import())加载的模块。

这里的重点是优先二字。以async为例,假如你有两个模块 a 和 b,两者都引用了 jQuery,但是 a 模块还通过动态加载的方式引入了 lodash。那么在 async 模式下,插件在打包时会分离出lodash~for~a.js的 chunk 模块,而 a 和 b 的公共模块 jQuery 并不会被(优化)分离出来,所以它可能还同时存在于打包后的a.bundle.jsb.bundle.js文件中。因为async告诉插件优先考虑的是动态加载的模块

接下来聚焦第二段分离每个 npm 包的 Webpack 配置中

maxInitialRequestsminSize确实就是插件自作多情的杰作了。插件自带一些分离 chunk 的规则:如果即将分离的 chunk 文件体积小于 30KB 的话,那么就不会将该 chunk 分离出来;并且限制并行下载的 chunk 最大请求个数为 3 个。通过覆盖 minSizemaxInitialRequests 配置就能够重写这两个参数。注意这里的maxInitialRequestsminSize是在splitChunks根目录中的,我们暂且称它为全局配置

cacheGroups配置才是最重要,它允许自定义规则分离 chunk。并且每条cacheGroups规则下都允许定义上面提到的chunksminSize字段用于覆盖全局配置(又或者将cacheGroups规则中enforce参数设为true来忽略全局配置)

cacheGroups里默认自带vendors配置来分离node_modules里的类库模块,它的默认配置如下:

cacheGroups: { vendors: { test: /[/]node_modules[/]/, priority: -10 },

如果你不想使用它的配置,你可以把它设为false又或者重写它。这里我选择重写,并且加入了额外的配置nameenforce:

vendors: { test: /[/]node_modules[/]/, name: "vendors", enforce: true, },

最后介绍以上并没有出现但是仍然常用的两个配置:priorityreuseExistingChunk

reuseExistingChunk: 该选项只会出现在cacheGroups的分离规则中,意味重复利用现有的 chunk。例如 chunk 1 拥有模块 A、B、C;chunk 2 拥有模块 B、C。如果 reuseExistingChunkfalse 的情况下,在打包时插件会为我们多带带创建一个 chunk 名为 common~for~1~2,它包含公共模块 B 和 C。而如果该值为true的话,因为 chunk 2 中已经拥有公共模块 B 和 C,所以插件就不会再为我们创建新的模块

priority: 很容易想象到我们会在cacheGroups中配置多个 chunk 分离规则。如果同一个模块同时匹配多个规则怎么办,priority解决的这个问题。注意所有默认配置的priority都为负数,所以自定义的priority必须大于等于0才行

小结

截至目前为止,我们已经看出了一套分离代码的模式:

首先决定我们想要解决什么样的问题(避免用户在每次访问时下载额外的代码);

再决定使用什么样的方案(通过将修改频率低、重复的代码分离出来,并配上恰当的缓存策略);

最后决定实施的方案是什么(通过配置 Webpack 来实现代码的分离)

本文也同时发布在我的知乎专栏,欢迎大家关注

参考资料

Bundle VS Chunk

What are module, chunk and bundle in webpack?

Concepts - Bundle vs Chunk

SurviveJS: Glossary

Hash

What is the purpose of webpack hash and chunkhash?

Hash vs chunkhash vs ContentHash

Adding Hashes to Filenames

SplitChunksPlugin

Webpack 4 — Mysterious SplitChunks Plugin

Webpack (v4) Code Splitting using SplitChunksPlugin

Reduce JavaScript Payloads with Code Splitting

Webpack v4 chunk splitting deep dive

what reuseExistingChunk: true means, can give a sample?

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/6650.html

相关文章

  • 深入理解 Webpack 打包分块(下)

    摘要:例如允许我们在打包时将脚本分块利用浏览器缓存我们能够有的放矢的加载资源。文章的内容大体分为两个方面,一方面在思路制定模块分离的策略,另一方面从技术上对方案进行落地。我之前提到测试之下是什么样具体的场景并不重要。前言 随着前端代码需要处理的业务越来越繁重,我们不得不面临的一个问题是前端的代码体积也变得越来越庞大。这造成无论是在调式还是在上线时都需要花长时间等待编译完成,并且用户也不得不花额外的...

    pingan8787 评论0 收藏0
  • 关于vue的懒加载实践

    摘要:最近在研究的按需加载,好奇怪,之前好像并没有看到的官文里面有这一部分,是我看差了吗尬笑其实只需要看官文就可以了,里面有懒加载的讲解,并且附带了详细内容的连接。所以很大程度上优化了页面的初始加载速度。只是为了测试按需加载随便写的而已。 最近在研究vue的按需加载,好奇怪,之前好像并没有看到vue的官文里面有这一部分,是我看差了吗hahaha~尬笑~ 其实只需要看vue-router官文就...

    wangzy2019 评论0 收藏0
  • 谈谈前端工程化 js加载

    摘要:当年的加载在没有前端工程化之前,基本上是我们是代码一把梭,把所需要的库和自己的代码堆砌在一起,然后自上往下的引用就可以了。而且对于前后端的技术要求较高,所以对于项目未必是最有效的方案。 当年的 js 加载 在没有 前端工程化之前,基本上是我们是代码一把梭,把所需要的库和自己的代码堆砌在一起,然后自上往下的引用就可以了。 那个时代我们没有公用的cdn,也没有什么特别好的方法来优化加载j...

    paulli3 评论0 收藏0
  • webpack再看一遍

    摘要:在终端中使用可以自动创建这个文件输入这个命令后,终端会问你一系列问题。百度后发现引入了模式,有三个状态,开发模式生产模式无。 什么是webpack,为什么要使用webapck * 导语 之前一直忙着项目,没时间整理自己的东西,最近刚好发现自己对webpack又如此陌生了,于是整理了一篇关于webpack初探的干货,这里是一点简单的webpack配置 为什么使用webpck 现今很多网页...

    whinc 评论0 收藏0

发表评论

0条评论

Rocko

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<