在淘宝优化了一个大型项目,分享一些干货(代码实例,图文结合)

项目分享一千字,读完须要5分钟,实例部分三千字,读完须要15分钟,动手尝试实例须要1小时,若有错误请指正。
复制代码

在淘宝的优化第一弹

场景

本项目是淘系用户增加团队的一个大中台系统,单页应用,涵盖不少业务功能,运用了不少懒加载页面组件来提高性能,首屏时间 1s 左右,体验良好。然而大项目文件不少,致使构建和发布时间很长,内存占用较大。个人任务是尽量优化与此相关的问题。前端

思路

  • 首先不难发现问题并不在用户体验上,而在于开发体验,打包时间太长下降了开发效率。
  • 观察项目,项目运用了不少懒加载组件来提高单页性能,可是这些组件组件同时引用了不少重复的模块,致使体积膨胀。
  • 接下来定位重复模块具体有哪些,影响大不大,因此须要观察打包后的 chunk 包含哪些模块,重复程度如何。咱们能够将 webpack 编译器的输出信息做一些设定,从而展现 chunk 代码块的具体信息:
const compiler = webpack(webpackConfig); //经过webpack配置信息获得编译器,而后配置其输出信息
compiler.hooks.done.tap('done', (stats) => {
  console.log(
    stats.toString({
      colors: true,
      chunks: true,//这里设为true
      assets: true,
      children: false,
      modules: false,
    })
  );
}
复制代码

固然,简单的项目也能够在打包命令后面加个参数:webpack --display-chunks,效果和上面至关。node

  • 在 chunk 信息中寻找在多个 chunk 中重复的模块,将他们的路径记录,好比:
[./src/components/xxx.jsx] 4.52 KiB {55} {60} {66} {73} {87} {96} {113} {119} {127} {129} {133}
[./node_modules/base/yyy.js] 205 bytes {50} {54} {64} {70} {73} {74} {75} {80} {82} {83} {87} {92} {97} {104} {109} {111} {112} {113} {115} {117} {120} {127} {128} {129} {130} {132} {138} {150} {151}
[./node_modules/base/zzz.js] 205 bytes {50} {54} {64} {70} {73} {74} {75} {80} {82} {83} {87} {92} {97} {104} {109} {111} {112} {113} {115} {117} {120} {127} {128} {129} {130} {132} {138} {150} {151}
···
复制代码

每一个大括号内都是一个 chunk 的 id,这三个模块被重复打包到了众多 chunk 中。webpack

  • 制定代码分割策略,着重配置 optimization.splitChunks,提取重复模块,要兼顾首屏性能,首页须要的包不能太大,若是打得太大须要拆分。(项目代码分割策略不便贴出,只能用下面的实例来代替了)git

  • 最后验证打包效果,不断调整策略直至最优。github

成果

摘自个人淘宝前端团队周报:web

项目的打包编译优化,取得有效成果,目前项目整体积比原来减小了 6.4M(原来体积 42.2M,如今 35.8M),编译时间缩短 60% ,发布时间缩短 20%,文件数减小 30 个,打包时再也不出现内存溢出问题。正则表达式

使用的技术只有一个:webpack 的 SplitChunksPlugin。SplitChunksPlugin 出了两年,社区也积累了很多资料,我仍是以为须要补充下面的实例教程,有两个缘由:缓存

  • 中文社区对 SplitChunksPlugin 的某些属性讲解并不到位,官网教程翻译到中文有些地方很差理解。
  • 我能找到的 demo 都很基础,通常仅仅演示某个属性的用法,我须要一个渐进的能把各类配置统一在一块儿考虑的实例,这样才能映射到实际项目。

如下代码图文预警,webpack 知识体系完整的老司机能够直接略过,小白建议仔细阅读。bash

打包优化中心思想

专心作事前,首先要找准大方向,才不会在复杂项目中迷路。前端优化无外乎作两件事:网络

  • 优化用户体验
    • 减小首屏加载时间
    • 提高各项交互的流畅度,如表单验证和页面切换
  • 优化开发体验
    • 减小构建耗时
    • 自动化完成一些重复工做,解放生产力,脚手架是表明性产物

而 webpack 提供了模块化项目中最主要的优化手段:

  • 提取公共代码
  • 按需加载(懒加载)

因此,咱们就是要经过 Webpack 的两大优化手段,去完成上面前端优化的两件事。当咱们面对庞大的项目摸不着头脑,不妨跳出来看看。

SplitChunksPlugin 私房菜

SplitChunksPlugin 引入缓存组(cacheGroups)对模块(module)进行分组,每一个缓存组根据规则将匹配到的模块分配到代码块(chunk)中,每一个缓存组的打包结果能够是单一 chunk,也能够是多个 chunk。

webpack 作了一些通用性优化,咱们手动配置 SplitChunksPlugin 进行优化前,须要先理解 webpack 默认作了哪些优化,是怎么作的,以后才能根据本身的须要进行调整。既然造了 SplitChunksPlugin,本身确定得用上,webpack 的默认优化就是经过 SplitChunksPlugin 配置实现的,以下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      //在cacheGroups外层的属性设定适用于全部缓存组,不过每一个缓存组内部能够重设这些属性
      chunks: "async", //将什么类型的代码块用于分割,三选一: "initial":入口代码块 | "all":所有 | "async":按需加载的代码块
      minSize: 30000, //大小超过30kb的模块才会被提取
      maxSize: 0, //只是提示,能够被违反,会尽可能将chunk分的比maxSize小,当设为0表明能分则分,分不了不会强制
      minChunks: 1, //某个模块至少被多少代码块引用,才会被提取成新的chunk
      maxAsyncRequests: 5, //分割后,按需加载的代码块最多容许的并行请求数,在webpack5里默认值变为6
      maxInitialRequests: 3, //分割后,入口代码块最多容许的并行请求数,在webpack5里默认值变为4
      automaticNameDelimiter: "~", //代码块命名分割符
      name: true, //每一个缓存组打包获得的代码块的名称
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/, //匹配node_modules中的模块
          priority: -10, //优先级,当模块同时命中多个缓存组的规则时,分配到优先级高的缓存组
        },
        default: {
          minChunks: 2, //覆盖外层的全局属性
          priority: -20,
          reuseExistingChunk: true, //是否复用已经从原代码块中分割出来的模块
        },
      },
    },
  },
};
复制代码

