手记

Web 性能优化: 使用 Webpack 分离数据的正确方法

摘要: Webpack骚操作。

Fundebug经授权转载,版权归原作者所有。

制定向用户提供文件的最佳方式可能是一项棘手的工作。 有很多不同的场景,不同的技术,不同的术语。

在这篇文章中,我希望给你所有你需要的东西,这样你就可以:

  1. 了解哪种文件分割策略最适合你的网站和用户
  2. 知道怎么做

根据 Webpack glossary,有两种不同类型的文件分割。 这些术语听起来可以互换,但显然不是。

Webpack 文件分离包括两个部分,一个是 Bundle splitting,一个是 Code splitting:

  • Bundle splitting: 创建更多更小的文件,并行加载,以获得更好的缓存效果,主要作用就是使浏览器并行下载,提高下载速度。并且运用浏览器缓存,只有代码被修改,文件名中的哈希值改变了才会去再次加载。
  • Code splitting:只加载用户最需要的部分,其余的代码都遵从懒加载的策略,主要的作用就是加快页面的加载速度,不加载不必要的代码。

第二个听起来更吸引人,不是吗?事实上,关于这个问题的许多文章似乎都假设这是制作更小的JavaScript 文件的惟一值得的情况。

但我在这里要告诉你的是,第一个在很多网站上都更有价值,应该是你为所有网站做的第一件事。

就让我们一探究竟吧。

Bundle splitting

bundle splitting 背后的思想非常简单,如果你有一个巨大的文件,并且更改了一行代码,那么用户必须再次下载整个文件。但是如果将其分成两个文件,那么用户只需要下载更改的文件,浏览器将从缓存中提供另一个文件。

值得注意的是,由于 bundle splitting 都是关于缓存的,所以对于第一次访问来说没有什么区别。

(我认为太多关于性能的讨论都是关于第一次访问一个站点,或许部分原因是“第一印象很重要”,部分原因是它很好、很容易衡量。

对于经常访问的用户来说,量化性能增强所带来的影响可能比较棘手,但是我们必须进行量化!

这将需要一个电子表格,因此我们需要锁定一组非常特定的环境,我们可以针对这些环境测试每个缓存策略。

这是我在前一段中提到的情况:

  • Alice 每周访问我们的网站一次,持续 10 周
  • 我们每周更新一次网站
  • 我们每周都会更新我们的“产品列表”页面
  • 我们也有一个“产品详细信息”页面,但我们目前还没有开发
  • 在第 5 周,我们向站点添加了一个新的 npm 包
  • 在第 8 周,我们更新了一个现有的 npm 包

某些类型的人(比如我)会尝试让这个场景尽可能的真实。不要这样做。实际情况并不重要,稍后我们将找出原因。

基线

假设我们的 JavaScript 包的总容量是400 KB,目前我们将它作为一个名为 main.js 的文件加载。

我们有一个 Webpack 配置如下(我省略了一些无关的配置):

// webpack.config.js 

const path = require('path')

module.exports = {
  entry: path.resolve(__dirame, 'src/index.js')
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js'
  }
}

对于那些新的缓存破坏:任何时候我说 main.js,我实际上是指 main.xMePWxHo.js,其中里面的字符串是文件内容的散列。这意味着不同的文件名 当应用程序中的代码发生更改时,从而强制浏览器下载新文件。

每周当我们对站点进行一些新的更改时,这个包的 contenthash 都会发生变化。因此,Alice 每周都要访问我们的站点并下载一个新的 400kb 文件。

如果我们把这些事件做成一张表格,它会是这样的。

也就是10周内, 4.12 MB, 我们可以做得更好。

分解 vendor 包

让我们将包分成 main.jsvendor.js 文件。

 // webpack.config.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'
    }
  }
}

Webpack4 为你做最好的事情,而没有告诉你想要如何拆分包。这导致我们对 webpack 是如何分包的知之甚少,结果有人会问 “你到底在对我的包裹做什么?”

添加 optimization.splitChunks.chunks ='all'的一种说法是 “将 node_modules 中的所有内容放入名为 vendors~main.js 的文件中”。

有了这个基本的 bundle splitting,Alice 每次访问时仍然下载一个新的 200kb 的 main.js,但是在第一周、第8周和第5周只下载 200kb 的 vendor.js (不是按此顺序)。

总共:2.64 MB

减少36%。 在我们的配置中添加五行代码并不错。 在进一步阅读之前,先去做。 如果你需要从 Webpack 3 升级到 4,请不要担心,它非常简单。

我认为这种性能改进似乎更抽象,因为它是在10周内进行的,但是它确实为忠实用户减少了36%的字节,我们应该为自己感到自豪。

但我们可以做得更好。

分离每个 npm 包

我们的 vendor.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[\\/](.*?)([\\/]|$)/)[1];

            // npm package names are URL-safe, but some servers don't like @ symbols
            return `npm.${packageName.replace('@', '')}`;
          },
        },
      },
    },
  },
};

