vue3+vite项目性能优化

1,443 阅读9分钟

1-CDN加速

在项目中引入vite-plugin-cdn-import包即可,然后像下面这样使用

import importToCDN from 'vite-plugin-cdn-import'; // CDN引入

export default function (isBuild) {
  // 开发环境不使用CDN
  if (!isBuild) return importToCDN({ modules: [] });
  return importToCDN({
    modules: [
      // {
      //   name: 'element-plus',
      //   var: 'ElementPlus',
      //   path: 'https://cdn.bootcdn.net/ajax/libs/element-plus/2.3.12/index.full.min.js',
      //   css: 'https://cdn.bootcdn.net/ajax/libs/element-plus/2.3.12/index.min.css'
      // },
    ],
  });
}

这个插件主要是用CDN来加载第三方库的,不适用于数据文件。如果要用CDN来加载数据文件,可以直接写在index.html中。

建议用来加载一些大图片/字体文件/json文件,比如地图json和大logo等,这些都是不会轻易改的,非常适合用cdn加载,大大提高加载速度。对于地图加载,尝试了很多方法,就是加载很慢,导致性能评分很低,后面使用cdn加载json文件,然后使用webWorker来加载json文件,不会占用主线程渲染,提高性能。同时,建议把地图中的动画全部关闭,以及使用地图中的setFiteView方法时一定要带上地址,不然会非常卡。

像这样使用:

// shader.js
self.onmessage = function () {
  // 创建一个数组,包含所有的fetch请求
  const fetchRequests = [
    fetch(`${import.meta.env.VITE_BASE}json/szTransform.json`).then((response) => response.json()),
    fetch(`${import.meta.env.VITE_BASE}json/szAreaTransform.json`).then((response) => response.json()),
  ];

  // 使用Promise.all处理所有的fetch请求
  Promise.all(fetchRequests)
    .then((results) => {
      // 将所有请求的结果发送给主线程
      self.postMessage(results);
    })
    .catch((error) => {
      // 处理错误
      console.error('Fetch请求出错:', error);
    });
};


// 在index.vue中使用shader.js
import Worker from './shader.js?worker';
const worker = new Worker();
// 监听Web Worker发送的消息
worker.onmessage = (event) => {
  const message = event.data;
  polygons.sz = message.flat();
};

// 向Web Worker发送消息
worker.postMessage('开始处理');

建议是json文件大于100KB再使用,太小了使用webworker也没啥效果。

2-按需加载

一些常用的ui库或者lodash库都可以实现按需加载,不必全局引入,比如使用element-ui,可以像下面这样使用:

import AutoImport from 'unplugin-auto-import/vite'; // 自动引入api
import Components from 'unplugin-vue-components/vite'; // 自动引入组件
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; // ElementPlusResolver 去看文档 里面有介绍有那些ui库可以直接使用  直接看官网也有

export default function () {
  return [
    AutoImport({
      dts: false,
      imports: ['vue', 'pinia', 'vue-router', '@vueuse/core'],
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      dts: false,
      extensions: ['vue'],
      include: [/\.vue$/],
      dirs: ['src/components/'],
      resolvers: [ElementPlusResolver()],
    }),
  ];
}

使用时直接拿来就用,不必再写引入语句了。

3-Gzip压缩

简单理解就是把代码再次压缩成.gz包,从而大大减少代码体积,提高加载速度,如下使用:

import viteCompression from 'vite-plugin-compression'; // 打包压缩

export default function () {
  return viteCompression({
    verbose: true, // 是否在控制台输出压缩结果
    disable: false, // 是否禁用
    // filter:()=>{}, // 过滤哪些资源不压缩 RegExp or (file: string) => boolean
    threshold: 1024 * 50, // 体积大于50KB才会被压缩,单位 b
    deleteOriginFile: false, // 压缩后是否删除源文件
    algorithm: 'brotliCompress', // 压缩算法 推荐使用brotliCompress 比gzip的压缩效率高15%-20%
    ext: '.gz', // 生成的压缩包后缀
  });
}

打包后可以看到会出多.gz后缀的包,体积非常小。但是这个得配合后端nginx添加配置项目,如下:

//在nginx添加 
http { 
# 开启或者关闭gzip模块(on|off) 
gzip_static on; 
# gzip压缩比,1 压缩比最小处理速度最快,9 压缩比最大但处理最慢(传输快但比较消耗cpu)
gzip_comp_level 2; 
}

4-将svg图转雪碧图

雪碧图的作用还是挺多的,比如减少HTTP请求数量,减小图像文件大小和提高渲染性能。作用还是挺大的,而且用起来也方便。在项目中引入vite-plugin-svg-icons包即可(虽然这个包停止维护了,但是还是挺好用的)。 使用方式如下:

