vite构建-优化篇

83 阅读17分钟

项目优化的三个方向

概念:我们在项目构建过程中,或多或少都会遇到优化方面的一些问题。这个问题无关于构建工具,不管是webpack还是viterollup,我们想要优化一个项目,可以从三个层面出发:代码编写层面网络传输层面资源体积层面。三个层面也并不是独立的,三者之间是有依存关系的。例如:想要开启tree shaking,去除项目中的无用代码,做到减小项目体积的优化,在代码编写层面就需要使用具名导入来支持这一功能的开启。另外,具体使用什么优化手段,是要根据场景来的,而不是一味的为了优化而优化。

1.代码层面的优化

  • 代码层面的优化太多了,具体要看项目中的实际操作来实际应用,这块我单独挑出一些常用的加以介绍

1.1 摇树优化(tree shaking)

  • vite默认支持tree shaking,但要确保使用 ES Module 格式的具名导入,并且模块本身无副作用,才能有效剔除未使用的代码。tree shaking实现去除无用代码的底层原理就是基于esModule导入的静态关系确立,所以,不管是自己写的模块还是第三方库,tree shaking只支持es模块。
// 导入整个lodash包
// import * as lodash from 'lodash-es';
// 仅仅导入debounce方法
import {debounce} from 'lodash-es';
​
// package.json
{
  "sideEffects": false
}

1.2 帧动画

  • 项目中,除了css动画,还有部分js动画,这些动画,可能由于某种原因,触发的频率太高,会影响浏览器的性能,这个时候可以考虑使用帧动画来降低触发频率,或者模拟帧动画的模式来降低触发频率。
  • 场景一:使用js实现一个改变div元素的宽高动画,我们可以尝试使用帧动画实现
// 使用requestAnimationFrame来实现div的宽度、高度变化,达到动画的效果
let wGap = 10;
let hGap = 5;
let animationFrameId;
let shouldUpdate = true
const animation = () => {
    animationFrameId = requestAnimationFrame(() => {
      if (!shouldUpdate) {
        closeAnimation()
          return
      }
      const div = document.getElementById("test");
      const rect = div.getBoundingClientRect();
      if (rect.width === 500) {
         wGap = -10;
         hGap = -5;
      } else if (rect.width === 0) {
         wGap = 10;
         hGap = 5;
      }
      div.style.width = rect.width + wGap + "px";
      div.style.height = rect.height + hGap + "px";
      animation();
   });
};
function closeAnimation () {
    shouldUpdate = false;
    cancelAnimationFrame(animationFrameId)
}
animation();
  • 场景二:在一些高频事件中,例如:滚动事件,触发回调调用的频率非常高,这个时候,可以使用js模拟帧动画来降低回调内代码块调用的频率,这里用到的主要思路就是节流函数。
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .container {
        width: 100%;
        height: 100vh;
        overflow: auto;
      }
      .content {
        width: 100%;
        height: 100%;
        background-color: blue;
        word-wrap: break-word;
      }
      .content2 {
        width: 100%;
        height: 100%;
        background-color: red;
        word-wrap: break-word;
      }
    </style>
  </head>
  <body>
    <div id="test"></div>
    <div class="container">
      <div class="content">
      </div>
      <div class="content2">
      </div>
    </div>
    <script>
      const con = document.querySelector(".container");
      const content = document.querySelector(".content");
      const createTimeUtil = (fpsLimit = 60) => {
        let lastFrameTime = 0;
        return {
          // 模拟浏览器的帧率,1秒60FPS,判断当前触发时机是否在模拟帧内
          isInCurrentFrame() {
            const now = this.getTimestamp();
            if (now - lastFrameTime < 1000 / fpsLimit) return true;
            lastFrameTime = now;
            return false;
          },
          // 获取当前触发时机的时间戳
          getTimestamp() {
            if (
              typeof performance !== "undefined" &&
              typeof performance.now === "function"
            ) {
              return performance.now();
            }
            // 降级到Date对象(精度到毫秒)
            console.warn("当前环境不支持高精度时间戳,使用Date.now()降级方案");
            return Date.now();
          },
        };
      };
      const timeUtil = createTimeUtil();
      con.addEventListener("scroll", () => {
        // 判断触发时机是否在模拟帧内,如果在,则跳过该帧,下一帧再触发回调
        if (timeUtil.isInCurrentFrame()) return
        console.log("scroll");
      });
    </script>
  </body>
