前端性能优化之打包优化实践

1,788

前端性能优化是一个老生常谈的问题,我写这篇博客的目的是记录自己的一点实践,没有什么高大上的东西,如果顺便能帮助到你,我将万分荣幸。

性能优化包含几个方面,比如减少http请求,当然现在http2有多路复用,其实很大程度上减缓了多个http请求的问题,但是话说回来,我相信大多数公司目前用的都是http1。

因此这依然是一个需要考虑的问题,比如字体图标火起来之前的雪碧图,js资源合并等等。

再比如减少首页资源包的大小,比如组件按需加载,其他依赖的按需加载等。

原因分析

这次优化的原因是我们移动端项目的首屏加载肉眼可见的慢,已经被客户吐槽了,因此必须被提上日程。我们先来看看优化之前的首屏加载状况。

longtask.png

可以看到,首屏加载过程中产生了10个长任务,同时首屏数据加载完成需要2s以上,长任务中脚本的下载时间和解析时间占比非常大,原因是主 vender 包过大,最大的 js vender 包gzip压缩后仍然接近700kb。

本地用analyzer命令查看打包分析(需要先添加 webpack-bundle-analyzer 依赖 并且在 script 配置中增加 analyzer 命令):

analyzer.png

突出的问题主要包括:

1、第三方库的共同依赖bn.js重复打包了7次

2、momentjs太重了,项目内只用了一些简单的日期格式化api,完全可以自己写一套轻量api去替换。

3、vconsole 调试工具不必打包到项目中,可走官方CDN,并且生产环境可以去掉

4、echarts、dingtalk-jsapi 等重型依赖没有按需引入

5、自研UI组件库的单个组件体积过大,同时存在单个组件多次打包的情况,需要优化打包策略

接下来我们针对上面的问题一个一个解决。

一、第三方库的共同依赖如何避免重复打包?

首先,因为 webpack 在递归查找依赖的时候会默认去第三方库的 node_modules 目录中查找并单独打包,所以每个库只要对 bn.js 有依赖就会单独打包一次。

解决这个问题其实也简单,就是告诉webpack,查找依赖时遇到bn.js就去 nodejs的命令执行目录的node_modules目录中查找依赖。

这样webpack不管遇到多少次bn.js都会识别为同一个依赖,也就只会打包一次了。

而我们要做的就是往webpack配置中的 alias(别名) 目录中增加一行如下的配置:

'bn.js': path.resolve(process.cwd(), 'node_modules', 'bn.js')

这里的 process.cwd() 是精髓所在,就是把依赖的查找目录指向当前 npm 命令的执行目录,也就是我们的项目根目录下的 node_modules 目录。

我的项目是vue项目,因此只需要在vue.config.js 中的 configureWebpack.resolve.alias 配置中增加这行配置,修改后的配置如下:

alias.png

修改完这行配置后再执行 analyzer 命令查看结果,发现光这一项操作就把主 vendor 包的gzip体积缩小了 67kb,可真是小付出博取了大回报。

二、你是否真的需要重型依赖?

这一步我们提刀砍向了重型库 momentjs,这是一个日期格式化的库,有多重呢?上个图:

moment.png

事实上我们项目中对 momentjs 的依赖仅限于几个简单的日期格式化 api ,它功能很全(其实很大一部分体积来自语言包)但其实我们用不上,针对这种情况其实完全可以自己写几个api替换,然后把momentjs移除。

但是考虑到自己写api的可靠性和工作量问题,我选择了轻量级的dayjs来替换,dayjs的 api 和momentjs基本一样,因此替换起来可以说毫不费力,同时dayjs打包后的gzip体积只有2.7kb,比 momentjs 直接小了 71kb

dayjs.png

三、调试工具何苦打包到项目中?

作为混合开发的hybrid应用,app内的真实环境调试必不可少,我们选择的是腾讯的 vconsole ,顺嘴提一句, vconsole 的 v3.10.0 版本之前,在 vue3 项目中有循环报错的问题,会导致页面卡死,我到官方仓库提了issue,官方在v3.10.0版本修复了,这里要感谢官方团队的快速响应,点赞!