文档将很好地解释这里的大部分内容,但是我将稍微解释一下需要注意的部分,因为它们花了我太多的时间。

  • Webpack 有一些不太聪明的默认设置,比如分割输出文件时最多3个文件,最小文件大小为30 KB(所有较小的文件将连接在一起),所以我重写了这些。
  • cacheGroups 是我们定义 Webpack 应该如何将数据块分组到输出文件中的规则的地方。这里有一个名为 “vendor” 的模块,它将用于从 node_modules 加载的任何模块。通常,你只需将输出文件的名称定义为字符串。但是我将 name定义为一个函数(将为每个解析的文件调用这个函数)。然后从模块的路径返回包的名称。因此,我们将为每个包获得一个文件,例如 npm.react-dom.899sadfhj4.js
  • NPM 包名称必须是 URL 安全的才能发布,因此我们不需要 encodeURIpackageName。 但是,我遇到一个.NET服务器不能提供名称中带有 @(来自一个限定范围的包)的文件,所以我在这个代码片段中替换了 @
  • 整个设置很棒,因为它是一成不变的。 无需维护 - 不需要按名称引用任何包。

Alice 仍然会每周重新下载 200 KB 的 main.js 文件,并且在第一次访问时仍会下载 200 KB 的npm包,但她绝不会两次下载相同的包。

总共: 2.24 MB.

与基线相比减少了44%,这对于一些可以从博客文章中复制/粘贴的代码来说非常酷。

我想知道是否有可能超过 50% ? 这完全没有问题。

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具Fundebug

分离应用程序代码的区域

让我们转到 main.js 文件,可怜的 Alice 一次又一次地下载这个文件。

我之前提到过,我们在此站点上有两个不同的部分:产品列表和产品详细信息页面。 每个区域中的唯一代码为25 KB(共享代码为150 KB)。

我们的产品详情页面现在变化不大,因为我们做得太完美了。 因此,如果我们将其做为单独的文件,则可以在大多数时间从缓存中获取到它。

另外,我们网站有一个较大的内联SVG文件用于渲染图标,重量只有25 KB,而这个也是很少变化的, 我们也需要优化它。

