likes
comments
collection
share

首屏渲染优化实战 (Webpack 打包优化)

作者站长头像
站长
· 阅读数 46

前言

最近检查前端监控,发现项目的首屏渲染时间较长,影响了用户体验; 从图表中可以看出,主要是「资源加载」的耗时,查看网络面板,一些资源是同步加载的,且资源体积较大,还加载了首屏不必要的资源; 尝试使用 Webpack 进行优化。 首屏渲染优化实战 (Webpack 打包优化) 首屏渲染优化实战 (Webpack 打包优化)

1 - 分析耗时原因

项目使用了 single-spa 微前端框架,渲染出一个完整的页面时,需要加载两个项目,主应用和子应用; 主应用解析完成才能加载子应用的 app.js ,就导致了部分资源是同步加载的; 主应用加载解析完成耗时 800ms,耗时过长; 子应用加载解析完成耗时 1000ms,耗时过长;

所以可以从以下几个方面着手优化加载速度:

  1. 加快主应用加载速度;
  2. 加快子应用加载速度;
  3. 网络加载速度优化;
  4. 主应用和子应用并行加载; (暂时未实现)

2 - 主应用优化

文件合并

single-spa 使用了 system.js 库来加载子应用,需要在项目初始化时引入这个库,所以之前就写了这样一段代码:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <!-- 引入 system.js 相关文件 -->
    <script src="libs/systemjs/system.min.js"></script>
    <script src="libs/systemjs/extras/amd.min.js"></script>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
  </body>
</html>

通过 script 标签引入了两个本地文件,线上环境去加载时,index.html + system.js 两个文件,耗时 400ms 左右,耗时过长; 观察加载顺序得知,是先下载完成 index.html , 在 html 解析的过程中,下载和执行 scrpit 文件;

<script> Let’s start by defining what <script> without any attributes does. The HTML file will be parsed until the script file is hit, at that point parsing will stop and a request will be made to fetch the file (if it’s external). The script will then be executed before parsing is resumed.

首屏渲染优化实战 (Webpack 打包优化) 首屏渲染优化实战 (Webpack 打包优化) 所以这里可以合并 index.html 和 system.js 3 个文件成为 1 个文件,减少同步请求,并启用 gzip 压缩 (稍后会讲解如何启用),降低客户端和服务端之间的数据传输大小;

优化后的代码:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="prefetch" href="<%= BASE_URL %>favicon.ico">
    <!-- 引入 system.js 相关文件 -->
    <script async>
      /*!
       * SystemJS 6.12.6
       */
      // system.min.js (这里仅作演示,就不贴出全部的 system.min.js 代码了)
      !function(){};
      // amd.min.js (这里仅作演示,就不贴出全部的 amd.min.js 代码了)
      !function(){};
    </script>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
  </body>
</html>

这里用到了 script 标签的 async 属性,关于 script 的属性对比见:async vs defer attributes ps: 这里的 async 属性主要用来控制 script 的执行时机,不用来控制 script 的下载顺序,因为 scrpit 文件已经和 index.html 一同下载; 还用到了 link 标签的 prefetch 属性,详细说明见:页面资源优化之preload、prefetch

删除不必要的库

之前为了方便使用弹窗、Container 布局容器、icon,引入了 element-plus 组件库,虽然有 tree shaking 减少了打包体积,但还是增加了 150kb 左右的大小,这些功能完全可以自己实现;

  • 弹窗使用 函数式组件 封装;
  • 布局容器使用原生 sectionheadermain 标签实现;
  • icon 使用 i 标签加 svg 标签来实现,可通过 css 属性改变 icon 颜色;
<i class="icon">
  <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
    <path fill="currentColor" d="*" />
  </svg>
</i>

gzip 压缩静态资源

vue.config.js

const CompressionWebpackPlugin = require('compression-webpack-plugin');
const GzipExtensions = ['js', 'html', 'css', 'svg']; // 对纯文本类型的文件进行压缩

module.exports = {
  configureWebpack: (config) => {
    if (process.env.NODE_ENV === 'production') {
      // 生产环境开启 gzip 压缩
      config.plugins.push(
        new CompressionWebpackPlugin({
          algorithm: 'gzip', // 压缩协议
          test: new RegExp(`\\.(${GzipExtensions.join('|')})$`), // 匹配文件后缀
          threshold: 10240, // 对超过 10k 的数据进行压缩
          minRatio: 0.8, // 压缩率
          deleteOriginalAssets: false, // 是否删除原文件
        }),
      );
    }
  },
};