其中五个属性是控制代码分割规则的关键,我再额外提一提:

  • minSize(默认 30000):使得比这个值大的模块才会被提取。
  • minChunks(默认 1):用于界定至少重复多少次的模块才会被提取。
  • maxInitialRequests(默认 3):一个代码块最终就会对应一个请求数,因此该属性决定入口最多分红的代码块数量,过小的值会使你不管怎么分割,都没法让入口的代码块变小。
  • maxAsyncRequests(默认 5):同上,决定每次按需加载时,代码块的最大数量。
  • test:经过正则表达式精准匹配要提取的模块,能够根据项目结构制定各类规则,是手动优化的关键。

这些规则一旦制定,只有所有知足的模块才会被提取,因此须要根据项目状况合理配置才能达到满意的优化结果。

宝藏属性 Name

name(默认为 true),用来决定缓存组打包获得的 chunk 名称,容易被轻视但做用很大。奇特的是它有两种类型取值,boolean 和 string:

  • 值为 true 的时候,webpack 会基于代码块和缓存组的 key 自动选择一个名称,这样一个缓存组会打包出多个 chunk。
  • 值为 false 时,适合生产模式使用,webpack 会避免对 chunk 进行没必要要的命名,以减少打包体积,除了入口 chunk 外,其余 chunk 的名称都由 id 决定,因此最终看到的打包结果是一排数字命名的 js,这也是为啥咱们看线上网页请求的资源,总会掺杂一些 0.js,1.js 之类的文件(固然,使资源名为数字 id 的方式不止这一种,懒加载也能轻松办到,且看下文)。
  • 值为 string 时,缓存组最终会打包成一个 chunk,名称就是该 string。此外,当两个缓存组 name 同样,最终会打包在一个 chunk 中。你甚至能够把它设为一个入口的名称,从而将这个入口会移除。

