跳过正文
  1. 前端工程体验优化实战/

代码分割最佳实践:应用改造示例

hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
代码优化 - 这篇文章属于一个选集。
§ 4: 本文

在了解了细粒度代码分割方案的优点后,下面我们将以老朋友前端优化示例项目为例,来完整演示增加细粒度代码分割的流程和具体代码改动。

4. 示例:为前端项目增加细粒度代码分割
#

1. 确认优化前状态
#

优化开始前,我们先通过webpack-bundle-analyzer插件,确认前端工程项目当前的编译打包产物的状况,例如下图就是配套示例项目fe-optimization-demo 在模块懒加载优化后的分析图:

可以看到,vendors.${hash}.js包含了许多模块,体积非常大。

每次编译打包,vendors区块中任何一个模块变化,都会导致vendors区块的产物文件名称中的哈希字符串更新。进而导致生产环境用户需要重新下载vendors.${hash}.js,使缓存命中率降低。

细粒度代码分割正是为了解决上述痛点而发明的。

2. 增加细粒度代码分割缓存组
#

具体做法是,在Webpack配置中增加libshared2个缓存组(cacheGroup),将vendors这样体积较大的模块进一步合理、精细地分割。

具体的改动主要有3步:

  1. 删除原有的vendors缓存组:释放其包含的模块。
  2. 禁用默认缓存组:避免干扰细粒度代码分割。
  3. 新增libshared2个缓存组配置

《细粒度代码分割》完整改造代码示例MR:https://github.com/JuniorTour/fe-optimization-demo/pull/3

const crypto = require('crypto');
const path = require('path');

const MAX_REQUEST_NUM = 20;
// 指定一个 module 可以被拆分为独立 区块(chunk) 的最小源码体积(单位:byte)
const MIN_LIB_CHUNK_SIZE = 10 * 1000;

const isModuleCSS = (module) => {
  return (
    // mini-css-extract-plugin
    module.type === `css/mini-extract` ||
    // extract-css-chunks-webpack-plugin (old)
    module.type === `css/extract-chunks` ||
    // extract-css-chunks-webpack-plugin (new)
    module.type === `css/extract-css-chunks`
  );
};