webpack.config.js

const CompressionWebpackPlugin = require('compression-webpack-plugin');
const GzipExtensions = ['js', 'html', 'css', 'svg']; // 对纯文本类型的文件进行压缩

module.exports = {
  mode: 'production',
  plugins: [
    new CompressionWebpackPlugin({
      algorithm: 'gzip', // 压缩协议
      test: new RegExp(`\\.(${productionGzipExtensions.join('|')})$`), // 匹配文件名
      threshold: 10240, // 对超过 10k 的数据进行压缩
      minRatio: 0.8,
      deleteOriginalAssets: false, // 是否删除原文件
    }),
  ],
};

优化前后对比

优化前: 首屏渲染优化实战 (Webpack 打包优化) 优化后: 首屏渲染优化实战 (Webpack 打包优化) 可以看到主应用的首屏加载时间(no cache, no keep-alive 条件下)由原来的 800ms,减少到了 200+ms,HTTP 请求个数由原来的 5 个,减少到了 3 个;

3 - 子应用优化

打包分析

优化 chunk 前,我们需要知道每个 chunk 里包含了哪些 module;

安装 webpack-bundle-analyzer 工具

npm i webpack-bundle-analyzer -D

vue.config.js

const WebpackBundleAnalyzer = require('webpack-bundle-analyzer');

module.exports = {
  configureWebpack: (config) => {
    config.plugins.push(new WebpackBundleAnalyzer.BundleAnalyzerPlugin());
  },
};

webpack.config.js

const WebpackBundleAnalyzer = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [new WebpackBundleAnalyzer.BundleAnalyzerPlugin()],
};

分析图 首屏渲染优化实战 (Webpack 打包优化)

拆包 (splitChunks)

原则:大 module 抽离,小 module 合并; 将大 module xlsx.js element-plus video.js echarts 抽离为单独 chunk; 将小 module dayjs resize-observer-polyfill jsonp 等,合并到 vendor.js; 相关文章见:谈一谈Webpack的SplitChunks

module.exports = {
  chainWebpack: (config) => {
    config.optimization.splitChunks({
      cacheGroups: {
        vendor: {
          name: 'chunk-vendor',
          test: /[\\/]node_modules[\\/](vxe-table|xe-utils|@tencent\/beacon-web-sdk|@ctrl|dayjs|@popperjs|normalize-wheel-es|resize-observer-polyfill|jsonp|core-js|lodash|@babel\/runtime|buffer|base64-js|ieee754|async-validator|url|node-libs-browser|querystring-es3|regenerator-runtime)[\\/]/,
          reuseExistingChunk: true,
          chunks: 'async',
        },
        element: {
          name: 'chunk-element',
          test: /[\\/]node_modules[\\/](element-plus)[\\/]/,
          reuseExistingChunk: true,
          chunks: 'async',
        },
        video: {
          name: 'chunk-video',
          test: /[\\/]node_modules[\\/](video.js|@videojs|aes-decrypter|m3u8-parser|mpd-parser|videojs-font|videojs-vtt.js|safe-json-parse|url-toolkit|global|keycode|mux.js|@xmldom)[\\/]/,
          reuseExistingChunk: true,
          chunks: 'all',
        },
        xlsx: {
          name: 'chunk-xlsx',
          test: /[\\/]node_modules[\\/]xlsx[\\/]/,
          reuseExistingChunk: true,
          chunks: 'all',
        },
        echarts: {
          name: 'chunk-echarts',
          test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/,
          reuseExistingChunk: true,
          chunks: 'all',
        },
      },
    });
  },
};

异步加载

xlsx 异步加载

xlsx 多用于 excel 文件的读取和导出,一般通过按钮来触发,不需要在首屏时加载这个模块; 可通过异步加载的形式,在用户点击时进行下载使用;

async function exportExcel() {
  const XLSX = await import(/* webpackChunkName: "import-xlsx" */ 'xlsx');
  return XLSX;
};

const click = async () => {
  const XLSX = await exportExcel();
  // ...
}

echarts 异步加载

page.vue