import path from 'path';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

// 将svg图标生成雪碧图
export default function (isBuild) {
  return createSvgIconsPlugin({
    customDomId: '__svg__icons__',
    iconDirs: [path.resolve(process.cwd(), 'src/assets/svgs')], // 指定需要缓存的图标文件夹
    symbolId: 'icon-[dir]-[name]', // 指定symbolId格式
    svgoOptions: isBuild,
  });
}

具体的使用方式可以在github上找到这个包,里面有详细的使用说明,vuereact都有。

5-图片压缩

使用的是这个包vite-plugin-imagemin,但是这个包国内比较难安装,可能需要科学上网才行。使用方式摘至另一个掘友的,代码如下:

import viteImagemin from 'vite-plugin-imagemin'

plugin: [
    viteImagemin({
      gifsicle: {
        optimizationLevel: 7,
        interlaced: false
      },
      optipng: {
        optimizationLevel: 7
      },
      mozjpeg: {
        quality: 20
      },
      pngquant: {
        quality: [0.8, 0.9],
        speed: 4
      },
      svgo: {
        plugins: [
          {
            name: 'removeViewBox'
          },
          {
            name: 'removeEmptyAttrs',
            active: false
          }
        ]
      }
    })
]

因为这个插件一直拉不下来,因此换了种方式。找到一个调用tiny的api方式去压缩文件,脚本如下:

const { statSync, readdirSync } = require('fs');
const { resolve, parse, dirname } = require('path');
const tinify = require('tinify');
tinify.key = ''; // api的key 得自己去申请

const src = resolve(__dirname, 'src/assets/images');
const compress = (path) => {
  const files = readdirSync(path); // 读取文件

  if (files.length) {
    files.forEach((item) => {
      const filePath = resolve(path, item);
      const fileName = parse(filePath).name;
      const dirName = dirname(filePath);

      const stats = statSync(filePath); // 获取文件信息
      if (stats.isDirectory()) {
        // 如果是一个文件夹
        compress(filePath);
      } else {
        // 如果是一个文件
        const ext = filePath.split('.').pop(); // 获取文件扩展名
        if (ext === 'png' || ext === 'jpg' || ext === 'jpeg') {
          const source = tinify.fromFile(filePath);
          const converted = source.convert({ type: ['image/webp'] });
          converted.toFile(`${dirName}/${fileName}.webp`);
        }
      }
    });
  }
};

compress(src);

推荐使用webp图片格式,压缩效率最高,而且现代浏览器也非常支持。相关api的地址可以看这里

当压缩完后,如果不想手动的去替换文件名,那么可以使用下面这个插件

// vite-plugin-image-webp.js
const path = require('path');

export default function () {
  return {
    name: 'vite-plugin-image-webp',
    enforce: 'pre',
    transform(code, id) {
      if (id.includes('/src/')) {
        const images = code.match(/['"]([^'"]+\.(jpe?g|png))['"]/g);

        if (images) {
          for (const image of images) {
            const imagePath = image.slice(1, -1);
            const imageExt = path.extname(imagePath).toLowerCase();

            if (['.jpg', '.jpeg', '.png'].includes(imageExt)) {
              const webpPath = imagePath.replace(imageExt, '.webp');
              code = code.replace(image, `"${webpPath}"`);
            }
          }
        }
      }

      return { code };
    },
  };
}

// 在Index.js 中引入
import ImageWebpPlugin from './vite-plugin-image-webp';
plugins: [vitePluginReplaceImageExtension()]

在打包时执行node compressImg.js && npm run build 即可。这样使用的其他格式图片都会先压缩转换成webp格式然后在生产打包时自动的替换图片格式为webp

6-图片加载

这个懒加载的话,有一些现有的很好用的包比如vue-lazyload或者lozad.js都可以。这里我使用的是自定义的指令来实现图片懒加载,更加贴合实际项目需求,代码如下:

import { useIntersectionObserver } from '@vueuse/core';  // 判断当前元素是否在可视范围内
import defaultImg from '../assets/images/loading.svg'; // 加载时svg
import errorImg from '../assets/images/error-image.png'; // 加载错误时的图片
import noImg from '../assets/images/no-image.png'; // 没有图片时的默认图片
import api from '@/api';

const isImage = (url) => {
  return ['data:image','.jpg','.png','.jpeg','.gif','.bmp','.pic','.svg'].find((item) => url.includes(item));
};

const isVideo = (url) => {
  return ['.avi', '.wmv', '.mpeg', '.mkv', '.mov', '.rmvb', '.rm', '.flv', '.mp4', '.3gp'].find((item) => url.includes(item));
};

