vue3 + vite 性能优化篇(一)

2,526 阅读10分钟

vue3 + vite 性能优化篇(一)

前言

本篇文章是笔者在使用 vue3 + vite 实际开发过程中的一些经验分享,本篇主要分享的是对Vite + vue3 使用CDN加速首屏访问的实践

项目技术栈

  • Vite + Vue3 + vue-router + Pinia + ant-design-vue + axios

使用vite相关插件

  • "vite-plugin-cdn-import": "^1.0.1"是一个用于在 Vite 项目中通过 CDN 加载外部依赖的插件。它可以显著加快开发和构建过程,减少依赖的安装和打包,从而优化项目的性能,特别是在项目中使用了大量常见的库时。
  • "vite-plugin-externals": "^0.6.2"是一个用于在 Vite 项目中排除某些外部依赖,并通过 CDN 或其他方式在构建时将它们作为外部资源加载的插件。这个插件可以帮助你优化打包过程,减少包体积,并将一些依赖从打包中排除,让它们通过外部链接(例如 CDN)加载。
  • "rollup-plugin-visualizer": "^5.6.0"是一个 Rollup 插件,用于生成项目打包后的可视化报告,帮助开发者分析和理解打包的结果,查看包的大小和依赖关系。通过这个插件,你可以更好地了解项目中各个模块的体积、依赖结构,进而进行优化,减少不必要的打包和冗余。
  • "vite-plugin-compression": "^0.5.1"是一个用于在 Vite 项目中自动压缩构建输出文件的插件。它支持常见的压缩格式,如 GzipBrotli,可以有效减小项目的文件体积,从而提高网络传输效率,尤其适合生产环境的部署和优化。

vite.config.ts配置

首先先附上本次实践的相关配置

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import { visualizer } from 'rollup-plugin-visualizer'
import viteCompression from 'vite-plugin-compression'
import versionUpdatePlugin from './src/utils/versionUpdatePlugin' //Rollup 的虚拟模块
import { viteExternalsPlugin } from 'vite-plugin-externals';
import { Plugin as importToCDN } from 'vite-plugin-cdn-import';
const CurrentTimeVersion = new Date().getTime()
import path from 'path'
export default defineConfig(({ mode }) => {
  const { VITE_look_visualizer = false, VITE_APP_ENV} = loadEnv(mode, process.cwd());
  return {
    plugins: [
      vueJsx(),
      vue(),
      VueSetupExtend(),
      viteExternalsPlugin({
        '@wangeditor/editor': 'wangEditor',
        'echarts': 'echarts'
      }),
      importToCDN({
        modules: [
          {
            name: 'vue',
            var: 'Vue',
            path: 'https://.../vue@3.5.12.js'
          },
          {
            name: 'vue-demi',
            var: 'VueDemi',
            path: 'https://.../vue-demi@0.14.10.js'
          },
          {
            name: 'vue-router',
            var: 'VueRouter',
            path: 'https://.../vue-router@4.4.5.js'
          },
          {
            name: 'pinia',
            var: 'Pinia',
            path: 'https://.../pinia@2.2.6.iife.js'
          },
          {
            name: 'axios',
            var: 'axios',
            path: 'https://.../axios@1.7.7.js'
          },
          {
            name: 'dayjs',
            var: 'dayjs',
            path: ['https://.../dayjs-1.11.13/dayjs.min.js', 'https://.../dayjs-1.11.13/locale/zh-cn.js', 'https://.../dayjs-1.11.13/locale/vi.js', 'https://.../dayjs-1.11.13/locale/en.js']
          },
          {
            name: 'lodash',
            var: '_',
            path: 'https://.../lodash@4.17.21.js',
            alias: ['lodash-es']
          },
          {
            name: 'ant-design-vue',
            var: 'antd',
            path: 'https://.../ant-design-vue@3.2.3.js'
          }
        ]
      })
    ],
    resolve: {
      alias: {
        // @ts-ignore
        '@': path.resolve(__dirname, 'src')
      }
    },
    // 注入开发环境 CDN
    optimizeDeps: {
      // exclude: ['@wangeditor/editor'], // 不进行依赖预构建
    },
    css: {
      preprocessorOptions: {
        less: {
          modifyVars: {
            hack: `true; @import "${path.join(__dirname, './src/assets/style/var.less')}"`
            // antd 自定义主题 https://www.antdv.com/docs/vue/customize-theme-cn
          },
          additionalData: '@import "@/assets/style/common.less";',
          javascriptEnabled: true
        }
      }
    },
    server: {
      host: '0.0.0.0',
      port: 8080,
      open: false,
      https: false,
      proxy: {}
    },
    build: {
      minify: 'terser',
      terserOptions: {
        compress: {
          /* 清除console */
          drop_console: VITE_APP_ENV === 'production',
          /* 清除debugger */
          drop_debugger: VITE_APP_ENV === 'production'
        }
      },
      rollupOptions: {
        plugins: [
          // 判断是否使用
          VITE_look_visualizer && visualizer({
            open: true, // 是否在打包完成后自动打开生成的报告文件(默认为 `false`)
            filename: 'dist/stats.html', // 指定报告文件的输出路径(默认为 `stats.html`)
            gzipSize: true, // 是否显示 Gzipped 文件的大小(默认为 `false`)
            brotliSize: true // 是否显示 Brotli 压缩后的文件大小(默认为 `false`)
          }),
          // 此处是笔者自定义的一个插件 可忽略
          versionUpdatePlugin({
            version: CurrentTimeVersion
          }),
          // gzip压缩 生产环境生成 .gz 文件
          viteCompression({
            verbose: true,
            disable: false,
            threshold: 10240,
            algorithm: 'gzip',
            ext: '.gz'
          })
        ],
        output: {
          chunkFileNames: 'static/js/[name]-[hash].js',
          entryFileNames: 'static/js/[name]-[hash].js',
          assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
          manualChunks(id) {
            //静态资源分拆打包
            if (id.includes('node_modules')) {
              return id.toString().split('node_modules/')[1].split('/')[0].toString()
            }
          }
        }
      },
      sourcemap: mode === 'production'
    }
  }
})

