页面后端接口比较慢,模块异步卡顿感比较严重,怎么优化处理

45 阅读7分钟

这里只谈前端针对用户感受的优化体验

优化执行层代码逻辑

  1. 检查代码是否有多余的接口调用,导致冗余

问题CASE:

  1. watch监听多次调用导致同样的代码被执行多次
  2. 错误的执行顺序,导致两个函数的执行区域存在重叠

解决方案:

  • 代码层面的时序逻辑更改重构。这个需要结合实际的情况变化,比如频繁切换样式变更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无法捕获以下几种错误类型,必须在全局文件/组件文件内部执行错误捕获

  1. 异步错误,比如setTimeout和Promise.reject
  2. 全局错误,比如window.onerror
  3. ErrorBoundary组件自身的错误

所以需要在main.ts进行全局错误兜底

  1. app.config.errorHandler

    • 捕获场景:Vue自身导致的错误,以及自定义组件的错误,比如路由守卫,自定义组件,生命周期错误等等
    • 无法捕获的场景:异步错误,比如Promise.reject,setTimeout; 原生JS错误; 资源加载错误
  2. window.onerror

    • 捕获场景:原生JS错误; setTimeout异步错误; 资源加载错误
    • 无法捕获的场景:Promise类型的异步错误,async/await未加try/catch等等
    • 一定需要return:true,防止向上冒泡
  3. 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();

错误兜底机制

兜底的核心是 “错误发生后,用户仍能操作,系统不崩溃”

  1. 请求数据兜底

    • 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'
    };
  }
};
  1. ErrorBoundary组件级别的兜底
  2. 页面路由级别兜底:当整个页面出错时,引入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;
  1. 应用级别兜底

在main.ts里app.config.errorHandler/window.onerror/window.addEventListener('unhandledrejection') 捕获到错误的时候进行页面重加载,或者弹出错误信息提示