项目优化的三个方向
概念
:我们在项目构建过程中,或多或少都会遇到优化方面的一些问题。这个问题无关于构建工具,不管是webpack
还是vite
、rollup
,我们想要优化一个项目,可以从三个层面出发:代码编写层面
、网络传输层面
和资源体积层面
。三个层面也并不是独立的,三者之间是有依存关系的。例如:想要开启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
中的产生的响应式副作用,该方法可以追踪到,再搭配onScopeDispose
、getCurrentScope
这两个钩子,就可以实现对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代码分割
- 代码分割,在
webpack
和vite
中都比较常用,目的是,将单一的大体量模块抽离成多个小模块。这样做的好处就是,项目初始化记载时,可以根据入口文件,按需加载需要的模块,减少传输文件体积,缩短传输时长。并且,浏览器支持并行请求,被拆离的单独模块还可以并行加载,缩短项目初始化的时长。最重要的是,浏览器有缓存机制,我们可以结合代码分割来做一些缓存上的优化。 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
压缩图片的一些社区插件来进一步压缩图片体积。
-
安装(具体文档地址)
pnpm add vite-plugin-imagemin -D
-
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
也可以指定压缩工具。
-
安装
pnpm add terser -D
-
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
也有相关插件做这方面的处理。
-
安装
pnpm add vite-plugin-compression2 -D
-
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
项目中的常见优化方式进行总结,欢迎结合实际情况灵活调整。