这里只谈前端针对用户感受的优化体验
优化执行层代码逻辑
- 检查代码是否有多余的接口调用,导致冗余
问题CASE:
- watch监听多次调用导致同样的代码被执行多次
- 错误的执行顺序,导致两个函数的执行区域存在重叠
解决方案:
- 代码层面的时序逻辑更改重构。这个需要结合实际的情况变化,比如频繁切换样式变更DOM行为。
- 防抖和节流。一个函数有概率短期内执行多次,比如点击button,点击新增文件夹,点击弹窗,输入框输入查询,这种情况都可以使用防抖节流去优化整个体验感受。
- 监听器+定时器及时解绑,不然全局绑定的监听器/定时器在页面销毁后还在进行浏览器内存和线程消耗
- 在spa项目中router导航的时候,可以keep-alive,并且设置最多缓存的页面,这样既可以防止组件缓存,防止太多页面缓存在后台占据内存,有些高性能消耗的工作,也可以在后台的时候先关闭,等到用户再切换回前台的时候再去开启,比如轮询排查版本号是否发生打包更新,在页面切到后台的时候就可以把轮询定时器暂停,等到切换回前端页面的时候再去开启
- shallowRef/shallowReactive减少性能损耗,如果数据无需深层响应式的时候可以使用
- 利用缓存优化重复的数据,比如前端缓存数据,这个数据特定形况下不会变,如果不变的话则走前端缓存,如果变化则再调后端接口数据
- 长列表优化:只加载可视区域往外大概一个屏幕的数据,等到下拉的时候再去调后端的接口进行数据的拼接
虽然vue还有自封装的v-once/v-memo等指令工具,但是因为vue本身是细粒度的响应式检测,所以v-memo等指令实际上并没有很频繁使用的场景(也可能我没遇到那种高性能多页面加载的极端case); react里面的useMemo和useCallback,React.memo等则很必要,因为react是以一个模块为单位的
骨架屏&Loading优化白屏加载体验
- Loading一般组件库都会有自带的组件
- 骨架屏我用的是vue-content-loader去处理局部组件骨架屏,不过这个是需要自己去绘制骨架屏,优点是自由度高,但是相对比较繁琐;vite-plugin-vue-skeleton据说可以自动生成首页骨架屏,但是公司镜像没有,导致没办法尝试一下
- errorboundary + suspense + fallback
//主文件,子文件HeavyComponent只要正常执行接口代码就可以
<template>
<div>
<ErrorBoundary>
<Suspense>
<!-- default 插槽:要等待的异步内容 -->
<template #default>
<HeavyComponent />
</template>
<!-- fallback 插槽:加载中兜底内容 -->
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</ErrorBoundary>
<div>
</template>
<script>
import { defineAsyncComponent } from 'vue';
const HeavyComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
);
</script>
error-boundary的组件vue没有,需要自己实现对应的代码,主要依托于vue的生命周期中的onErrorCaptured函数,具体代码实现Demo如下:
数据兜底存放在Localstorage里面,为了保证localstorage的缓存空间以及上报信息不重复:
(1)规定一个上报的key,这个key只缓存最多100-200条数据,超过这个数据,就自动清除最早的数据
(2)每次打开这个页面就会从localstorage里面读取key,然后发送之前失败缓存的错误信息给后端,发送成功一条清除一条对应信息
/**
* 发送错误上报请求(低优先级,不阻塞页面)
*/
const sendErrorReport = async (data: any) => {
try {
await fetch('/api/error/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
priority: 'low', // 低优先级,不影响主业务
cache: 'no-cache'
});
} catch (e) {
// 上报失败:缓存到localStorage,后续重试
const failedErrors = JSON.parse(localStorage.getItem('failed_error_reports') || '[]');
failedErrors.push({ ...data, retryTime: Date.now() });
// 限制缓存数量
localStorage.setItem('failed_error_reports', JSON.stringify(failedErrors.slice(-100)));
}
};
自定义封装ErrorBoundary组件:
<template>
<!-- 错误兜底UI:捕获错误后展示 -->
<div v-if="hasError" class="error-boundary">
<div></div>
</div>
<!-- 正常渲染子组件 -->
<slot v-else />
</template>
<script setup lang="ts">
import { ref, onErrorCaptured, type VNode, type ComponentPublicInstance } from 'vue';
import { sendErrorReport } from "@/utils/sendErrorReport.js"
interface Props {
fallback?: VNode;
onError?: (error: Error, instance: ComponentPublicInstance | null, info: string) => void;
}
interface ErrorInfo {
error: Error;
instance: ComponentPublicInstance | null;
info: string;
timestamp: number;
pageUrl: string;
componentName: string;
}
const props = withDefaults(defineProps<Props>(), {
fallback: () => null,
onError: () => () => {}
});
const hasError = ref(false);
const errorMessage = ref('');
let errorInfo: ErrorInfo | null = null;
// Vue内置的错误捕获钩子:捕获子组件树的错误
onErrorCaptured((error: Error, instance: ComponentPublicInstance | null, info: string) => {
hasError.value = true;
errorMessage.value = error.message || '未知错误';
// 收集完整的错误信息
errorInfo = {
error,
instance,
info,
timestamp: Date.now(),
pageUrl: window.location.href,
componentName: instance?.$options.name || instance?.$options.__name || '未知组件'
};
if(props.onError){
// 执行外部错误回调
props.onError?.(error, instance, info);
}else{
// 手动上报错误
reportError(errorInfo);
}
// 返回true:阻止错误继续向上冒泡
return true;
});
/**
* 错误上报核心方法
* @param errorInfo 完整的错误信息
*/
const reportError = (errorInfo: typeof errorInfo) => {
if (!errorInfo) return;
// 组装上报数据(可根据需求扩展)
const reportData = {
type: 'vue_component_error', // 错误类型:组件级
timestamp: errorInfo.timestamp,
pageUrl: errorInfo.pageUrl,
componentName: errorInfo.componentName,
error: {
message: errorInfo.error.message,
stack: errorInfo.error.stack, // 错误堆栈(定位问题关键)
name: errorInfo.error.name
},
errorLocation: errorInfo.info, // 错误发生位置(如 "setup function")
userAgent: navigator.userAgent,
// 可选:添加用户信息(如登录态)
// userId: localStorage.getItem('userId')
};
// 上报逻辑(和性能监控复用同一个上报接口即可)
sendErrorReport(reportData);
//错误信息提示
ElMessage.error({
message: '系统出错了,我们已记录,正在努力修复',
duration: 5000,
showClose: true
});
};
</script>
但是ErrorBoundary无法捕获以下几种错误类型,必须在全局文件/组件文件内部执行错误捕获
- 异步错误,比如setTimeout和Promise.reject
- 全局错误,比如window.onerror
- ErrorBoundary组件自身的错误
所以需要在main.ts进行全局错误兜底
-
app.config.errorHandler
- 捕获场景:Vue自身导致的错误,以及自定义组件的错误,比如路由守卫,自定义组件,生命周期错误等等
- 无法捕获的场景:异步错误,比如Promise.reject,setTimeout; 原生JS错误; 资源加载错误
-
window.onerror
- 捕获场景:原生JS错误; setTimeout异步错误; 资源加载错误
- 无法捕获的场景:Promise类型的异步错误,async/await未加try/catch等等
- 一定需要return:true,防止向上冒泡
-
window.addEventListener('unhandledrejection')
- Promise类型的异步错误,async/await未加try/catch等等,所以一般接口相关都需要加try catch进行错误捕获以及兜底,即使try catch抛出错误不处理,也会被vue的错误处理器app.config.errorHandler捕获
- 一定需要event.preventDefault,防止向上冒泡
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { sendErrorReport } from "@/utils/sendErrorReport.js"
const app = createApp(App);
/**
* 全局错误监听:捕获Vue应用内的全局错误
* (如全局钩子、路由守卫、异步组件加载错误)
*/
app.config.errorHandler = (error: Error, instance: any, info: string) => {
console.error('【Vue全局错误】', error, instance, info);
// 组装全局错误上报数据
const reportData = {
type: 'vue_global_error',
timestamp: Date.now(),
pageUrl: window.location.href,
error: {
message: error.message,
stack: error.stack,
name: error.name
},
errorLocation: info,
userAgent: navigator.userAgent
};
// 上报全局错误
sendErrorReport(reportData)
};
/**
* 全局window错误监听:捕获所有未被捕获的JS错误(如第三方脚本、同步错误)
*/
window.onerror = (message, source, lineno, colno, error) => {
console.error('【Window全局错误】', message, source, lineno, colno, error);
const reportData = {
type: 'js_global_error',
timestamp: Date.now(),
pageUrl: window.location.href,
error: {
message: String(message),
stack: error?.stack || '无堆栈信息',
name: error?.name || 'Error'
},
errorLocation: `${source || '未知文件'}:${lineno}:${colno}`,
userAgent: navigator.userAgent
};
sendErrorReport(reportData)
// 返回true:阻止错误默认行为(如控制台重复输出)
return true;
};
/**
* 监听未捕获的Promise错误(如异步请求失败、setTimeout内的reject)
*/
window.addEventListener('unhandledrejection', (event) => {
console.error('【未捕获的Promise错误】', event.reason);
const reportData = {
type: 'promise_unhandled_error',
timestamp: Date.now(),
pageUrl: window.location.href,
error: {
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '无堆栈信息',
name: event.reason?.name || 'PromiseRejection'
},
errorLocation: 'unhandledrejection',
userAgent: navigator.userAgent
};
sendErrorReport(reportData)
// 阻止错误冒泡到控制台
event.preventDefault();
});
// 挂载路由和应用
app.use(router);
app.mount('#app');
// 启动时重试之前失败的错误上报
const retryFailedErrorReports = () => {
const failedErrors = JSON.parse(localStorage.getItem('failed_error_reports') || '[]');
if (failedErrors.length === 0) return;
failedErrors.forEach(async (error: any) => {
try {
await fetch('/api/error/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(error),
priority: 'low'
});
// 上报成功:移除缓存
const remaining = failedErrors.filter((e: any) => e.timestamp !== error.timestamp);
localStorage.setItem('failed_error_reports', JSON.stringify(remaining));
} catch (e) {
console.warn('【错误上报重试失败】', error.pageUrl);
}
});
};
// 执行重试
retryFailedErrorReports();
错误兜底机制
兜底的核心是 “错误发生后,用户仍能操作,系统不崩溃”
-
请求数据兜底
- async/await和接口请求都是用try catch进行数据兜底,promise使用catch兜底
// src/api/user.ts
export const getUserInfo = async () => {
try {
const res = await fetch('/api/user/info');
if (!res.ok) throw new Error(`请求失败:${res.status}`);
const data = await res.json();
// 缓存数据(用于兜底)
localStorage.setItem('user_info', JSON.stringify(data));
return data;
} catch (error) {
// 数据级兜底:使用缓存数据
const cacheData = localStorage.getItem('user_info');
if (cacheData) {
console.warn('用户信息请求失败,使用缓存数据');
return JSON.parse(cacheData);
}
// 无缓存:使用默认值
return {
id: '',
name: '游客',
avatar: '/default-avatar.png'
};
}
};
- ErrorBoundary组件级别的兜底
- 页面路由级别兜底:当整个页面出错时,引入ErrorBoundary组件包裹,外部传入onError函数跳转到统一的错误页
// src/router/index.ts
import { createRouter, createWebHistory, h } from 'vue-router';
import ErrorBoundary from '@/components/ErrorBoundary/index.vue';
import ErrorPage from '@/views/ErrorPage/index.vue';
// 路由级ErrorBoundary包裹(页面级兜底)
const withErrorBoundary = (component: () => Promise<any>) => {
return {
render() {
return h(ErrorBoundary, {
fallbackTitle: '页面加载失败',
onError: (error: Error) => {
// 严重错误跳转到错误页
if (error.message.includes('fatal')) {
router.push('/error');
}
}
}, [h(component)]);
}
};
};
const routes = [
{
path: '/',
name: 'Home',
component: withErrorBoundary(() => import('@/views/Home/index.vue'))
},
{
path: '/detail/:id',
name: 'Detail',
component: withErrorBoundary(() => import('@/views/Detail/index.vue'))
},
// 统一错误页(兜底)
{
path: '/error',
name: 'Error',
component: ErrorPage,
props: route => ({
code: route.query.code,
message: route.query.message
})
},
// 404兜底
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/404/index.vue')
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
export default router;
- 应用级别兜底
在main.ts里app.config.errorHandler/window.onerror/window.addEventListener('unhandledrejection') 捕获到错误的时候进行页面重加载,或者弹出错误信息提示