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

阿里云最近在作活动,低至2折,有兴趣能够看看:
https://promotion.aliyun.com/...

为了保证的可读性,本文采用意译而非直译。css

制定向用户提供文件的最佳方式多是一项棘手的工做。 有不少不一样的场景,不一样的技术,不一样的术语。html

在这篇文章中,我但愿给你全部你须要的东西,这样你就能够:前端

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

根据 Webpack glossary,有两种不一样类型的文件分割。 这些术语听起来能够互换,但显然不是。node

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

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

第二个听起来更吸引人,不是吗?事实上,关于这个问题的许多文章彷佛都假设这是制做更小的JavaScript 文件的唯一值得的状况。webpack

但我在这里要告诉你的是,第一个在不少网站上都更有价值,应该是你为全部网站作的第一件事。git

就让咱们一探究竟吧。es6

Bundle splitting

bundle splitting 背后的思想很是简单,若是你有一个巨大的文件,而且更改了一行代码,那么用户必须再次下载整个文件。可是若是将其分红两个文件,那么用户只须要下载更改的文件,浏览器将从缓存中提供另外一个文件。github

值得注意的是,因为 bundle splitting 都是关于缓存的,因此对于第一次访问来讲没有什么区别。web

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

对于常常访问的用户来讲,量化性能加强所带来的影响可能比较棘手,可是咱们必须进行量化!

这将须要一个电子表格,所以咱们须要锁定一组很是特定的环境,咱们能够针对这些环境测试每一个缓存策略。

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

  • 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% ? 这彻底没有问题。

分离应用程序代码的区域

让咱们转到 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文档 中所述。

总结

对于上面总结如下两点:

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

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

原文:

https://hackernoon.com/the-10...

你的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总以下,以为不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」做者,对前端技术保持学习爱好者。我会常常分享本身所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,便可看到福利,你懂的。

clipboard.png