webpack 4: 代码分割优化最佳实践方案

403 阅读9分钟

前提

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

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

  1. 知道您的网站的最佳文件拆分策略(file-splitting strategy)

  2. 知道该方案该如何实现

正文

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

  • 捆绑拆分(Bundle splitting):创建更多、更小的文件(但无论如何都要在每个网络请求中加载所有文件)以获得更好的缓存。

  • 代码拆分(Code splitting):动态加载代码,以便用户只下载他们正在查看的网站部分所需的代码。

第二个听起来更吸引人,不是吗?事实上,许多关于这一问题的文章似乎都认为这是制作较小JavaScript文件的唯一有价值的案例。(笔者:确实是这样想的)

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

让我们深入了解。

捆绑拆分(Bundle splitting)

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

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

(我认为过多的性能文章都是关于第一次访问网站的。也许这部分是因为“第一印象很重要”,部分是因为它很好,测量起来很简单。)

以下是我在上一段中提到的场景:

  1. Alice每周访问我们的网站一次,为期10周
  2. 我们每周更新一次网站
  3. 我们每周更新“产品列表”页面
  4. 我们还有一个“产品详细信息”页面,但目前还没有开发
  5. 在第5周,我们向网站添加了一个新的npm包
  6. 在第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文件。

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

image.png 结果上述的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。

image.png 这样就只会下载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包,但她永远不会下载两次相同的包。

image.png

这下就只有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下载量。

image.png 只剩下1.815MB了!(笔者:这个感觉就是路由懒加载的模式)

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

所有这些都是通过更改我们的网页包配置来完成的——我们没有对应用程序代码做任何更改

我在前面提到,测试中的具体场景实际上并不重要。这是因为,无论您提出什么方案,结论都是一样的:将您的应用程序拆分为合理的小文件,以便您的用户下载更少的代码。

问题分析

  1. 拆分这么多的包会影响网络请求时间嘛?

可以使用http2.0进行处理,分帧加载,并不会因为请求数量增加请求时间

  1. 每个webpack包包中没有开销/样板代码吗?

会的

  1. 拆分更多的包会导致可压缩的代码减少嘛

有的,每个被拆分出的包确实会有一些多余的样板代码。** 并且被拆分的包越多,增加的样板代码越多,并且能被压缩的体积越小。**

但是影响不大,我刚刚做了一个测试,一个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);
}

译者:其实也就是一些依赖可以在需要的时候做动态引入

路由懒加载

直接看链接就行了,常用技术

总结

如果有人多次访问您的站点,请将您的代码拆分为许多小文件。

如果站点中有大部分用户不访问,请动态加载该代码。

感谢您的阅读,祝您度过愉快的一天!

原文地址:david-gilbertson.medium.com/the-100-cor…