我们只需手动添加一些入口点,告诉 Webpack 为每个项创建一个文件。

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('@', '')}`;
          },
        },
      },
    },
  },
};

Webpack 还会为 ProductListProductPage 之间共享的内容创建文件,这样我们就不会得到重复的代码。

这将为 Alice 在大多数情况下节省 50 KB 的下载。

只有 1.815 MB!

我们已经为 Alice 节省了高达56%的下载量,这种节省将(在我们的理论场景中)持续到时间结束。

所有这些都只在Webpack配置中进行了更改——我们没有对应用程序代码进行任何更改。

我在前面提到过,测试中的确切场景并不重要。这是因为,无论你提出什么场景,结论都是一样的:将应用程序分割成合理的小文件,以便用户下载更少的代码。

很快,=将讨论“code splitting”——另一种类型的文件分割——但首先我想解决你现在正在考虑的三个问题。

#1:大量的网络请求不是更慢吗?

答案当然是不会

在 HTTP/1.1 时代,这曾经是一种情况,但在 HTTP/2 时代就不是这样了。

尽管如此,这篇2016年的文章Khan Academy 2015年的文章都得出结论,即使使用 HTTP/2,下载太多的文件还是比较慢。但在这两篇文章中,“太多”的意思都是“几百个”。所以请记住,如果你有数百个文件,你可能一开始就会遇到并发限制。

如果您想知道,对 HTTP/2 的支持可以追溯到 Windows 10 上的 ie11。我做了一个详尽的调查,每个人都使用比那更旧的设置,他们一致向我保证,他们不在乎网站加载有多快。

#2:每个webpack包中没有 开销/引用 代码吗?

是的,这也是真的。

好吧,狗屎:

  • more files = 更多 Webpack 引用
  • more files = 不压缩

让我们量化一下,这样我们就能确切地知道需要担心多少。

好的,我刚做了一个测试,一个 190 KB 的站点拆分成 19 个文件,增加了大约 2%发送到浏览器的总字节数。

因此…在第一次访问时增加 2%,在每次访问之前减少60%直到网站下架。

正确的担忧是:完全没有。

当我测试1个文件对19个时,我想我会在一些不同的网络上试一试,包括HTTP / 1.1

在 3G 和4G上,这个站点在有19个文件的情况下加载时间减少了30%。

这是非常杂乱的数据。 例如,在运行2号 的 4G 上,站点加载时间为 646ms,然后运行两次之后,加载时间为1116ms,比之前长73%,没有变化。因此,声称 HTTP/2 “快30%” 似乎有点鬼鬼祟祟。

我创建这个表是为了尝试量化 HTTP/2 所带来的差异,但实际上我唯一能说的是“它可能没有显著的差异”。

真正令人吃惊的是最后两行。那是旧的 Windows 和 HTTP/1.1,我打赌会慢得多,我想我需把网速调慢一点。

我从微软的网站上下载了一个Windows 7 虚拟机来测试这些东西。它是 IE8 自带的,我想把它升级到IE9,所以我转到微软的IE9下载页面…

关于HTTP/2 的最后一个问题,你知道它现在已经内置到 Node中了吗?如果你想体验一下,我编写了一个带有gzip、brotli和响应缓存的小型100行HTTP/2服务器点击预览,以满足你的测试乐趣。

这就是我要讲的关于 bundle splitting 的所有内容。我认为这种方法唯一的缺点是必须不断地说服人们加载大量的小文件是可以的。

Code splitting (加载你需要的代码)

我说,这种特殊的方法只有在某些网站上才有意义。

我喜欢应用我刚刚编造的 20/20 规则:如果你的站点的某个部分只有 20% 的用户访问,并且它大于站点的 JavaScript 的 20%,那么你应该按需加载该代码。

如何决定?

假设你有一个购物网站,想知道是否应该将“checkout”的代码分开,因为只有30%的访问者才会访问那里。

首先要做的是卖更好的东西。

第二件事是弄清楚多少代码对于结账功能是完全独立的。 由于在执行“code splitting” 之前应始终先“bundle splitting’ ”,因此你可能已经知道代码的这一部分有多大。

它可能比你想象的要小,所以在你太兴奋之前做一下加法。例如,如果你有一个 React 站点,那么你的 storereducerroutingactions 等都将在整个站点上共享。唯一的部分将主要是组件和它们的帮助类。

因此,你注意到你的结帐页面完全独特的代码是 7KB。 该网站的其余部分是 300 KB。 我会看着这个,然后说,我不打算把它拆分,原因如下:

  • 提前加载不会变慢。记住,你是在并行加载所有这些文件。查看是否可以记录 300KB307KB 之间的加载时间差异。

  • 如果你稍后加载此代码,则用户必须在单击“TAKE MY MONEY”之后等待该文件 - 你希望延迟的最小的时间。

  • Code splitting 需要更改应用程序代码。 它引入了异步逻辑,以前只有同步逻辑。 这不是火箭科学,但我认为应该通过可感知的用户体验改进来证明其复杂性。

让我们看两个 code splitting 的例子。

Polyfills

我将从这个开始,因为它适用于大多数站点,并且是一个很好的简单介绍。

我在我的网站上使用了一些奇特的功能,所以我有一个文件可以导入我需要的所有polyfill, 它包括以下八行:

// polyfills.js 
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 中导入这个文件。

// index-always-poly.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 语法混淆),有条件地加载polyfill 非常容易。

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);
}

合理? 如果支持所有这些内容,则渲染页面。 否则,导入 polyfill 然后渲染页面。 当这个代码在浏览器中运行时,Webpack 的运行时将处理这四个 npm 包的加载,当它们被下载和解析时,将调用 render() 并继续进行。

顺便说一句,要使用 import(),你需要 Babel 的动态导入插件。另外,正如 Webpack 文档解释的那样,import() 使用 promises,所以你需要将其与其他polyfill分开填充。

基于路由的动态加载(特定于React)

回到 Alice 的例子,假设站点现在有一个“管理”部分,产品的销售者可以登录并管理他们所销售的一些没用的记录。

本节有许多精彩的特性、大量的图表和来自 npm 的大型图表库。因为我已经在做 bundle splittin 了,我可以看到这些都是超过 100 KB 的阴影。

目前,我有一个路由设置,当用户查看 /admin URL时,它将渲染 <AdminPage>。当Webpack 打包所有东西时,它会找到 import AdminPage from './AdminPage.js'。然后说"嘿,我需要在初始负载中包含这个"

但我们不希望这样,我们需要将这个引用放到一个动态导入的管理页面中,比如import('./AdminPage.js') ,这样 Webpack 就知道动态加载它。

它非常酷,不需要配置。

因此,不必直接引用 AdminPage,我可以创建另一个组件,当用户访问 /admin URL时将渲染该组件,它可能是这样的:

// AdminPageLoader.js 
import React from 'react';

class AdminPageLoader extends React.PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      AdminPage: null,
    }
  }

  componentDidMount() {
    import('./AdminPage').then(module => {
      this.setState({ AdminPage: module.default });
    });
  }

  render() {
    const { AdminPage } = this.state;

    return AdminPage
      ? <AdminPage {...this.props} />
      : <div>Loading...</div>;
  }
}

export default AdminPageLoader;

这个概念很简单,对吧? 当这个组件挂载时(意味着用户位于 /admin URL),我们将动态加载 ./AdminPage.js,然后在状态中保存对该组件的引用。

render 方法中,我们只是在等待 <AdminPage> 加载时渲染 <div>Loading...</div>,或者在加载并存储状态时渲染 <AdminPage>

我想自己做这个只是为了好玩,但是在现实世界中,你只需要使用 react-loadable ,如关于 code-splitting 的React文档中所述。

总结

对于上面总结以下两点:

  • 如果有人不止一次访问你的网站,把你的代码分成许多小文件。
  • 如果你的站点有大部分用户不访问的部分,则动态加载该代码。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有Google、360、金山软件、百姓网等众多品牌企业。欢迎大家免费试用

0人推荐
随时随地看视频
慕课网APP