onMounted(async () => {
  const chartDom: HTMLElement | null = document.getElementById('chart-dom');
  const echarts = await import(/* webpackChunkName: "import-echarts" */ '.echarts.config');
  myChart = echarts.default.init(chartDom as HTMLElement);
});

echarts.config.ts

import * as echarts from 'echarts/core';
import { BarChart } from 'echarts/charts';
import { GridComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([BarChart, CanvasRenderer, GridComponent]);

export default echarts;

路由懒加载

Vue Router 路由懒加载 ; 路由懒加载可以对代码进行拆包,也可以对拆包页面中使用到的模块异步化处理; 这里对 VXE-Table 进行了异步加载,抽离出 app.js; 因为 router-view.vue 是顶层的路由页面,所以下层的页面都能正常使用 VXE-Table 组件;

router.ts

import { RouteRecordRaw } from 'vue-router';

const manageRouters: Array<RouteRecordRaw> = [
  {
    path: '/add',
    component: () => import(/* webpackChunkName: "manage" */ '@/pages/router-view.vue'),
    children: [
      {
        path: '403/:authType',
        name: 'add-page-forbidden',
        component: () => import(/* webpackChunkName: "403" */ '@/pages/403.vue'),
      },
      {
        path: '',
        name: 'add',
        component: () => import(/* webpackChunkName: "manage" */ '@/pages/manage/add/index.vue'),
      },
    ],
  },
];

export default manageRouters;

router-view.vue

<template>
  <router-view v-slot="{ Component, route }">
    <component :is="Component" :key="route.name" />
  </router-view>
</template>

<script setup lang="ts">
import { inject, App } from 'vue';
import { VXETable, Header, Column, List, Table } from 'vxe-table';
import 'xe-utils';

VXETable.setup();

// inject('$app') 来自main.ts
// const app = createApp(App);
// app.provide('$app', app);
const app = inject<App>('$app');

[Header, Column, List, Table].map((component) => app?.use(component));
</script>

video.js 组件异步加载

Vue3 异步组件 异步组件里引用的模块会被单独打包,而且是在使用到时才会加载;

base-video.vue component

// ...
<script>
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
// ...
</script>

video-dialog.vue (use base-video.vue)

<template>
  <el-dialog v-model="visible">
    <component :is="visible ? 'BaseVideo' : ''" />

    <template #footer>
      <el-button @click="visible = false">关闭</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts">
import { defineAsyncComponent, defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'DialogVideo',
  components: {
    BaseVideo: defineAsyncComponent(() => import('@/components/base-video.vue')),
  },
  setup() {
    const visible = ref(false);

    return {
      visible,
    };
  },
});
</script>

按需引入

使用 xlsx 组件时,如果只需要导出 excel 文件,而不需要读取 excel 内容,可以只引入 xlsx/dist/xlsx.mini.min.js;

// 全量引入
import('xlsx');

// 最小化引入 (只能导出 excel)
import('xlsx/dist/xlsx.mini.min.js');

echarts 支持按需引入:文档 TDesign 支持自动引入:文档 ElementPlus 支持自动引入:文档

HTTP 连接

通过上面的 拆包 (splitChunks)异步加载,我们将小的 module 进行合并,不必要的 module 需要时加载,从而减少了首屏时 HTTP 的连接数量; 之所以要控制 HTTP 的连接数量,是因为 Chrome 对同一域名,只允许最大 6 个并发请求,超过 6 个的连接会等待(stalled),阻塞首屏的渲染;

优化前: 首屏渲染优化实战 (Webpack 打包优化) 优化后: 首屏渲染优化实战 (Webpack 打包优化)

雪碧图

合并 svg 为雪碧图,将多张 svg 图片合并为一个 svg,可独立一个请求,也可打包到 js 中 (本业务只用到了 8 个 svg,因此合并到了 js 中);

安装 svg-sprite-loader

npm i svg-sprite-loader -D

vue.config.js

module.exports = {
  chainWebpack: (config) => {
    const svgRule = config.module.rule('svg');
    svgRule.uses.clear();
    svgRule.include.add(resolve('src/assets/svg'));
    svgRule.use('svg-sprite-loader').loader('svg-sprite-loader').options({
      symbolId: 'icon-[name]',
    });
  },
};

svg-icon component

<template>
  <svg class="svg-icon" aria-hidden="true">
    <use :xlink:href="iconName" />
  </svg>
</template>