</html>

1.3策略模式

  • 在代码编写中,可能会遇到多if分支的情况,这种情况下,会降低代码的可读性,增加维护成本,最重要的是,会影响浏览器性能,并且代码的可扩展性也比较差。这个时候可以考虑策略模式,减少if分支

场景一:根据具体的值,来判断走什么分支,这种情况最易更改成策略模式

function three() {
  console.log("three");
  return "three";
}
​
function test(val) {
  if (val === "1") {
    return "one";
  } else if (val === "2") {
    return "two";
  } else if (val === "3") {
    return three();
  } else if (val === "4") {
    return "four";
  } else if (val === "5") {
    return "five";
  } else {
    return "value not found";
  }
}
//
function strategy(val) {
  // 此处使用对象形式,根据不同的key,匹配不同的值,值也可以是函数
  const values = {
    1: "one",
    2: "two",
    3: three,
    4: "four",
    5: "five",
  };
  const res = values[val];
  const flag = typeof res === "function";
  return flag ? res() : res || "value not found";
}
​
console.log(strategy("3"));
console.log(test("3"));

场景二:根据判断来走具体的if分支,可以将判断部分抽离出去,减小单个函数内的if分支复杂度

function test(val) {
  if (val > 0 && val < 10) {
    if (val <= 5) {
      console.log("small min");
      return "small min";
    }
    console.log("small");
    return "small";
  } else if (val >= 10 && val < 100) {
    if (val > 90) {
      console.log("medium max");
      return "medium max";
    }
    console.log("medium");
    return "medium";
  } else if (val >= 100 && val < 1000) {
    if (val > 500 && val <= 800) {
      console.log("large max");
      return "large max";
    }
    if (val > 800) {
      console.log("huge");
      return "huge";
    }
    console.log("large");
    return "large";
  } else {
    console.log("value not found");
    return "value not found";
  }
}
// 将首层判断抽离出来,降低strategy函数的if复杂度
function getStrategy(val) {
  if (val > 0 && val < 10) {
    return "0";
  } else if (val >= 10 && val < 100) {
    return "1";
  } else if (val >= 100 && val < 1000) {
    return "2";
  } else {
    return "3";
  }
}
​
function strategy(val) {
  // 其他逻辑抽离成单独的函数,对于抽离的函数而已,if复杂度很低
  // 如果抽离的函数还存在多层嵌套的if分支,还可以利用该方法对抽离函数进行进一步if复杂度降低
  const strategies = {
    0: (val) => {
      if (val <= 5) {
        console.log("small min");
        return "small min";
      }
      console.log("small");
      return "small";
    },
    1: (val) => {
      if (val > 90) {
        console.log("medium max");
        return "medium max";
      }
      console.log("medium");
      return "medium";
    },
    2: (val) => {
      if (val > 500 && val <= 800) {
        console.log("large max");
        return "large max";
      }
      if (val > 800) {
        console.log("huge");
        return "huge";
      }
      console.log("large");
      return "large";
    },
    3: () => {
      console.log("value not found");
      return "value not found";
    },
  };
  return strategies[getStrategy(val)](val);
}
test(800)
strategy(800);

以上提供的场景并不能囊括所有场景,重要的是,提供策略模式这种思考方式。当然,具体的策略模式代码如何编写,还需要根据具体代码来分析。

1.4 vue相关优化

  • 关于vue的相关优化,比较多,这里就不一一列举了,我列举几个典型常用的例子。
1.4.1 vue2中,Object.freeze的使用

