掌握编程式导航的核心技巧与常见陷阱实战总结

纪娜的笔记 前端 阅读 701
赞 9 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月上线一个后台管理页,用 Vue 3 + Pinia + Vue Router,路由跳转全靠 router.push 编程式导航。本来以为很轻量,结果一测发现:从首页点进「数据看板」页,白屏时间平均 4.8s,LCP(最大内容绘制)直接干到 5.2s,用户反馈“点一下要等半支烟”。更离谱的是,连路由守卫里的异步逻辑都卡——beforeEach 里 fetch 用户权限,还没开始渲染,UI 就先卡住 1.2s。

掌握编程式导航的核心技巧与常见陷阱实战总结

这不是慢,是堵。不是加载慢,是阻塞式卡顿。

找到瘼颈了!

我先开 Chrome DevTools → Performance 面板录了一次导航过程,点下「数据看板」后停住。放大 Timeline,一眼看到主线程上一堆黄色的「Script Evaluation」长条,占满 2s+。点进去看调用栈,80% 都在 router.push 后触发的组件 setup() 里 —— 具体是某个封装的 useDataFetch() 组合函数,在 onBeforeRouteEnter 里手动调用了 fetch('/api/dashboard'),但没加防抖、没做缓存、也没取消上一次请求。

接着切到 Network,发现每次 push 都会发 3 次重复请求:
① 路由守卫里发一次(未 await);
② 组件 onMounted 里又发一次(忘了判断是否已加载);
③ 还有个 watchEffect 监听 route.query 又发一次……
三个请求还都是串行,因为用了 await Promise.all([...]) 包着,但里面每个 fetch 都没 timeout,网络抖动时直接 hang 死。

再看 Memory 标签页,跳转 5 次后 heap 增长了 40MB —— 是路由组件实例没销毁,onUnmounted 里漏写了 event listener 清理和 AbortController.abort()。

试了几种方案,最后这个效果最好

我试过:

  • 把所有数据请求挪到服务端 SSR(太重,不现实)
  • 加 loading skeleton(治标不治本,卡还是卡)
  • router.replace 替代 push(没用,问题不在历史栈)

真正起效的,就三件事:

第一,砍掉冗余请求,强制单次执行
我把所有路由级数据加载逻辑收口到一个 useRouteData(),内部用 reactive state + computed 判断是否已加载,并用 key 控制组件强制刷新(避免复用导致状态错乱):

// composables/useRouteData.js
import { ref, reactive, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteData(fetchFn) {
  const route = useRoute()
  const router = useRouter()
  const state = reactive({
    data: null,
    loading: false,
    error: null,
    _loadedKey: Date.now(), // 用于 v-if 强制重载
  })

  const loadData = async () => {
    if (state.loading || state.data) return
    state.loading = true
    try {
      const data = await fetchFn(route)
      state.data = data
      state._loadedKey = Date.now()
    } catch (e) {
      state.error = e
    } finally {
      state.loading = false
    }
  }

  // 关键:监听 route 变化,但只在 params/query 有实际变化时才触发
  const routeKey = computed(() => JSON.stringify({ path: route.path, query: route.query, params: route.params }))
  watch(routeKey, loadData, { immediate: true })

  // 清理 abort controller(这里我踩了坑:一开始写在 onBeforeRouteLeave,但组件可能根本没挂载)
  let abortController = new AbortController()
  onUnmounted(() => {
    abortController.abort()
  })

  return {
    ...toRefs(state),
    reload: () => {
      abortController.abort()
      abortController = new AbortController()
      state.data = null
      loadData()
    }
  }
}

第二,把路由守卫里的 fetch 干掉,全部交给组件自己管
之前在 router.beforeEach 里写了一堆 if (to.name === 'Dashboard') fetch(...),看着整齐,实则灾难 —— 守卫里不能 await 异步操作(否则整个导航被 block),只能写成同步逻辑,最后变成 “假装加载” 然后靠组件兜底。删掉后,所有数据流清晰可控,首屏也快了 1.3s。

第三,加超时 & 自动取消
原来 fetch 全是裸调用:fetch('/api/dashboard')。现在统一包一层:

// utils/fetchWithTimeout.js
export async function fetchWithTimeout(url, options = {}, timeout = 8000) {
  const controller = new AbortController()
  const id = setTimeout(() => controller.abort(), timeout)

  try {
    const res = await fetch(url, {
      ...options,
      signal: controller.signal,
    })
    clearTimeout(id)
    return res
  } catch (err) {
    clearTimeout(id)
    if (err.name === 'AbortError') {
      throw new Error(Request timeout (${timeout}ms))
    }
    throw err
  }
}

然后在 useRouteDatafetchFn 里直接用它:

// views/Dashboard.vue
import { useRouteData } from '@/composables/useRouteData'
import { fetchWithTimeout } from '@/utils/fetchWithTimeout'

const { data, loading, reload } = useRouteData(async (route) => {
  const res = await fetchWithTimeout('https://jztheme.com/api/dashboard', {
    headers: { Authorization: Bearer ${localStorage.getItem('token')} }
  })
  return res.json()
})

优化后:流畅多了

改完部署测试环境,本地 Lighthouse 跑分从 32 分升到 89 分。实测数据:

  • 首屏加载时间:4.8s → 780ms(降了 84%)
  • LCP:5.2s → 620ms
  • 内存增长:5 次跳转后 heap +40MB → +2.1MB
  • Network 请求次数:平均 3.2 次/跳转 → 1.1 次/跳转

最明显的是交互反馈 —— 点击按钮后不到 100ms 就显示骨架屏,300ms 内出真实内容,用户不再觉得“点了没反应”。

当然也有小遗憾:某些低配安卓机上,首次跳转仍有轻微卡顿(约 120ms 主线程阻塞),查了是 JSON.parse 大数据导致,后面打算加个 Web Worker 解析,但优先级不高,先放着。

性能数据对比

这是我在同一台 MacBook Pro(M1,Chrome 126)上,用 Performance 面板录制的三次典型导航(首页 → 数据看板)平均值:

指标 优化前 优化后 提升
FCP(首次内容绘制) 3.4s 420ms ↑ 88%
TTFB(首字节时间) 1.1s(含前端阻塞) 180ms(纯网络) ↑ 84%
主线程总阻塞时间 2100ms 230ms ↓ 89%
JS 执行时间(setup 阶段) 1650ms 190ms ↓ 89%

注意:TTFB 的优化不是后端变快了,是去掉了前端“假等待” —— 原来代码卡在 fetch.then 里等响应,现在 fetch 立即发出去,不等结果就渲染骨架,所以感知上的 TTFB 直接压下来了。

以上是我的优化经验,有更好的方案欢迎交流

这次优化没碰什么高大上的技术,就是老老实实砍冗余、加 abort、控生命周期、防重复。没有银弹,只有一个个具体的问题点对点打补丁。

如果你也在用 Vue Router 做编程式导航,建议先打开 Performance 录一段跳转 —— 别猜,直接看哪块黄条最长。有时候你以为是路由慢,其实是某个 watchEffect 在疯狂 rerun;你以为是接口慢,其实是没 cancel 上一次请求把线程堵死了。

这个技巧的拓展用法还有很多,比如结合 keep-alive 缓存 + activated 钩子做条件刷新,或者给 useRouteData 加上 localStorage 缓存 fallback —— 下篇博客再说。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论