在正式讲解具体内容之前,我们先讨论一个问题,项目使用插件越多,全局引用npm包较多的情况下,是否与笔者一样遇到过vite开发环境首次加载越来越慢的情况?如果有同感,请各位看在vite.config.ts文件中,是否有区别顶层plugins 与 build.rollupOptions.plugins

顶层plugins 与 build.rollupOptions.plugins 的区别

两者区别对比:

特性plugins(顶级)build.plugins(构建)
适用范围开发(dev)和构建(build仅构建(build
调用时机整个生命周期仅生产构建阶段
底层实现Vite 和 Rollup 插件均可用仅限 Rollup 插件
使用场景通用插件,如 Vue、React 支持等构建优化,如压缩、分析等
插件类型支持Vite 插件、Rollup 插件Rollup 插件
实用场景举例

开发和生产都需要的插件

放在 plugins 顶层,确保开发和生产环境一致。

import vue from '@vitejs/plugin-vue';
import { viteExternalsPlugin } from 'vite-plugin-externals';
export default defineConfig({
    plugins: [
        vue(),
        viteExternalsPlugin({
        '@wangeditor/editor': 'wangEditor',
        'echarts': 'echarts'
        })
    ] // 支持开发和构建
});

仅构建阶段的插件

比如代码压缩、构建分析等,可以放在 build.rollupOptions.plugins

import { visualizer } from 'rollup-plugin-visualizer';
import viteCompression from 'vite-plugin-compression'
export default defineConfig({
    build: {
        rollupOptions:{
            plugins: [
              visualizer({ open: true }), // 构建后生成打包分析报告
                // gzip压缩 生产环境生成 .gz 文件
              viteCompression({
                verbose: true,
                disable: false,
                threshold: 10240,
                algorithm: 'gzip',
                ext: '.gz'
              })
            ],
        }
    }
})

对console及debug的清除

案例所在项目直接对打包环境清除,但实际上会存在环境上的区分使用,在生产环境清除,其他环境保留

build: {
  minify: 'terser',
  terserOptions: {
    compress: {
      /* 清除console */
      drop_console: VITE_APP_ENV === 'production',
      /* 清除debugger */
      drop_debugger: VITE_APP_ENV === 'production'
    }
  }
}

打包分析

使用 rollup-plugin-visualizer 分析构建包

image.png 从构建分析中可以看出,比较大的包基本上都是node_modules下的文件,而且构建出的包体积比较大,不利于网页加载使用及资源分析;

拆分包rollupOptions.output.manualChunks

export default defineConfig({
    build: {
        rollupOptions:{
            manualChunks(id) {
              //静态资源分拆打包
              if (id.includes('node_modules')) {
                 return id.toString().split('node_modules/')[1].split('/')[0].toString()
              }
            }
        }
    }
})

再看构建结果 image.png 从图中可以看出,比较大的体积包有ant-design-vue必须全部使用CDN、echarts@wangeditor/editorzrenderlodash,不会变更版本的静态资源包Vue3vue-routerPinia、、axios;

结合案例项目,最终分析出以下几点:

  1. echarts包体积大,且只在个别页面引入,但echarts确在全局引入,虽然是使用了按需引入,但仍旧会在首次加载时全局加载 echarts* 的 js 包;
  2. zrender 项目中未直接使用,但是 echarts 中有依赖 zrender
  3. @wangeditor/editor 包体积大,但也是只在个别页面中有使用
  4. lodash 体积大,虽然项目中有使用模块化技术(lodash/cloneDeep)(import { cloneDeep } from 'lodash-es')等进行按需引入,但使用的地方较多,且三方组件依赖lodash的使用方式也有问题,每个人的使用方式不一样,导致实际效果大打折扣,依旧被全量打包
  5. Vue3vue-routerPiniaaxios ...等静态资源均是项目全局依赖资源

性能优化的思考

针对以上五点特性,笔者有以下几种思考:

  • 静态资源使用插件vite-plugin-cdn-import排除打包使用普通外链/CDN加速
    • 可以使用CDN托管(尽量使用可控的CDN资源)
    • 没有CDN资源可以直接下载资源到本地,进行外链
  • 静态资源使用vite的dll打包插件,对需要提取的资源进行打包
    • 没有CDN资源的时候可以使用此类方式
  • echarts@wangeditor/editor 使用外链CDN,全局引入有可能一直未使用到,比较浪费资源
    • 使用插件vite-plugin-externals ,开发及构建均对 echarts@wangeditor/editor 进行映射与排除,组件内部进行外链引入

@wangeditor/editorecharts 的优化代码

// vite.config.js 开发&& 生产构建排除相关打包
import vue from '@vitejs/plugin-vue';
import { viteExternalsPlugin } from 'vite-plugin-externals';
export default defineConfig({
plugins: [
vue(),
viteExternalsPlugin({
'@wangeditor/editor': 'wangEditor',
'echarts': 'echarts'
})
] // 支持开发和构建
});

// wangeditor/editor vue组件内部使用
const loadStyle = (url: string) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
document.head.appendChild(link);
};
loadStyle('https://XXX.com/transportation/vue3/wangeditor/style.css')
import 'https://XXX.com/transportation/vue3/wangeditor/index.js'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'

// echarts 组件内使用
import 'https://XXX.com/transportation/vue3/echarts/@5.4.3/echarts.js'
import * as Echarts from 'echarts';
let myChart = undefined as any
// echarts初始化
onMounted(() => {
  myChart = Echarts.getInstanceByDom(container.value)
  !myChart && (myChart = (Echarts as any).init(container.value))
  myChart?.showLoading()
  // 执行主要echart流程 setOption
  drawChart()
  // 根据页面大小自动响应图表大小
  window.addEventListener('resize', function () {
    myChart?.resize()
  })
})

优化问题集锦

踩坑1

Vue3vue-routerPiniaant-design-vue 必须全部使用CDN进行托管与排除构建,否则会报错vue未定义之类的错误

踩坑2

Pinia 有依赖vue-demi,so,vue-demi 也需要进行CDN引入与排除构建,否则会出现错误提醒:

Uncaught ReferenceError: VueDemi is not definedat pinia.iife.prod.js:7:2854

踩坑3

vue使用cdn引入后,依旧有@vue相关的依赖被打包,以为是未成功,但实际是有npm包对@vue模块的依赖,且实际打包资源较小,最后未关注

踩坑4

lodash排除后仍旧有loadsh-es及lodash相关依赖构建产物

image.png

image.png 从打包产物中分析,lodash文件里面是对lodash的模块映射,lodash-es文件不应该出现才正确,毕竟已经引入了lodash的js;查看lodash-es的文件源码,分析出里面是对lodash-es相关模块的lodash映射,此时可分析出lodash-es未映射到lodash使用; 初步解决方案是使用rollup-plugin-external-globals 插件对lodash-es做映射,然后结合rollupOptionsexternal 排除功能做处理;构建后lodash-es的打包产物消失,总的打包体积减小

import externalGlobals from 'rollup-plugin-external-globals';
const globals = externalGlobals({
  'lodash-es': 'lodash'
});
export default defineConfig({ 
    build: { rollupOptions: { external: ['lodash-es'], plugins: [globals] } 
} })

进一步优化vite-plugin-externals的源码分析,插件内部的实际实现也是是使用rollup-plugin-external-globals 插件对lodash-es做映射,然后结合rollupOptionsexternal 排除功能做处理,且有配置alias做多映射,经过修改后的lodash的cdn使用为

{
  name: 'lodash',
  var: '_',
  path: 'https://XXX.com/transportation/vue3/lodash@4.17.21.js',
  alias: ['lodash-es']
}

动手构建后,结果比较nice, lodash-es的相关产物消失

踩坑5

使用CDN时,组件内部有多个顶层dom,子组件使用:deep导致页面跳转时空白,此类问题不是所有组件都出现的,问题比较坑,至今也未排查出原因,**供参考

  • 组件使用div进行外层包裹,问题解决

踩坑6

组件内部有多个顶层dom,子组件使用:deep导致页面样式失效

  • 样式放在父组件进行deep,问题解决

踩坑7

ant-design-vue时间组件是依赖dayjs, lodash 与 dayjs 使用后的i18n国际化冲突,导致ant-design-vue的时间组件国际化出现问题;分析原因是dayjs引入的国际化相关是单独引入的,未被dayjs打包排除,但使用过程中与lodash的国际化冲突导致国际化失效

image.png 解决方案: dayjs使用CDN引入对应的语言包,且在构建时排除相应的包

{
name: 'dayjs',
var: 'dayjs',
path: ['https://XXX.com/transportation/vue3/dayjs@1.11.13.js', 
'https://unpkg.com/dayjs@1.8.21/locale/zh-cn.js', 
'https://unpkg.com/dayjs@1.8.21/locale/ms-my.js', 
'https://unpkg.com/dayjs@1.8.21/locale/en.js']
},

export default defineConfig({ 
    build: {
    rollupOptions: {
        external: [
            'dayjs/locale/zh-cn.js', 
            'dayjs/locale/ms-my.js', 
            'dayjs/locale/en.js']
            } 
        }
    })
  
//注意最开始的设想是开发环境引入,生产环境生效,但是构建后依旧会有对应的构建包出现;尽量避免此类写法

const { VITE_APP_ENV } = import.meta.env
if (VITE_APP_ENV === 'development') {
import('dayjs/locale/zh-cn.js')
import('dayjs/locale/vi.js')
import('dayjs/locale/en.js')
}

解决后效果,且构建结果中未出现dayjs相关的资源包 image.png

踩坑8

echarts 进行异步CDN引入后,有出现两个问题

  • 样式效果与开发环境的效果不一致
  • 访问页面未渲染,报错提示echarts实例已存在

分析原因一:优化前代码echarts是按需引入,再全局进行引入项目中;使用cdn之后引用的是完整的js文件,存在配置存在但实际因为按需引入而导致样式未生效的问题; 分析原因二:echarts实例已存在,重复echarts.init同一个demo的报错

解决方案:1.根据已有的进行样式调整;2.echarts.init前判断实例是否已存在,避免重复init

踩坑9

@wangeditor/editor-for-vue 最开始的设想是同 @wangeditor/editor 一样使用cdn,但实际效果未能实现,且构建包的体积较小,就未做深入研究;此处记录下,供参考

持续优化设想

  • 从构建的产物中,有ant-design-vue的css包比较大,但是项目中使用时使用了定制化的less变量,此点也可以做一个未来的优化点
  • 其他的有第三方组件库,也可以跟echarts一样作为外部js引入