<script>
export default {
  props: {
    icon: {
      type: String,
      required: true,
    },
  },
  computed: {
    iconName() {
      return `#icon-${this.icon}`;
    },
  },
};
</script>

<style scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;
  overflow: hidden;
}
</style>

use component

<svg-icon icon="icon-arrow-up" />

合并后的 svg 代码,并整合到了 body 标签下: 首屏渲染优化实战 (Webpack 打包优化)

HTTP 加载顺序

见文章上方 主应用优化 - 文件合并 - async、defer、prefetch、preload 说明;

Tree Shaking

lodash 俗称鲁大师,前端很常用的一个 js 库; 强烈建议使用 lodash-es 按需引入,而不是使用 lodash; 注意!!!Tree Shaking 导致 get 方法有问题 issue ,建议只使用 babel-plugin-lodash 即可;

安装 babel-plugin-lodash lodash-webpack-plugin

npm i babel-plugin-lodash lodash-webpack-plugin -D

babel.config.js

modules.exports = {
  // ...
  plugins: ['lodash'],
}

vue.config.js

const LodashWebpackPlugin = require('lodash-webpack-plugin');

module.exports = {
  // ...
  configureWebpack: (config) => {
    config.plugins.push(new LodashWebpackPlugin());
  }
}

patch-package 手动 Tree Shaking

使用 element-plus 的 icons-vue 库时发现,没有使用到的 icon 也被打包进来了,导致压缩后的体积也有 40kb 左右,分析原因发现 element-plus 引用 icons-vue 时没有使用 esm 版本,导致 Tree Shaking 无效; 关于 Tree Shaking 的两篇文章可以看一下:你的Tree-Shaking并没什么卵用Tree-Shaking性能优化实践 - 原理篇; 这里采用了比较简单粗暴的方法,直接修改 icons-vue 的 index.js,去掉用不到 icon 的 require 引用; 对比修改前后文件的差异,生成一个 patch 文件(类似 git diff),在每次本地运行和打包之前应用这个 patch 文件即可; 这里使用到了 patch-package 这个 npm 工具,成功的将 40kb 的模块减小到了 5kb;

安装 patch-package

npm i patch-package -D

修改 node_modules/@element-plus/icons-vue/dist/lib/index.js 文件 (仅列出几段用于演示)

// var apple = require('./apple.vue.js');
// var arrowDownBold = require('./arrow-down-bold.vue.js');
var arrowDown = require('./arrow-down.vue.js');
var arrowLeftBold = require('./arrow-left-bold.vue.js');

// exports.Apple = apple["default"];
// exports.ArrowDownBold = arrowDownBold["default"];
exports.ArrowDown = arrowDown["default"];
exports.ArrowLeftBold = arrowLeftBold["default"];

生成补丁

npx patch-package @element-plus/icons-vue

可以看到生成了一个 patches 目录,目录里就是生成的补丁 首屏渲染优化实战 (Webpack 打包优化)

修改运行脚本

{
  "scripts": {
    "serve": "patch-package && vue-cli-service serve",
    "build": "patch-package && vue-cli-service build"
  }
}

优化前后对比

分析图 首屏渲染优化实战 (Webpack 打包优化) 优化前: 首屏渲染优化实战 (Webpack 打包优化) 优化后: 首屏渲染优化实战 (Webpack 打包优化) 可以看到子应用的首屏加载时间(no cache, no keep-alive 条件下)由原来的 900+ms,减少到了 350ms;

4 - 网络优化

从主应用和子应用的前后对比中可以看到,大小差不多的文件,下载速度不同,优化后下载速度更快;

优化前,大小 126kb 文件的耗时详情: 首屏渲染优化实战 (Webpack 打包优化) 优化后,大小 123kb 文件的耗时详情: 首屏渲染优化实战 (Webpack 打包优化) 这里是因为,优化后换了更近的服务器。之前的服务器在上海,现在换到了广州,所以从深圳访问广州服务器的速度明显变快了; 在部署应用的时候,可以多地多机部署,通过网关控制地区访问最近的服务器,来达到网络优化; 我们这里是把应用部署在了 TKE 上,部署了南京和广州两个集群,北方地区选择南京集群,西南和东南地区地区选择广州集群; 当然,最好的办法是通过 CDN 部署资源,让专业的内容分发网络来优化地区访问。