场景一:在vue2中,有部分data中定义的数据,并不需要响应式,这个时候添加响应式,反而加大了vue2的开销,影响浏览器的性能,这个时候,可以使用Object.freeze来将这部分数据冻结(一般针对的是对象),这样,该数据下的所有属性都不会添加响应式了。

注意: Object.freeze只能冻结浅层对象,无法进行深层次冻结,因此,可以实现一个辅助函数,来递归冻结深层次对象

注意: 使用 Object.freeze() 后,该对象不再具有响应性,适合静态配置、只读展示,不能与双向绑定结合使用。

function deepFreeze(obj) {
  Object.keys(obj).forEach(key => {
    if (typeof obj[key] === 'object' && obj[key] !== null) {
      deepFreeze(obj[key]);
    }
  });
  return Object.freeze(obj);
}
export default {
    data() {
        return {
            UI: deepFreeze({
                title: 'xxx',
                titleColor: 'red',
                subTitle: {
                    title: 'xxxxx',
                    color: 'blue'
                },
                ......
            }) 
        }
    }
}

场景二:vue2项目,对于解救返回的数据,如果仅作展示作用,而无修改的话,也可以使用Object.freeze将其冻结,例如:商品信息

export default {
    data() {
        return {
            goods: []
        }
    },
    methods: {
        getGoodList() {
            fetchGoodList(type).then(res => {
                if (res.data.code === '0') {
                    // 商品的渲染,并无数据要更改,仅作展示,无需添加为响应式数据
                    this.goods = res.data.data.map(item => deepFreeze(item))
                }
            })
        }
    }
}

所以,对于仅作展示的数据,可以使用这种方法,减少不必要的响应式开销

1.4.2 effectScope
  • vue3中,我们使用的hook,大多数对其副作用的范围并未做限制,这使得,明明其他组件并未使用这个hook,但是hook的开销依旧在,由此,vue团队推出了一个方法effectScope,官方对的介绍是:创建一个 effect 作用域,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理。也就是说,对于hook中的产生的响应式副作用,该方法可以追踪到,再搭配onScopeDisposegetCurrentScope这两个钩子,就可以实现对hook副作用的追踪、开启和关闭
  • 多组件使用相同 composable 时,若不加限制可能造成未使用的副作用残留,effectScope 可帮助追踪并清理这些副作用。
import { effectScope, onScopeDispose, getCurrentScope } from 'vue'
import type { EffectScope } from 'vue'
type AnyFn = (...args: any[]) => any
export type Fn = () => void
export function tryOnScopeDispose(fn: Fn) {
  if (getCurrentScope()) {
    // 如果当前有作用域,则注册onScopeDispose,节省内存空间
    // console.log('tryOnScopeDispose', getCurrentScope())
    onScopeDispose(fn)
    return true
  }
  return false
}
// 创建一个共享的composable,当有多个地方使用时,只会创建一次实例,类似react的useMemo
// createSharedComposable 包装一个 composable 时,无论你多少次调用这个 composable,都会返回同一个实例,而不会重新执行这个 composable。
export function createSharedComposable<Fn extends AnyFn>(composable: Fn): Fn {
  let subscribers = 0
  let state: ReturnType<Fn> | undefined
  let scope: EffectScope | undefined

  const dispose = () => {
    subscribers -= 1
    if (scope && subscribers <= 0) {
      scope.stop()
      state = undefined
      scope = undefined
    }
  }

  return <Fn>((...args) => {
    subscribers += 1
    if (!scope) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    // console.log('createSharedComposable', state)
    tryOnScopeDispose(dispose)
    return state
  })
}


// hook
import { ref } from 'vue'
const useTest = createSharedComposable(() => {
    const a = ref(0)
    
    const increment = () => {
        a.value += 1
    }
    
    return {
        a,
        increment
    }
})

关于vue方面的优化,我就不大篇幅去举例,该篇文档的重心在vite这块。

2.网络传输方面优化