一个实例玩转各类场景

该上手撸代码了。webpack 默认优化策略对普通规模的项目已然足够,然而大厂的项目几经转手一般错综复杂,这时就须要手动优化了。下面咱们经过一个有必定结构复杂度的实例来玩转 SplitChunksPlugin,当前版本的 webpack 是 4.43.0,项目结构以下:

|--node_modules/
|   |--vendor1.js
|   |--vendor2.js
|--pageA.js
|--pageB.js
|--pageC.js
|--utility1.js
|--utility2.js
|--utility3.js
|--webpack.config.js
复制代码

vendor1.js:

export default () => {
  console.log("vendor1");
};
复制代码

vendor2.js:

export default () => {
  console.log("vendor2");
};
复制代码

pageA.js:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  console.log("pageA");
};
复制代码

pageB.js:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  console.log("pageB");
};
复制代码

pageC.js:

import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  console.log("pageC");
};
复制代码

utility1.js:

import utility2 from "./utility2";

export default () => {
  console.log("utility1");
};
复制代码

utility2.js:

export default () => {
  console.log("utility2");
};
复制代码

utility3.js:

export default () => {
  console.log("utility3");
};
复制代码

每一个文件内容并很少,关键在于它们的引用关系。webpack.config.js 配置以下:

var path = require("path");

module.exports = {
  mode: "development",
  // mode: "production",
  entry: {
    pageA: "./pageA",
    pageB: "./pageB",
    pageC: "./pageC",
  },
  optimization: {
    chunkIds: "named", // 指定打包过程当中的chunkId,设为named会生成可读性好的chunkId,便于debug
    splitChunks: {
      minSize: 0, // 默认30000(30kb),可是demo中的文件都很小,minSize设为0,让每一个文件都知足大小条件
      cacheGroups: {
        commons: {
          chunks: "initial",
          minChunks: 2,
          maxInitialRequests: 3, // 默认为3
        },
        vendor: {
          test: /node_modules/,
          chunks: "initial",
          name: "vendor",
        },
      },
    },
  },
  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].js",
  },
};
复制代码

控制台运行:webpack,打包结果:

初始运行结果
初始运行结果
能够看到,splitChunks 全局设置了 minSize=0,全部模块都符合这个条件。缓存组 vendor 经过 test 正则匹配了 node_modules 的内容,打包到一个代码块 vendor.js 中;utility2.js 在 pageA,pageB,pageC 中都被引用,符合 commons 缓存组 minChunks=2 的规则,因此单独打包到 commons~pageA~pageB~pageC.js 中。然而 utility3.js 也在 pageB.js,pageC.js 中被引用,符合 commons 的条件,却依然分散在了 pageB 和 pageC 两个 chunk 中,怎么回事?咱们观察输出信息发现 pageB 入口须要加载 commons~pageA~pageB~pageC.js vendor.js pageB.js 这三个包,而咱们 commons 的规则里 maxInitialRequests 为 3,入口分包数量达到了上限,极可能是上限过小致使没法继续分包,因此咱们修改 commons 的规则,将 maxInitialRequests 增长到 5:

commons: {
  chunks: "initial",
  minChunks: 2,
  maxInitialRequests: 5, // 默认为3时,没法知足咱们的分包数量
},
复制代码

再次打包,结果为:

maxInitialRequests=5
maxInitialRequests=5
此次 utility3.js 被单独打包为 commons~pageB~pageC,同时 pageB 入口变为了四个包:commons~pageA~pageB~pageC.js,vendor.js commons~pageB~pageC.js,pageB.js。因此,当咱们发现怎么修改规则某些模块就是提取不出,能够看看是否是打包数到达了上限,去检查 maxInitialRequests 和 maxAsyncRequests 这两个属性,maxAsyncRequests 和 maxInitialRequests 同样,只不过决定的是按需加载的分包上限。