const videoFrame = (url,{ width = 100,height = 100,time = 1 } = {}) => {
  return new Promise((resolve) => {
    const oVideo = document.createElement('video');
    oVideo.setAttribute('src',url);
    oVideo.setAttribute('controls','controls');
    oVideo.currentTime = time; // 设置当前视频为 第1s
    oVideo.crossOrigin = 'Anonymous';
    oVideo.style.display = 'none';
    document.body.appendChild(oVideo);
    oVideo.addEventListener('canplay',function () {
      const oCanvas = document.createElement('canvas');
      oCanvas.width = width;
      oCanvas.height = height;
      oCanvas.getContext('2d').drawImage(oVideo,0,0,oCanvas.width,oCanvas.height); // 绘制图片
      oCanvas.style.display = 'none';
      document.body.appendChild(oCanvas);
      const url = oCanvas.toDataURL('image/png'); // base64格式
      document.body.removeChild(oVideo);
      document.body.removeChild(oCanvas);
      resolve(url);
    });
    oVideo.onerror = () => {
      document.body.removeChild(oVideo);
    };
  });
};

const getImgUrl = async (item) => {
  if (!item) return '';
  if (item.filePath) return item.filePath;
  const url = await api.get(
    'api',
    { '备用字段': item.serverName }
  );
  return url;
};

// 获取图片地址  当src上没有传值时,则从绑定的对象上获取filePath,如果没有filePath,则从绑定的对象上获取备用字段,再通过接口来获取  三重保险
const getPreviewImg = async (src,{ value }) => {
  if (!src && !value) return '';
  const url = src || (await getImgUrl(value[0]));
  const str = url.toLowerCase();
  if (isImage(str)) return url;
  if (isVideo(str)) {
    const src = await videoFrame(url);
    return src;
  }
  return noImg;
};

export default {
  name: 'lazyLoad',
  mounted(el,binding) {
    if (!['IMG'].includes(el.nodeName)) return;
    const { src } = el;
    el.src = defaultImg;
    const { stop } = useIntersectionObserver(el,([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.onerror = () => {
          el.src = errorImg;
          el.onerror = null;
        };
        getPreviewImg(src,binding).then((url) => {
          el.src = url;
        });
        stop();
      }
    });
  },
};

如果不使用懒加载,也可以用两张图片来展示,一张是默认显示的1Kb大小的模糊图,一张是正常大小的图片。首先展示1Kb的图片,然后再用户点击或者图片处于可视范围内再进行加载正常大小的图片。

7-页面缓存

<template>
  <RouterView>
    <template #default="{ Component }">
      <div
        v-tloading="store.getPageLoading"
        loading-tip="加载中.."
      >
        <keep-alive :include="/.*content/">
          <component :is="Component" />
        </keep-alive>
      </div>
    </template>
  </RouterView>
</template>

这里有两个优化,第一个是当页面正在加载中时,会显示loading效果,在页面加载完成后loading效果去除。第二个是缓存页面中导出名字包含content字段的页面,同时需要时可以配合pinia实现数据持久化

8-数据缓存

比如常见的后台页面,主要由查询框和表格组成,查询框的下拉数据过多,此时可以将这部分数据缓存到本地使用,具体方法如下:

// 在发起请求时判断是否有缓存,有则直接返回;没有则发起请求并将结果缓存
 reauest(config,options) {
  const cacheData = this.getCache(config);
  if (cacheData) return cacheData;
  const res = await this.requestxx(config);
  this.setCache(config, res);
  return res;
}

const responseMap = new Map(); // 返回结果缓存池
this.generateReqKey(config); // 用来将config转换成唯一的key

// 获取缓存数据
getCache(config) {
  // 2、没取到返回null
  const requestKey = this.generateReqKey(config);
  const data = responseMap.get(requestKey) || null;
  if (!data) return null;
  // 3、取到判断过期状态,如果过期清除缓存返回null,否则返回缓存数据
  if (data.time < Date.now()) {
    responseMap.delete(requestKey);
    return null;
  }
  return data.data;
}

// 设置缓存
setCache(config,data,cacheTime) {
  if (cacheTime) {
    const requestKey = this.generateReqKey(config);
    responseMap.set(requestKey,{ time: Date.now() + cacheTime,data });
  }
}

// 使用
export async function getDict(params) {
  const res = await api.get('xx',{...params },{ cacheTime: 1000 * 60 * 60 * 24 });
  return res;
}

注意,这里的缓存不是放在localStorage里的,所以当页面强制刷新时还是会重复调用。这里缓存的意义是在页面不刷新的情况下缓存,页面的查询框数据不变,其他部分改变。结合业务需求,也可以做成localStorage缓存并设置过期时间。

9-ssr

目前还不会,后续加上

10-DNS预解析