2.1路由懒加载

  • 在前端项目中,不管是react还是vue,路由懒加载都是针对网络传输这块必上的手段,特别是首屏这块。首屏白屏问题可以从两个方面来讲:1.传输的文件体积太大,传输时长太长;2.js代码执行比较耗时。那么,在针对第一个维度的问题时,路由懒加载具有优势。为什么这么说,因为,我们编写的路由列表,其中路由对应的页面组件,使用improt()动态加载的,这在vite构建中,会将所有的页面组件代码拆离出去,形成单独的模块。那么,在首屏时,只需要加载首屏首页对应的页面组件就可以了,减少了首屏时需要传输的代码体积。这里减少的不仅仅是js代码体积,还有未加载的页面组件,还会加载图片等静态资源,这部分文件也在首屏时规避了加载,缩短了传输时长。
const routes = [
  {
    path: "/login",
    name: "login",
    meta: { title: "登录", hidden: true },
    component: () => import("@/views/login/index.vue"),
  },
  {
    path: "/",
    name: "layout",
    component: () => import("@/layout/index.vue"),
    redirect: "/home",
    meta: { title: "layout", hidden: false },
    children: [
      {
        path: "/home",
        name: "home",
        meta: { title: "首页", hidden: false, icon: "House" },
        component: () => import("@/views/home/index.vue"),
      },
    ],
  },
  {
    path: "/404",
    name: "404",
    meta: { title: "404", hidden: true },
    component: () => import("@/views/404/index.vue"),
  },
  // 大屏路由
  {
    path: "/screen",
    name: "Screen",
    meta: { title: "数据大屏", hidden: false, icon: "DataLine" },
    component: () => import("@/views/screen/index.vue"),
  },
  {
    path: "/word",
    name: "Word",
    meta: { title: "word", hidden: false, icon: "Edit" },
    children: [
      {
        path: "/word/canvasWord",
        name: "CanvasWord",
        meta: { title: "canvasWord", hidden: false, icon: "Edit" },
        component: () => import("@/views/word/canvasWord.vue"),
      },
    ],
  },
  {
    path: "/test",
    name: "Test",
    meta: { title: "测试", hidden: false, icon: "Edit" },
    component: () => import("@/views/test/index.vue"),
  },
];

2.3异步组件

  • 异步组件所用到的原理和路由懒加载一样,都是通过import()方式来加载组件的,只不过,相对于页面组件,异步组件的颗粒化更精细,可以在页面组件中,根据条件判断的决定加载与否。这在首屏优化方面有很大的作用
<template>
  <div class="home_wrapper">
     <HomeSetting />
     <HomeUser v-if="flag" />
     <HomeContent v-else />
  </div>
</template>