言归正传,我们对 vconsole 的依赖仅限于非生产环境的调试,因此即使 vconsole 挂了也仅仅影响调试,完全可以走官方 CDN ,而且要注意,生产环境不需要引入。但是我们原来的做法是直接把 vconsole 加入到本地依赖,同时不管什么环境直接引入,只是判断在生产环境的时候不做初始化。

vconsole 即使压缩打包后依然有52kb:

vconsole.png

为此我们直接 uninstall vconsole, 同时增加如下脚本文件,在main.ts 中 import 这个脚本文件。

vconsoleCode.png

这个操作后主vendor包再次缩小52.5kb。

四、按需!按需!还是按需!

社区有众多功能全面的库,比如echarts等图表库,vantantd 等UI库,但大多数情况下我们只使用其中的部分功能。

UI组件库的按需引入以及路由的懒加载一般来说是前端项目的标配。我们的项目在创建之初就已经做过这件事。

所以这里只把精力放在我们项目中存在的问题上。

1、echarts

echarts是一个大而全的组件库,像日常开发中我们可能为了省事直接一行代码全量引入了:

import * as echarts from 'echarts'

我们来看一下全量引入echarts在压缩打包后的体积有多大:

echart.png

而事实上我们常规的项目一般只会用到柱图、饼图、折线图等几种常见且简单的图形。因此完全没必要全量引入了。我的做法是将必要的基础组件放在单独的 echartsRequire.js 文件中引入。

然后在需要使用图表的vue文件中引入 echartsRequire.js 暴露的 charts对象,再单独引入需要的图表类型 调用一次 echarts.use()

// echartsRequire.js

import * as echarts from 'echarts/core'

// 标签自动布局,全局过渡动画等特性
import { LabelLayout, UniversalTransition } from 'echarts/features'

// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers'

import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent,
  LegendComponent
} from 'echarts/components'

echarts.use([
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent,
  LegendComponent,
  LabelLayout,
  UniversalTransition,
  CanvasRenderer,
  SVGRenderer

])


export default echarts

具体使用图表的 vue 文件

  import echarts from '@/utils/echartsRequire'
  import { BarChart } from 'echarts/charts'

  echarts.use([BarChart])

接下来逻辑代码中正常使用 echarts 就行了,一顿操作之后,echarts 缩小了139kb,体积缩小一半不止。

echartsmall.png

2、dingtalk-jsapi

这个库是钉钉多段统一的 jsapi,我们只用到了其中一个更改页面title的小功能,因此 可以做按需加载。

dingtalk.png

具体操作官方文档都有,这里就不上代码了,直接上结果:

dingtalksmall.png

可以看到,我们又节省了16kb的空间,不要小看这16kb,性能优化,毫秒必争!

五、splitChunks分包策略

除了第三方库的依赖会重复打包外,我们项目中自己写的工具文件或者组件存在重复打包的问题。webpack 有一套默认配置来判断一个依赖是否需要单独打包,但是默认选项不一定适合我们的项目。

我们可以通过 webpack 的 configureWebpack.optimization.splitChunks 选项来自定义适合我们的配置。

    optimization: {

      splitChunks: {
        // 表示选择哪些 chunks 进行分割,可选值有:async,initial和all
        chunks: 'async',
        // 表示新分离出的chunk必须大于等于minSize,默认为30000,约30kb。
        minSize: 20000,
        // 表示一个模块至少应被minChunks个chunk所包含才能分割。默认为1。
        minChunks: 2,
        // 表示按需加载文件时,并行请求的最大数目。默认为5。
        maxAsyncRequests: 30,
        // 表示加载入口文件时,并行请求的最大数目。默认为3。
        maxInitialRequests: 30,
        enforceSizeThreshold: 50000,
        // cacheGroups 下可以可以配置多个组,每个组根据test设置条件,符合test条件的模块,就分配到该组。模块可以被多个组引用,但最终会根据priority来决定打包到哪个组中。默认将所有来自 node_modules目录的模块打包至vendors组,将两个以上的chunk所共享的模块打包至default组。
        cacheGroups: {
          defaultVendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10,
            reuseExistingChunk: true
          },
          default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true
          }
        }
      }
    }

