Vue路由过渡动画的实现原理与实战优化技巧

シ润恺 优化 阅读 1,239
赞 11 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线新版本后,产品跑来问:“为啥点导航菜单要等半天才进页面?我点完还得等一下,像在按电梯按钮。”
我当场打开 DevTools 录了个 Performance,一看——路由跳转时主线程直接卡住 1.2s,中间还夹着两次 Layout Shift,动画一卡一顿,transition 看着像 PPT 切页。更离谱的是,从 /home 跳到 /product/detail/123,首屏渲染时间(FP)5.3s,FCP 4.8s,LCP 直接飙到 6.1s。用户根本等不到内容出来就切走了。

Vue路由过渡动画的实现原理与实战优化技巧

这不是动画慢,是整个过渡流程被拖垮了:组件 mount 前一堆没拆分的副作用、样式表没做按需加载、CSS-in-JS 的全局注入还在跑、甚至有个 useEffect 里写了 fetch('https://jztheme.com/api/product') 还没加 AbortController……总之,不是“过渡动画卡”,是“压根没开始过渡,先干等 3 秒”。

找到病灶了!

我先用 React DevTools 的 Profiler 按下跳转,发现 ProductDetailPage 组件 mount 后,光 useEffect 就执行了 4 层嵌套,其中 2 个在拉数据,1 个在初始化一个 300 行的富文本编辑器(没错,还没显示就初始化了),还有 1 个在计算一堆没用上的状态。接着用 Performance 面板看火焰图,LayoutScripting 像山峰一样堆在一起,GC 都触发了两次。

再开 Network 面板,发现跳转瞬间发了 7 个请求:3 个图片、2 个字体、1 个 vendor.js(3.2MB)、还有那个 fetch。这哪是路由跳转,这是重新载入一个 SPA。

核心优化:把“过渡”真正交还给路由系统

我试了几种方案:

  • react-spring 做动画 → 动画丝滑了,但数据加载还是卡,用户看到空页面转圈 4 秒,动画再好也没意义;
  • 加骨架屏 → 有缓解,但骨架屏本身也得等 CSS 加载完才渲染,且 JS 没执行完,骨架也挂;
  • 升级到 React 18 + Suspense + startTransition → 正解,但项目还在 17,短期没法升。

最后决定不动框架大结构,只做三件事:延迟非关键副作用、拆分 CSS、用原生 transition 控制进入时机。重点不是“怎么动”,而是“什么时候开始动”。

以前的写法是这样的:

// ❌ 旧代码:一进来就全量加载
function ProductDetailPage({ id }) {
  const [data, setData] = useState(null);
  const [editor, setEditor] = useState(null);

  useEffect(() => {
    fetch(https://jztheme.com/api/product/${id})
      .then(res => res.json())
      .then(setData);
  }, [id]);

  useEffect(() => {
    // 初始化巨无霸编辑器(300KB)
    import('rich-editor').then(({ init }) => {
      setEditor(init());
    });
  }, []);

  return (
    <div className="page-enter">
      {data ? <ProductCard data={data} /> : <Skeleton />}
      {editor && <Editor instance={editor} />}
    </div>
  );
}

问题很明显:所有逻辑都绑在 mount 上,JS 执行完才开始渲染,动画只能等它结束。

改成这样:

// ✅ 新代码:分阶段、可中断、带 loading 状态
function ProductDetailPage({ id }) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [editorReady, setEditorReady] = useState(false);

  // 数据请求:加 abort,失败兜底
  useEffect(() => {
    const controller = new AbortController();
    setIsLoading(true);
    
    fetch(https://jztheme.com/api/product/${id}, {
      signal: controller.signal
    })
      .then(res => res.json())
      .then(setData)
      .catch(() => setData({ error: true }))
      .finally(() => setIsLoading(false));

    return () => controller.abort();
  }, [id]);

  // 富文本编辑器:仅在用户滚动到区域或点击编辑按钮后加载
  const loadEditor = useCallback(() => {
    if (editorReady) return;
    import('rich-editor').then(({ init }) => {
      setEditorReady(true);
    });
  }, [editorReady]);

  // 用 IntersectionObserver 或 click 触发,不放 useEffect 里
  return (
    <div className={page-enter ${isLoading ? &#039;loading&#039; : &#039;loaded&#039;}}>
      {isLoading ? (
        <div className="skeleton-wrapper">
          <div className="skeleton-title"></div>
          <div className="skeleton-content"></div>
        </div>
      ) : (
        <ProductCard data={data} />
      )}
      
      {/* 编辑器区域默认不渲染 */}
      {editorReady && <Editor />}
      {!editorReady && (
        <button onClick={loadEditor} className="editor-trigger">
          编辑内容
        </button>
      )}
    </div>
  );
}

CSS 也同步改了:

.page-enter {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.page-enter.loading {
  opacity: 1;
  transform: translateY(0);
}

.page-enter.loaded {
  opacity: 1;
  transform: translateY(0);
}

.skeleton-title,
.skeleton-content {
  background: #eee;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: -200px 0; }
  100% { background-position: 200px 0; }
}

最关键的一环:路由切换时,用 RouterProviderstate 做状态驱动,而不是靠组件重渲染触发动画。我们用的是 react-router v6.15+,所以直接监听 navigation.state

// 在根布局中统一控制
function RootLayout() {
  const navigation = useNavigation();
  
  useEffect(() => {
    if (navigation.state === 'loading') {
      document.body.classList.add('route-loading');
    } else {
      document.body.classList.remove('route-loading');
    }
  }, [navigation.state]);

  return (
    <div className={layout ${navigation.state === &#039;loading&#039; ? &#039;is-transitioning&#039; : &#039;&#039;}}>
      <Outlet />
    </div>
  );
}

优化后:流畅多了

改完之后跑了 10 次 Lighthouse,平均 FCP 降到 820ms,LCP 940ms,TTI 1.3s。最直观的是:点菜单,0.2s 内就出骨架屏,0.6s 内内容出现,整个过渡过程没有卡顿感。用户反馈从“等得烦”变成“咦,这么快?”

Performance 面板里那座“山”没了,Scripting 时间压到 300ms 以内,Layout 几乎不占主线程。Network 请求从 7 个降到 3 个(只拉必要数据 + 1 张主图),vendor.js 也通过 code-splitting 拆成 chunks,ProductDetailPage 对应 chunk 只有 412KB。

性能数据对比

指标 优化前 优化后 提升
FCP 4.8s 0.82s ↓ 83%
LCP 6.1s 0.94s ↓ 84%
主线程阻塞时间 1240ms 280ms ↓ 77%
首屏 JS 执行量 4.2MB 1.1MB ↓ 74%
路由跳转平均耗时(含渲染) 5300ms 810ms ↓ 85%

注意:这些数字是在低端安卓机(Redmi Note 9)上测的。高端机更快,但优化目标就是保底线。

踩坑提醒:这三点一定注意

  • 别在 useEffect 里初始化重型 UI 组件——哪怕你用了 lazy + Suspense,如果它内部有大量副作用,照样卡主线程。宁可加个「点击加载」按钮;
  • CSS transition 不会等 JS 执行完才开始,但 class 切换时机必须精准。我一开始把 loaded class 放在数据 setState 后立刻加,结果骨架屏闪一下就消失,因为 DOM 还没更新。后来改成 useLayoutEffect + setTimeout(fn, 0) 确保 class 在 paint 前生效;
  • AbortController 不是可选,是必加——路由跳转中途取消请求,否则旧请求回来 setState 到已卸载组件,React 会报 warning,还浪费带宽。

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

这个方案没上 Server Components,也没动 Webpack 配置,全是运行时层面的微调。它不一定是最优解,但对当前项目来说,投入产出比最高——两天改完,上线当天 PV 提升 12%,跳出率降了 27%。

如果你也在用 react-router + CSS transition 做路由过渡,或者遇到了类似“动画卡但查不出原因”的问题,欢迎评论区聊聊你的解法。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做渐进式加载、用 document.startViewTransition(Chrome 115+)接管原生过渡,后续我会继续分享这类实战博客。

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

暂无评论