<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue'
import HomeSetting from './HomeSetting.vue'
const flag = ref(true)
const HomeUser = defineAsyncComponent({
  loader: () => import('@/components/home/HomeUser.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 3000
})
const HomeContent = defineAsyncComponent({
  loader: () => import('@/components/home/HomeContent.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 3000
})
</script>

2.3代码分割

  • 代码分割,在webpackvite 中都比较常用,目的是,将单一的大体量模块抽离成多个小模块。这样做的好处就是,项目初始化记载时,可以根据入口文件,按需加载需要的模块,减少传输文件体积,缩短传输时长。并且,浏览器支持并行请求,被拆离的单独模块还可以并行加载,缩短项目初始化的时长。最重要的是,浏览器有缓存机制,我们可以结合代码分割来做一些缓存上的优化。
  • vite.config.js配置
import { defineConfig } from "vite";
export default defineConfig(() => {
 return {
   build: {
     // 资源预加载polyfill,兼容浏览器版本
      modulePreload: {
        polyfill: true,
      },    
      rollupOptions: {
        input: { // 打包入口
          main: path.resolve(__dirname, "src/main.ts"),
        },
        output: { // 出口
          chunkFileNames: "js/[name]-[hash].js",
          entryFileNames: "js/[name]-[hash].js",
          assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
          // vite构建是通过rollup的manualChunks属性进行代码分割
          // obj形式
          manualChunks: {
            vue: ["vue", "pinia"],
            echarts: ["echarts"],
            axios: ["axios"],
            "vue-router": ["vue-router"],
          }, 
          // 函数形式
          // 使用函数式进行代码分割配置,可能形成模块循环导入的问题
          // 例如: elment-plus会与vue产生循环导入问题
          // 解决方法: 可以将产生循环导入问题的包打包到一起
          manualChunks(id) {
            const needSplit = {
              polyfill: ["core-js"],
              vue: ["vue", "pinia"],
              echarts: ["echarts"],
              axios: ["axios"],
              "vue-router": ["vue-router"],
            }
            const keys = Object.keys(needSplit)
            const values = Object.values(needSplit)
            const index = values.findIndex((item) => item.includes(id))
            if (index !== -1) {
              return keys[index]
            }
          }
        }     
      }
   }    
 }
})

2.4 vite与浏览器缓存机制

浏览器缓存机制,是现代浏览器原生支持的功能,它会缓存一个页面的所有文件资源,在一定时间内(这个时间是浏览器自行定义的,还有一种情况,那就是我们请求数据的接口,后端的请求头中的某个字段定义的),当浏览器二次访问该页面时,会根据请求的文件指纹(一般指文件名称)进行比较,如果没有出入,那么会直接使用缓存中的资源,反之,则会重新加载该资源。

那么,代码分割又如何跟浏览器缓存机制有关联呢?要探讨这个问题,首先,我们要从问题的需求点出发。究竟什么样的情况,我们希望浏览器沿用缓存资源,什么样的情况重新加载资源?是不是当文件内容发生变化后,我们希望浏览器重新加载该文件。那进一步,浏览器无法判断文件内容是否发生变化,它只会比对文件指纹,所以,我们要做的就是告诉浏览器,该文件已经已经变化了,需要加载。

想要做到这一点,就需要vite 对文件命名这块进行配置,在vite文件命名规则配置中,vite默认支持hash规则,也就是说,vite可以根据文件内容来生成对应的hash并加入到命名规则中去。这样,只要文件内容发生变化,其对应的文件指纹必定发生变化,反映到浏览器上,就是该文件发生变化,需要重新加载。

可是,有一个问题,那就是,如果项目中没有配置代码分割的话,整个项目会被打包成一个臃肿的js代块,这个代码块只要有一点变化,浏览器就会重新加载,极其消耗带宽,而且延长了项目初始化的时长。但是,如果配合上代码分割就不一样了,可以将臃肿的代码块拆分抽离,我们改动其中一个模块的内容,浏览器也只会加载该部分资源,其他大部分资源都是走的缓存,大大减小带块和加载时长

(由于这篇文档主要讲解vite 相关的知识点,所以,浏览器的缓存机制就不过多赘述了,感兴趣的可以去网上查找一下相关的内容)

2.5首屏优化

  • 对于项目首屏问题,无非两个维度的问题:1.js传输时长;2.js解析执行耗时。对于传输维度相关的问题,上述的三个方面:路由懒加载、组件懒加载、代码分割也是解决首屏白屏问题的关键处。至于其他的方面,我根据我的项目经验来说一些。
2.5.1 图片懒加载
  • 如果首屏加载的图片资源太多,导致首屏白屏,我们可以尝试先使用体积较小的默认图片先做展示效果,后续再根据视口中出现的img元素去加载对应的图片资源。
2.5.2 cdn加载
  • 对于项目中的一些分包之后,体积仍然很大的包,可以考虑cdn去加载该资源,cdn加载资源,在传输方面有加速作用。当然,cdn服务器有成本,看公司情况。
2.5.3 骨架屏
  • 骨架屏起到的效果其实和图片懒加载一样,在项目初始化时,也就是加载执行首页相关js代码时,先展示出来的界面UI效果,等到首页组件解析完毕,再显示页面组件内容
2.5.4 大体积文件延后加载
  • 对于首屏来说,一些体积较大,并且重要度不是特别高度资源,我们可以编写hook,在页面fcp之后通过import()延后其加载执行。

3.资源体积方面优化

  • 整个项目由不同的文件资源构成,有js文件、css文件、静态资源文件等等,这些文件在体积上还有一定的压缩空间,利于减小整个项目的体积大小。

3.1图片压缩

  • 图片在前端项目中很常见,用的地方也很多。但是,我们在使用图片过程中,或多或少会遇到体积很大的图片,尽管在导入项目之前已经做了压缩处理,但是,体积依旧超过项目所设置的阈值,这个时候可以尝试利用vite压缩图片的一些社区插件来进一步压缩图片体积。
  1. 安装(具体文档地址)

    pnpm add vite-plugin-imagemin -D
    
  2. vite.config.js配置

    import viteImagemin from 'vite-plugin-imagemin'
    
    export default () => {
      return {
        plugins: [
          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,
                },
              ],
            },
          }),
        ],
      }
    }
    