以上部分注释引用自掘金文章:如何使用 splitChunks 精细控制代码分割

在此感谢博主@前端论道,他的文章对 splitChunks 介绍的非常详细,感兴趣的可以去看看。

做完这一步我们发现很多重复打包的问题没有了,同时该合并的包合并了,这样不仅减小了包体积,还轻微减少了http请求的数量,可以说是一举多得。

六、自研UI库如何优化打包?

从项目打包分析中很容易看到,我们的自研UI组件库单个单个组件打包后体积非常大,而实际上我们的组件功能并不复杂,业务代码也不多,为啥会出现这样的情况呢?

UI.png

其实不难想到,我们的UI库依赖了很多我们的项目中本来就有的依赖,比如 vuex、vuejs、axios、lodash、js-md5 等等,而这些依赖在默认情况下会顺着依赖分析的路子找到组件库的 node_modules 中,从而导致把这些依赖单独打包到了组件中。

针对这个问题,其实webpack提供了一个 externals 配置来解决,官方文档是这么说的:

webapck.png

因此我们只需要在自研UI库项目中增加一些配置就能解决:

在根目录创建文件 /build/vuecli.lib.conf.js

module.exports = {
  configureWebpack: {
    externals: [
      {
        axios: 'axios',
        vuex: 'vuex',
        lodash: 'lodash',
        'crypto-js': 'crypto-js',
        'js-cookie': 'js-cookie',
        'sa-sdk-javascript': 'sa-sdk-javascript',
        'js-md5': 'js-md5',
        'js-sha1': 'js-sha1',
        '@qzd-cypher/core': '@qzd-cypher/core',
        'dingtalk-jsapi': 'dingtalk-jsapi',
      },
    ],
  }
}

这些配置能把里面列举的依赖从打包过程中排除出去,在 runtime 中再去外部获取。

此外,我们项目中安装的依赖要么在  dependencies 中,要么在 devDependencies 但其实 package.json 中还有另外一个 peerDependencies 对象(可以翻译成同版本依赖)

这个配置对于插件库或者其他库的开发者来说非常有用,总体来说 peerDependencies 的作用和 webpackexternals 配置有重合,但又不完全一样。总结一下peerDependencies的特点:

  • 如果用户显式依赖了核心库,则可以忽略各插件的 peerDependency 声明;
  • 如果用户没有显式依赖核心库,则按照插件 peerDependencies 中声明的版本将库安装到项目根目录中;
  • 当用户依赖的版本、各插件依赖的版本之间不相互兼容,会报错让用户自行修复;

不同的是external声明的依赖在插件开发中正常安装就行了,在引用这个插件的项目打包时会把externals中列举的插件排除出去,仅在引用插件的项目中打包一次。

peerDependencies的依赖你需要同时安装到devDependdencies中,这样你在开发插件的时候才能正常使用,外部项目打包的时候会根据外部项目是否显示声明依赖来决定是否安装和单独打包。

相同的是,外部项目必须有这些依赖才能体现它的价值。

我们这里单独把 vuecore-jsvant 放入 peerDependencies

  "peerDependencies": {
    "core-js": ">=3.6.5",
    "vant": ">=3.0.16",
    "vue": ">=3.0.0"
  },

好了,通过以上的两步操作,我们把自研UI库中和业务项目中的公共依赖抽离出来了,这样UI库的组件打包后的体积将会大大缩小。

analyzerRes.png

最后,经过一顿操作后原来主 vendor 包和 app.js 两个包加起来的体积是 669kb + 99kb = 768kb,优化后仅有497kb,并且减少了首屏的一次请求。同时自研UI库的重复打包问题和单个组件过大的问题也得到了解决。

最后的最后,我们来验收一下线上的效果:

domContentLoaded.png

长任务也从原来的10个缩减为5个,并且长任务耗时也大大缩减:

longTaskRes.png

到这里,这一轮的性能优化就结束了,当然,此次 DOMContentLoaded 时长能获得这个效果不仅是前端优化的结果,和后端同学接口性能的优化分不开。

最后总结一下,其实上面所做的所有优化举措都是非常简单的操作,但是实际的项目中就是有很多人会做错,所以希望这篇文章能对你有所帮助。