咱们继续研究,不由产生疑问:为何缓存组 commons 产出 commons~pageA~pageB~pageC.js 和 commons~pageB~pageC.js,而缓存组 vendor 产出 vendor.js?包名格式相差巨大。这就是 name 属性在起做用,咱们注释掉 vendor 中的 name:

vendor: {
  test: /node_modules/,
  chunks: "initial",
  // name: "vendor",
}
复制代码

打包结果以下:

vendor不加name
vendor不加name
发现本来的 vendor.js 分裂成了 vendor~pageA.js 和 vendor~pageB.js,回想上一节 name 的特性,正是由于咱们没有制定 name 的具体内容,默认为 true,因此 webpack 会基于代码块和缓存组的 key 自动选择一个名称,这样一个缓存组会打包出多个 chunk。而后咱们再体验下 name 为 false 的感觉:

splitChunks: {
  minSize: 0,// 默认30000(30kb),可是demo中的文件都很小,minSize设为0,让每一个文件都知足大小条件
  name:false,
  cacheGroups: {
    commons: {
      chunks: "initial",
      minChunks: 2,
      maxInitialRequests: 5, // 默认为3时,没法知足咱们的分包数量
    },
    vendor: {
      test: /node_modules/,
      chunks: "initial",
      name: "vendor",
    }
  }
}
复制代码

打包结果以下:

name为false
name为false
缓存组 commons 产出的两个 chunk 变成了 0.js 和 1.js,体积也减小了差很少 20 字节,也算一种优化方式了。

加点料,按需加载

上面的实例都是针对入口文件的优化,如今混入按需加载代码,看看会给咱们的优化带来什么新体验。项目中加入两个懒加载文件,async1.js 和 async2.js:

|--node_modules/
|   |--vendor1.js
|   |--vendor2.js
|--pageA.js
|--pageB.js
|--pageC.js
|--utility1.js
|--utility2.js
|--utility3.js
|--async1.js
|--async2.js
|--webpack.config.js
复制代码

async1.js 代码:

import utility1 from "./utility1";

export default () => {
  console.log("async1");
};
复制代码

async2.js 代码:

import utility1 from "./utility1";

export default () => {
  console.log("async1");
};
复制代码

pageA.js 更新为:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  //懒加载
  import("./async1");
  import("./async2");
  console.log("pageA");
};
复制代码

webpack.config.js 配置为:

var path = require("path");

module.exports = {
  mode: "development",
  // mode: "production",
  entry: {
    pageA: "./pageA",
    pageB: "./pageB",
    pageC: "./pageC",
  },
  optimization: {
    chunkIds: "named", // 指定打包过程当中的chunkId,设为named会生成可读性好的chunkId,便于debug
    splitChunks: {
      minSize: 0, // 默认30000(30kb),可是demo中的文件都很小,minSize设为0,让每一个文件都知足大小条件
      // name:false,
      cacheGroups: {
        commons: {
          chunks: "all", //加入按需加载后,设为all将全部模块包括在优化范围内
          // name: "commons",
          minChunks: 2,
          maxInitialRequests: 5, // 默认为3,没法知足咱们的分包数量
        },
        vendor: {
          test: /node_modules/,
          chunks: "initial",
          name: "vendor",
        },
      },
    },
  },
  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].js",
  },
};
复制代码

其余代码不变。如今项目文件又增长了,直接 webpack 打包输出的信息可能不太够用,咱们执行:webpack --display-chunks,具体获知每一个 chunk 包含哪些包含哪些模块,属于哪一个缓存组,这样就能够根据 chunk 的具体信息,判断是否有重复模块没提取干净,是否有一些模块明没有命中咱们想要的规则,若是有,表明还有继续优化的空间。打包结果以下:

按需加载1
按需加载1
那些命中缓存组的 chunk 都被标注了 split chunk 信息,入口 chunk 被标注了[entry],而两个按需加载的文件被打包成 0.js 和 1.js,并不属于任何缓存组或入口。

观察结果咱们发现,utility1.js 同时被 pageA.js,async1.js,async2.js 三个模块引用,照理应该命中 commons 缓存组的规则,从而被单独提取成一个 chunk,然而结果是它依然打包在 pageA.js 中。这是由于 async1.js,async2.js 都是 pageA.js 的懒加载模块,而 pageA.js 同步引用了 utility1.js,因此在加载 async1.js,async2.js 时 utility1.js 已经有了,直接拿来用便可,因此就不必提出一个新的 chunk,白白增长一个请求。

那什么状况下 utility1.js 才会被单独提出来?咱们调整代码,将按需加载代码从 pageA.js 移到 pageB.js:

pageA.js:

import vendor1 from "vendor1";
import utility1 from "./utility1";
import utility2 from "./utility2";

export default () => {
  //懒加载
  // import('./async1');
  // import('./async2');
  console.log("pageA");
};
复制代码

pageB.js:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  //懒加载
  import("./async1");
  import("./async2");
  console.log("pageB");
};
复制代码

执行 webpack --display-chunks,结果以下:

按需加载2
按需加载2
发现多了一个 chunk,utility1.js 被单独提取到了 0.js 中,且属于 commons 缓存组。将按需加载代码从 pageA.js 移到 pageB.js 后,由于 pageB 和 pageA 并行,没有依赖关系,因此 async1.js 和 async2.js 须要单独加载 utility1.js 模块,又由于 commons 缓存组 chunks=all,因此 async1.js,async2.js 和 pageA.js 的公共模块 utility1.js 会被单独提取。

最后咱们想把数字 id 名称变成有意义的名称,可使用 webpack 的 magic comments,把 pageB.js 改成:

import vendor2 from "vendor2";
import utility2 from "./utility2";
import utility3 from "./utility3";

export default () => {
  //懒加载
  import(/* webpackChunkName: "async1" */ "./async1");
  import(/* webpackChunkName: "async2" */ "./async2");
  console.log("pageB");
};
复制代码

普通打包便可,结果为:

按需加载chunk命名
按需加载chunk命名
这样全部按需加载的 chunk 都有了名字,且单独提取的 utility1.js 也命中了默认命名格式,有了本身的名字。

划重点

这个实例运用了 webpack 两样主要优化手段,主要聚焦于如何让项目打包处在咱们的掌控之中,不至于出现没法理解的打包状况,最终获得想要的打包结果。但愿读完本文,你们面对再复杂的项目都能有优化入手点。

固然,优化自己是一件拆东补西的事,好比提取出一个公共 chunk,打包产出的文件就会多一个,也必然会增长一个网络请求。当项目很庞大,每一个公共模块单独提取成一个 chunk 会致使打包速度出奇的慢,影响开发体验,因此一般会取折衷方案,将重复的较大模块单独提取,而将一些重复的小模块打包到一个 chunk,以减小包数量,同时不能让这个包太大,不然会影响页面加载时间。

在淘宝研究了一段时间打包的事儿,把个人心得分享给你们:优化就是在有限的时间空间和算力下,去除低效的重复(提出公共大模块),进行合理的冗余(小文件容许重复),并利用一些用户无感知的区间(预加载),达到时间和空间综合考量上的最优。

下一期,一块儿走进 SplitChunksPlugin 源码,条分缕析 webpack 的代码分割原理。 没多少人吧,趁机立个flag:点赞超50,一周内直接SplitChunksPlugin源码撕出来。

--- 分割线 ---

谢谢你们捧场,本身立的flag跪着也要拔了,下一期在整了哈(指新建文件夹[dog])

源码

个人代码分割实例

参考资料

webpack 官网

官方 demo

相关文章
相关标签/搜索