module.exports = {
  optimization: {
    // https://webpack.js.org/configuration/optimization/#optimizationruntimechunk
    // 指定是否将Webpack的运行时(每个文件中重复的、用于加载的函数)拆分为独立文件,能减少重复代码。
    runtimeChunk: 'single',
    splitChunks: {
      maxInitialRequests: MAX_REQUEST_NUM,
      maxAsyncRequests: MAX_REQUEST_NUM,
      minSize: MIN_LIB_CHUNK_SIZE,
      cacheGroups: {
        defaultVendors: false,
        default: false,
        lib: {
          chunks: 'all',
          test(module) {
            return (
              module.size() > MIN_LIB_CHUNK_SIZE &&
              /node_modules[/\]/.test(module.identifier())
            );
          },
          name(module) {
            const hash = crypto.createHash('sha1');
            if (isModuleCSS(module)) {
              module.updateHash(hash);
            } else {
              if (!module.libIdent) {
                throw new Error(
                  `Encountered unknown module type: ${module.type}. Please check webpack/prod.client.config.js.`,
                );
              }
              hash.update(
                module.libIdent({ context: path.join(__dirname, '../') }),
              );
            }

            return `lib.${hash.digest('hex').substring(0, 8)}`;
          },
          priority: 3,
          minChunks: 1,
          reuseExistingChunk: true,
        },
        shared: {
          chunks: 'all',
          name(module, chunks) {
            return `shared.${crypto
              .createHash('sha1')
              .update(
                chunks.reduce((acc, chunk) => {
                  return acc + chunk.name;
                }, ''),
              )
              .digest('hex')
              .substring(0, 8)}${isModuleCSS(module) ? '.CSS' : ''}`;
          },
          priority: 1,
          minChunks: 2,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

这份配置中,值得注意的细节是:

  • 限制了最大产物文件数量:声明了maxInitialRequests: MAX_REQUEST_NUM, maxAsyncRequests: MAX_REQUEST_NUM,避免数量过多,超过HTTP/2协议的并发数量,导致页面资源加载阻塞,用户体验受损,通常建议不要超过20。

  • 指定了minSize,避免拆分出的模块体积太小,缓存优化效果不明显。

  • 2个缓存组的name都包含基于内容的哈希值,类似Webpack自带的contentHash。同时也有通过模块内容,拆分模块的作用。

  • lib的优先级(priority)要比shared高,目的是为了优先拆分出独立性较强的模块。

3. 确认分割效果
#

完成上述配置后,即可再次编译打包,通过bundle-analyzer-plugin确认分割后的模块:

从上图中,我们可以看到显著的变化:

  • 多个模块被拆分为了独立的lib.${hash}.js,例如@hot-loader/react-domreact-hook-formmarkdown-to-jsx
  • 产生了一个shared.${hash}.js,包含了article-preview.tsxshared/row.tsx等模块,自动把多个组件间共用的模块进行了分割处理。

这些新增的产物文件,独立性较强,代码更新后,多次打包构建,仍然能保持文件名中的哈希不变,部署到生产环境后,能长期复用本地缓存,缓存命中率更高。

这样的变化完美地解决了我们之前提到的痛点:

  • 配置复杂,开发体验不佳:这套配置普适性极强,不必改造即可适用于大多数前端项目,避免了复杂配置影响开发体验。
  • 配置方案健壮性不强,可维护性不好:这套配置能适应项目的快速迭代变化,长时间不需要调整,仍然能保持较好的分割效果、缓存命中率,可维护性极佳;
  • 用户体验不好:缓存命中率更高,相应的使页面加载渲染更快,用户体验更好。

开发体验好、健壮性强、用户体验好正是这套细粒度代码分割方案的优势所在。

4. 健壮性证明:共用(shared)模块示例
#

为了进一步演示细粒度代码分割的优点,我们再主动增加一个多组件共用模块,来看看这套分割方案是否需要手动调整,会如何应对。

我们以近年来因体积庞大(源码700+KB)而在前端界臭名昭著的moment.js库为例,将其引入我们的项目中,并在2个组件间复用:

《添加moment.js作为共用(shared)模块示例》完整代码示例:https://github.com/JuniorTour/fe-optimization-demo/pull/3/commits/050a34943d0955fc016d71f4a945715eee572fdd

    // src\shared\temp-shared-chunk-example.ts
    import moment from 'moment';

    export function getTime() {
      return moment().format('MMMM Do YYYY, h:mm:ss a');
    }

    // 导入模块 1. src\pages\home\index.tsx
    import { getTime } from '@/shared/temp-shared-chunk-example';

    getTime();


    // 导入模块 2. src\pages\login\index.tsx
    import { getTime } from '@/shared/temp-shared-chunk-example';

    getTime();

代码修改完成后,再次运行bundle-analyzer-plugin,确认状况,可见下图:

可以看到,moment.js新引入的代码被自动拆分成了2个产物文件,各自有独立的带哈希文件名,只要moment.js版本号不更新,生产环境的缓存就可以一直复用,缓存效率极高。

这也进一步证明了这套细粒度代码分割方案的健壮性。

注:sharedlib缓存组的辨析:

共性:shared缓存组和lib缓存组的目标都是通过细粒度地分割代码模块,提高缓存的命中率,优化用户体验。

特性:shared缓存组的特有目标是将多个区块间共用的代码进行拆分,避免模块被重复打包进多个区块。

此外,shared缓存组优先级是priority: 1,比lib缓存组要低。

示例图-无shared,只有lib,导致moment/local模块被重复打包:

3447e6fe-4bae-4c35-99eb-4a3e4362151c.jpeg

5. 获取全部产物清单
#

细粒度代码分割后的产物数量较多,如果需要完整清单,可以使用webpack-manifest-plugin随编译打包生成一份JSON格式的产物清单文件,用于服务端渲染时加载、生产环境部署。

请看代码示例:

《以使用webpack-manifest-plugin生成产物清单》完整代码示例:https://github.com/JuniorTour/fe-optimization-demo/pull/3/commits/788bbd580455295a4541b608db8eee6c015796f2

// webpack\production.config.js
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');

const PUBLIC_PATH = 'http://localhost:3000/';

module.exports = {
  mode: 'production',
  // ...
  plugins: [
    new WebpackManifestPlugin({
      fileName: 'assets-manifest.json',
      generate: (seed, files) => {
        const entrypoints = new Set();
        files.forEach((file) => {
          const chunkGroups = file?.chunk?._groups;
          if (chunkGroups) {
            chunkGroups.forEach((group) => entrypoints.add(group));
          }
        });
        const entries = [...entrypoints];
        const entryArrayManifest = entries.reduce((acc, entry) => {
          const name = entry?.options.name || entry?.runtimeChunk.name;
          const chunks =
            entry?.chunks?.map((chunk) =>
              chunk.files.map((file) => PUBLIC_PATH + file),
            ) || [];
          const allFiles = [].concat(...chunks).filter(Boolean);

          return name ? { ...acc, [name]: allFiles } : acc;
        }, seed);

        return entryArrayManifest;
      },
    }),
  ],
}

这段代码中,我们利用WebpackManifestPlugin获取Webpack打包的产物数据(files),遍历每个产物文件,找到其对应的区块(chunk),将属于相同区块的文件拼接起来,作为清单文件的内容。

运行编译打包后,就会在dist目录中额外生成一个名为assets-manifest.json的文件,其内容就是每个区块加载所需的静态资源文件,并且因为我们已经做了路由懒加载优化,所以这里生成的就是前端路由对应的产物文件,例如:

    {
      "main": [
        "http://localhost:3000/runtime.8d50d2f1d0dbd9f95dd0.js",
        "http://localhost:3000/lib.a284c5a4.0e5840fc8fe0545c7b7f.js",
        "http://localhost:3000/main.030bb00c221b2a80e00e.css",
        "http://localhost:3000/main.f6360d0de4d842c6d6bc.js"
      ],
      "login": [
        "http://localhost:3000/lib.75fc9c18.d2b04d932876328ce671.js",
        "http://localhost:3000/shared.cc020c28.91c44f0c97414e03e3ae.js",
        "http://localhost:3000/login.18c40a63634cb8fc0422.css",
        "http://localhost:3000/login.2dd91b1f4cd8a0beecaa.js"
      ],
      "profile": [
        "http://localhost:3000/shared.183ce61f.1c528f06dcefa66a9d45.js",
        "http://localhost:3000/profile.16c4eba5c2e58679bc5b.css",
        "http://localhost:3000/profile.d4bf82924e65660a99b7.js"
      ],
      "article": [
        "http://localhost:3000/lib.3ca6b8d4.7ec6d611f235619f5eae.js",
        "http://localhost:3000/article.9803c033314d2ed0ca8e.css",
        "http://localhost:3000/article.0386d08bf2f2bd099618.js"
      ],
      "markdown-to-jsx": [
        "http://localhost:3000/lib.f15b848c.4611e1690d48799809e1.js"
      ]
    }

5. 验证,量化与评估
#

1. 验证
#

1. CDN需要使用HTTP/2版本
#

注意!

细粒度代码分割往往会产生20个以上的产物文件,需配合HTTP/2协议的多路复用特性,对多个产物文件复用同一TCP连接,才能避免同时下载文件过多,阻塞页面加载渲染。

所以上线前务必为CDN开启HTTP/2协议,详细介绍请参考上文第5节《CDN最佳实践》

注:HTTP协议同时链接数量限制

HTTP/1.1 及之前的HTTP版本,对单个域名只能建立不超过6个TCP链接,所以如果一次性加载6个以上的资源,会导致资源排队阻塞。

但是基于HTTP/2协议,根据Chrome官方的测算,初始请求资源数小于100个,对用户体验的负面影响都微乎其微。

所以使用基于HTTP/2协议的CDN,就可以放心大胆地使用细粒度代码分割了。

2. 基于webpack-bundle-analyzer分析产物文件组成模块
#

打包相关的优化,分析验证产物文件组成模块都是必不可少的环节。

推荐做法是用webpack-bundle-analyzer分别在优化前、后生成一份报告HTML文件,保存下来,并相互对比。

分析验证的要点是:

  • lib类型的产物文件有哪些,是否都是长期不变、内容稳定的模块,体积大小如何。
  • shared类型的产物文件,被哪些区块共用,是否符合预期。
  • 有没有遗留下体积较大的模块,期望被分割为独立文件,但最终没有被拆分。
  • 项目总体积、各文件体积的变化。

3. CSS分割后的样式覆盖问题
#

代码分割后,有时会出现CSS文件数量变多、CSS样式覆盖错乱的问题。

例如:

// 1. 代码分割优化前
<link href="/main.css" rel="stylesheet" type="text/css" />
<button class="btn confirm-btn">Confirm</button>    // 最终前景色为红色(red)

// main.css
.btn {
    color: blue;
}

// main.css
.confirm-btn {
    color: red;
}

// 2. 代码分割优化后 
<link href="/main.css" rel="stylesheet" type="text/css" />
<link href="/btn.css" rel="stylesheet" type="text/css" />
<button class="btn confirm-btn">Confirm</button>    // 最终前景色为蓝色(blue)

// btn.css
.btn {
    color: blue;
}

// main.css
.confirm-btn {
    color: red;
}

上述示例中,class"btn confirm-btn"button元素,在代码分割优化前,前景色为红色。而代码分割后,产物文件多了一个btn.css,加载后的颜色,就会变成蓝色。

原因是:代码分割也会分割CSS模块,分割后多了一个btn.css,加载顺序后于mian.css。会因为同样优先级(specifity)的CSS 选择符,声明的覆盖声明的规则,这条CSS的隐式特性,导致.confirm-btn.btn覆盖,样式随之变化。

解决方案可以考虑:

  1. 手动增加class优先级,例如把.confirm-btn {}改成div.confirm-btn {},多一级选择符,从而实现样式覆盖,规避CSS加载顺序文件。但是如果样式错乱的元素较多,定位、修改会非常麻烦,这个解决方案不太实用。
  2. 使用CSS in JS方案,规避CSS“声明规则覆盖声明规则”的隐式特性。详情请见后文的第13小节《用CSS in JS 解决CSS痛点》
  3. 增加一个缓存组,将所有CSS都合并为一个文件,避免先后加载导致样式错乱,例如Gatsby 框架源码中的styles缓存组
styles: {
  test(module) {
    return isCssModule(module)
  },
  name: `styles`,
  priority: 40,
  enforce: true,
},

4. 各路由加载资源体积变化
#

代码分割优化效果,可以通过对比各路由加载资源体积变化,在本地开发环境初步确认。

通常来说,细粒度代码分割不会对产物总体积有显著影响,其主要优化目标是提高多次部署更新后的缓存命中率。

2. 建立量化指标
#

1. JS资源缓存命中率指标
#

细粒度代码分割会直接影响JS加载是否命中了浏览器的本地缓存,我们可以继续复用懒加载中建立的缓存命中率指标,并通过上报资源的类型过滤出JS类型的缓存命中率。

例如:

const resourceTypes = {
    document: 'Document',
    script: 'Script',
    link: 'Link',
    stylesheet: 'Stylesheet',
    img: 'Image',
    media: 'Media',
    font: 'Font',
    xhr: 'XHR',
    fetch: 'Fetch',
    other: 'Other',
};

function checkResourceCacheHit() {
    // ...
    const perfEntries = performance.getEntriesByType('resource');

    for (const entry of perfEntries) {
      // ...
      let hitCache = entry.duration < 50;
      // 新增 type 变量
      const type =
        resourceTypes[resource.initiatorType] || resourceTypes['other'];  

      report(
        'cacheHiteRate',
        { 
            hitCache, 
            name: entry.name,
            type,    // 作为新增的 label 上报到 Grafana
        },
        '缓存命中率计数指标'
      );
    }
}

在上述代码中,我们新增了type变量,取自PerformanceResourceTiminginitiatorType资源类型字段,作为新增的label上报到 Grafana。

同时在Grafana中进行可视化图表数据查询时,增加label作为查询参数即可筛选出我们想要关注的JS类型资源,查询语句示例:cacheHiteRate{hitCache="true", type="Script"}

2. FCP 和LCP
#

细粒度代码分割提高静态资源的缓存命中率后,预计可以减少页面加载、解析资源的体积,加快页面渲染,从而优化FCP和LCP用户体验指标的状况。

所以继续复用基于web-vitals库建立的FCP和LCP指标,也可以量化优化效果。

3. 评估优化效果
#

如果FCP和LCP指标评分为优的样本占比或是缓存命中率指标,在优化后,有所提高,并且保持长期稳定,那就说明我们的细粒度代码分割产生了优化效果,改善了用户体验。

  1. 例如下图中的缓存命中率指标在10-04优化上线后,有了显著的提高:
image.png

对于加载静态资源总体积指标,只要确认加载的静态资源体积没有1%以上的大幅异常增加,即可说明优化没有产生负面影响,例如下表是fe-optimization-demo 应用细粒度代码分割后,各路由的体积变化:

加载JS体积(单位:KB)优化前(仅懒加载)优化后(懒加载 && 细粒度代码分割)差异
首页(/home )85.0
86.0
+1 KB (+1% )
文章页(/article)加载JS体积98.1
98.3
+0.2 KB(+0.2%)
登录页(/signin)加载JS体积82.8
83.0
+1 KB (+1% )

虽然让前端应用加载静态资源的体积增加了1%,但因为幅度较小,所以不必过于关注。

这点变化,相较于缓存命中率指标的改善和FCP,LCP的优化,几乎可以忽略不计。

小结
#

在这2节《代码分割最佳实践:细粒度代码分割》中,我们首先一起学习了Webpack的代码分割原理和12项核心配置逻辑。

接下来,又针对传统代码分割的痛点,介绍了更加先进、体验更好的细粒度代码分割方案。

同时以fe-optimization-demo项目为例演示了实施优化的具体过程和潜在问题,验证了这一优化方案的健壮性和优化效果。

代码优化 - 这篇文章属于一个选集。
§ 4: 本文

相关文章