浏览器在向跨域的服务器发送请求时,首先会进行 DNS 解析,将服务器域名解析为对应的 IP 地址。我们通过 dns-prefetch 技术将这一过程提前,降低 DNS 解析的延迟时间,具体使用方式如下:

<!-- href 为需要预解析的域名 -->
<link rel="dns-prefetch" href="https://fonts.googleapis.com/"> 

一般情况下 dns-prefetch会与preconnect 搭配使用,前者用来解析 DNS,而后者用来会建立与服务器的连接,建立 TCP 通道及进行 TLS 握手,进一步降低请求延迟。使用方式如下所示:

<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin>
<link rel="dns-prefetch" href="https://fonts.gstatic.com/">

值得注意的是,对于 preconnect 的 link 标签一般需要加上 crorssorigin(跨域标识),否则对于一些字体资源 preconnect 会失效。

11-字体使用

尽量使用woff2格式字体,可以看到字体文件大小差距还是蛮大的:

image.png

@font-face {
  font-family: 'YouSheBiaoTiHei';
  src: url('@/assets/font/YouSheBiaoTiHei.woff2');
  font-display: swap;
}

font-family: PangMenZhengDao, sans-serif;  // 一定要加上后备字体

在引入字体时加上font-display:swap,这样浏览器在加载时会优先使用系统自带字体,等到自定义字体加载好后再替换系统自带字体。好处就是不必等到自定义字体加载好后再显示字,在浏览器加载的第一时间就会显示出字。

12-组件拆分

在vue页面中只要使用了import,那么在打包时就会自动的把这些给打包到一个页面里,但其实有些是不一定会用到的,比如弹窗。只有真的点击了弹窗后才会显示弹窗,但是用户不一定会点击到,所以如果第一时间就加载好,其实有些浪费,可以使用异步组件来将这些弹窗组件全都使用异步加载,大大缩减页面体积,提升加载速度。

比如像下面这种:

  <!-- 以下为弹窗 -->
  <component
    :is="dialog.component"
    v-for="dialog in dialogs"
    :key="dialog.id"
    v-bind="dialog.props"
    @close="closeDialog(dialog.id)"
    @filter="camera.filterCamera"
    @node-click="camera.onMarkerClick"
    @submit="onSubmit"
    @video-call="onVideoCall"
  />
  
const MapDialog = defineAsyncComponent(() => import('./components/MapDialog.vue'));
const HomeDialog = defineAsyncComponent(() => import('@/components/home/HomeDialog.vue'));

这样使用打包时就会将这些组件分包打包,减少主页面的包体积,加快主页面的渲染速度。(使用之后性能提升最明显)

13-请求数量

控制页面中请求数量,意思是只发送那些页面一开始就必须要显示的内容的请求,其他的一律等到需要时再发送。比如某个点击效果要查看另外一些内容,那么这些另外内容就不需要在页面加载时就进行请求,可以等到真正点击后再去发送,然后缓存下来。非必要不请求

14-dom中元素数量

这个也是一个非常重要的影响因素,当页面中元素数量过多,就比如我优化前的一个页面有1300多个,性能评分就爆红,显示元素过多影响渲染速度。然后通过异步组件拆分出去一部分,再优化了写法,最后还剩750个,才勉强通过。这里就比较考验技术了,我实在优化不了了T_T

15-打包配置

这里给出我现在使用的打包配置:

  minify: 'terser',
  brotliSize: false, // 是否显示打包后文件压缩结果,由于vite不支持打包压缩,关闭此功能可以加快打包速度
  chunkSizeWarningLimit: 2000, // 配置打包大于2000KB会发出警告,默认为500KB
  terserOptions: {
    // 在生产环境移出注释
    format: {
      comments: false,
    },
    // 在生产环境移除console.log 和 debugger
    compress: {
      drop_console: true,
      drop_debugger: true,
    },
  },

重点就是在生产环境中去除注释,打印和debug。去除注释是后加上去的,可以看看去除注释前后的体积变化:

image.png

前后差了10KB,还是不错的。所以记得把注释也去掉,反正生产环境也看不到注释。

16-第三方库的打包

这里尚未实践,只是提出一些个人理解,比如element的样式打包,是全部打包在全局文件里,还是每个组件单独打包到一个文件里。为什么会提出这个问题,是因为我在分析入口文件和样式文件时发现大量未使用的代码和样式,特别是这个样式,打包了整个element的所有组件的样式,但在这个页面中压根用不到这么多,这就显得比较浪费了。那是不是说不应该把所有样式打包在一起,而是按需打包,同时把一些关键性的样式依然打包到全局。这里关键性的样式是指主题色和大小等。这样关键性的样式全局使用,而组件样式按需加载打包,就不会出现下面这种96%的样式都没有使用到的情况了,性能也会有所提升了。

image.png

总结

有任何问题欢迎指正,加油!!!