3.2代码压缩

  • 代码压缩这块,vite默认使用esbuild做代码体积压缩,压缩速度很快,压缩体积比率也只比terser低一点。当然,vite也可以指定压缩工具。
  1. 安装

    pnpm add terser -D
    
  2. vite.config.js配置

    export default () => {
      return {
        build: {
          // minify取值为true时,默认使用esbuild做代码压缩
          minify: 'terser',
          terserOptions: {
            compress: {
               // 移除console
               drop_console: viteEnv.VITE_DROP_CONSOLE,
               // 移除debugger
               drop_debugger: viteEnv.VITE_DROP_CONSOLE,
             }
           },
        }
      }
    }
    

3.3 gizp压缩

  • 大部分浏览器支持gzip格式的文件解析,相比较未做gzip压缩的文件来说,经过gzip压缩过后的文件体积,能大幅度减小,特别对一些体积大的模块来说,gzip压缩的效果尤为明显。在项目部署运维这块,nginx自带HttpGzip模块,可以帮我们把前端的文件做gzip压缩,但是比较消耗服务器带宽。为了应对这一情况,vite 也有相关插件做这方面的处理。
  1. 安装

    pnpm add vite-plugin-compression2 -D
    
  2. vite.config.js配置

    import { compression } from 'vite-plugin-compression2'
    export default () => {
      return {
          plugins: [
             compression({
               //压缩算法,默认gzip
               algorithm: 'brotliCompress',
               //匹配文件
               include: [/.(js)$/, /.(css)$/,],
               //压缩超过此大小的文件,以字节为单位
               // threshold: 10240,
               //是否删除源文件,只保留压缩文件
               // deleteOriginalAssets: true
             }),
          ]
      }
    }
    

3.4 CDN加载

  • 在项目中,有一些三方包体积在整个项目中的占比过大,例如excharts,极大加剧了的网络传输的时间成本,这个时候可以考虑cdn加载来解决这个问题。cdn加载会加速文件的传输,减小项目体积。但是,也有弊端,如果使用的cdn链接不是公司私域链接,而使用公用免费的cdn链接,这些公用的链接有传输不稳定和js库版本不稳定的安全隐患在里面,所以,尽量使用公司内部的cdn链接。

注意ssr项目不支持cdn加载

  • 使用cdn加载需要vite配置external这个配置项
export default () => {
  return {
    build: {
      // minify取值为true时,默认使用esbuild做代码压缩
      minify: 'terser',
      rollupOptions: {
         external:['echarts']  
      }
    }
  }
}
3.4.1 rollup-plugin-external-globals插件
  • 安装
pnpm add rollup-plugin-external-globals -D
  • 使用
export default () => {
  return {
    build: {
      // minify取值为true时,默认使用esbuild做代码压缩
      minify: 'terser',
      rollupOptions: {
        external:['echarts'],
        plugins: [
          externalGlobals({
            "echarts": "echarts",
          })
        ],
      }
    }
  }
}
  • html模块加上cdn地址
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js" crossorigin="anonymous"></script>

结语

优化是一个系统性过程,不应只追求技巧堆砌,而应结合场景、性能瓶颈、用户体验和业务体量,制定最适合的优化策略。本文只是对Vite项目中的常见优化方式进行总结,欢迎结合实际情况灵活调整。