前提
找出向用户提供文件的最佳方式可能是一项棘手的工作。有很多不同的场景,不同的技术,不同的术语。
在这篇文章中,我希望给你所需要的一切,这样你就可以:
-
知道您的网站的最佳文件拆分策略(file-splitting strategy)
-
知道该方案该如何实现
正文
根据Webpack术语表,有两种不同类型的文件拆分。这些术语听起来可以互换,但显然不是:
-
捆绑拆分(
Bundle splitting):创建更多、更小的文件(但无论如何都要在每个网络请求中加载所有文件)以获得更好的缓存。 -
代码拆分(
Code splitting):动态加载代码,以便用户只下载他们正在查看的网站部分所需的代码。
第二个听起来更吸引人,不是吗?事实上,许多关于这一问题的文章似乎都认为这是制作较小JavaScript文件的唯一有价值的案例。(笔者:确实是这样想的)
但我在这里告诉你,第一个在许多网站上都更有价值,应该是你为所有网站做的第一件事。
让我们深入了解。
捆绑拆分(Bundle splitting)
Bundle splitting背后的想法非常简单。如果您有一个巨大的文件,并且更改了一行代码,则用户必须再次下载整个文件。但是,如果您将其拆分为两个文件,那么用户只需要下载更改的文件,浏览器就会从缓存中提供另一个文件。
值得注意的是,由于Bundle splitting分都是关于缓存的,所以对第一次访问的人来说没有什么区别。
(我认为过多的性能文章都是关于第一次访问网站的。也许这部分是因为“第一印象很重要”,部分是因为它很好,测量起来很简单。)
以下是我在上一段中提到的场景:
- Alice每周访问我们的网站一次,为期10周
- 我们每周更新一次网站
- 我们每周更新“产品列表”页面
- 我们还有一个“产品详细信息”页面,但目前还没有开发
- 在第5周,我们向网站添加了一个新的npm包
- 在第8周,我们更新了一个现有的npm包
某些类型的人(比如我)会试图让这种情况尽可能现实。不要那样做。实际情况并不重要,我们稍后会找出原因。(暂停!)
步骤1
假设我们的JavaScript包总共有400 KB,我们目前正在将其加载为一个名为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',
},
};
(对于那些刚开始破坏缓存的人来说:每当我说main.js时,我的实际意思是类似main.xMePWxHo.js的东西,其中疯狂的字母串是文件内容的散列(也就是contenthash)。这意味着当应用程序中的代码发生变化时,会有一个不同的文件名,从而迫使浏览器下载新文件。)
每周当我们向网站推送一些新的更改时,这个包的contenthash都会发生变化。因此,爱丽丝每周都会访问我们的网站,并下载一个新的400KB文件。
如果我们要把这些事件做成一张表格,它会是这样的。
结果上述的10周,总共拉取了4.12M资源
使用vendor package拆分
使用如下代码:
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',
},
},
};
增加 optimization.splitChunks.chunks = 'all' 表示将所有 node_modules中的包打包进 vendors~main.js"文件.
有了这个基本的捆绑包拆分,Alice每次访问都会下载一个新的200 KB的main.js,但只在第一周、第八周和第五周下载200 KB的vendors.js。
这样就只会下载2.64MB资源了,减少了36%的资源传递!
拆分每个npm包
我们的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('@', '')}`;
},
},
},
},
},
};
文档会很好地解释这里的大部分内容,但我会解释一些关于精彩部分的内容。
- Webpack的默认参数并不合理, 例如文件的
minimum是 30 KB (所有小于30kB的包会被打包在一起). 所以我重写了它. cacheGroups是我们定义输出产物规则的地方. 我没有将node_modules打包成vendor包. 通常情况下,你可以用name字符串为每个产物命名. 但我将name定义为一个函数(它将为每个解析的文件调用)。然后,我以模块的路径返回包的名称。因此,我们将为每个包单独打包为一个文件,例如npm.react-dom.899sadfhj4.js。- NPM 名字必须是安全的, 所以我们不需要对packageName的URI进行编码。但是,我遇到了一个问题。NET服务器不提供名称中带有@的文件(来自作用域包),所以我在这个片段中替换了它。
- 这整个设置非常棒,因为它是设置好了就忘记了。不需要维护——我不需要提到任何包的名字。(笔者:确实比单独设置每个包的名称更智能化,但会不会导致打包产物的资源数过多?)
Alice每周仍将重新下载我们的200 KB main.js文件,第一次访问时仍将下载200 KB的npm包,但她永远不会下载两次相同的包。
这下就只有2.24MB,减少了44%的下载资源
Splitting out areas of the application code
现在再将情况说的具体一些:
- Alice访问的这个页面有一个列表页面,一个详情页面。有150KB的公共代码和25KB的独立代码,并且列表页面修改很少,平时只改动详情界面。
- 并且有一个svg资源,也占用25KB
那么在这种情况下我们该如何处理呢?使用如下配置:
module.exports = {
entry: {
main: path.resolve(__dirname, 'src/index.js'),
ProductList: path.resolve(__dirname, 'src/ProductList/ProductList.js'),
ProductPage: path.resolve(__dirname, 'src/ProductPage/ProductPage.js'),
Icon: path.resolve(__dirname, 'src/Icon/Icon.js'),
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js',
},
plugins: [
new webpack.HashedModuleIdsPlugin(), // so that file hashes don't change unexpectedly
],
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('@', '')}`;
},
},
},
},
},
};
这样就可以拆分ProductList和ProductPage之间共享的创建文件,这样我们就不会得到重复的代码。
这将为Alice在大多数时间内节省额外的50 KB下载量。
只剩下1.815MB了!(笔者:这个感觉就是路由懒加载的模式)
我们已经为Alice节省了高达56%的下载量,这种节省(在我们的理论场景中)将一直持续到时间结束。
所有这些都是通过更改我们的网页包配置来完成的——我们没有对应用程序代码做任何更改。
我在前面提到,测试中的具体场景实际上并不重要。这是因为,无论您提出什么方案,结论都是一样的:将您的应用程序拆分为合理的小文件,以便您的用户下载更少的代码。
问题分析
拆分这么多的包会影响网络请求时间嘛?
可以使用http2.0进行处理,分帧加载,并不会因为请求数量增加请求时间
每个webpack包包中没有开销/样板代码吗?
会的
拆分更多的包会导致可压缩的代码减少嘛
有的,每个被拆分出的包确实会有一些多余的样板代码。** 并且被拆分的包越多,增加的样板代码越多,并且能被压缩的体积越小。**
但是影响不大,我刚刚做了一个测试,一个190 KB的站点被分成19个文件,增加了发送到浏览器的总字节数的2%。 增加2%的首次访问成本,换取60%的后续访问节约,这是非常值得的。
代码拆分(Code splitting)
这种特殊的方法只有在某些网站上才有意义。
我喜欢应用我刚刚制定的20/20规则:如果你的站点中有一部分只有20%的用户访问,并且它大于你站点JavaScript的20%,那么你应该只根据需要加载代码。
很明显,根据口味调整这些数字,还有比这更复杂的场景。关键是,这是一种平衡,可以确定代码拆分对您的站点没有意义。
使用动态import
我将从这个开始,因为它适用于大多数网站,是一个很好的简单介绍。
我在我的网站上使用了一堆奇特的功能,所以我有一个文件,可以导入我需要的所有多边形填充。它包括以下八行:
// polyfills文件
require('whatwg-fetch');
require('intl');
require('url-polyfill');
require('core-js/web/dom-collections');
require('core-js/es6/map');
require('core-js/es6/string');
require('core-js/es6/array');
require('core-js/es6/object');
我把这个文件在index.js的最上方引入
import './polyfills';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App/App';
import './index.css';
const render = () => {
ReactDOM.render(<App />, document.getElementById('root'));
}
render(); // yes I am pointless, for now
使用bundle splitting部分的Webpack配置,我的polyfills将自动拆分为四个不同的文件,因为这里有四个npm包。它们总共大约有25 KB,而且90%的浏览器不需要它们,所以动态加载它们是值得的。
使用Webpack 4和import()语法(不要与import语法混淆),有条件地加载polyfills。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App/App';
import './index.css';
const render = () => {
ReactDOM.render(<App />, document.getElementById('root'));
}
if (
'fetch' in window &&
'Intl' in window &&
'URL' in window &&
'Map' in window &&
'forEach' in NodeList.prototype &&
'startsWith' in String.prototype &&
'endsWith' in String.prototype &&
'includes' in String.prototype &&
'includes' in Array.prototype &&
'assign' in Object &&
'entries' in Object &&
'keys' in Object
) {
render();
} else {
import('./polyfills').then(render);
}
译者:其实也就是一些依赖可以在需要的时候做动态引入
路由懒加载
直接看链接就行了,常用技术
总结
如果有人多次访问您的站点,请将您的代码拆分为许多小文件。
如果站点中有大部分用户不访问,请动态加载该代码。
感谢您的阅读,祝您度过愉快的一天!