返回博客

Next.js App Router 点击没反应?进度条 + loading.tsx 双层方案

用 App Router 做了一个组件展示站,左侧边栏点击切换内容。测试时发现一个"假 bug":点击侧边栏条目之后,右侧内容区静止不动,过了两三秒才猛地换成新页面。用户反馈:"点了没反应,以为卡死了。"——但开发者知道什么都没坏,只是没有反馈。

现象

  • 点击 <Link> 后界面没有任何变化,像是没响应
  • 几秒后页面突然切换到目标路由(内容越重、网络越慢,等待越久)
  • 整个过程中既没有顶部进度条,也没有骨架屏

根因

App Router 的导航模型和 Pages Router 截然不同:在目标路由的 JS chunk 和 RSC payload 全部下载完毕之前,路由器会一直保留当前页面的渲染,等数据就绪后才做"原子切换"。这是有意为之的设计——避免半空状态闪烁——但代价是零中间反馈。

重点在于:如果你想加进度条,监听 history.pushState 完全没用。因为 App Router 的 pushState 只在导航完成后才调用(即目标 chunk 已经拿到),在此时才起动进度条等于在终点升起发令枪。

坑:拦截 pushState 做进度条,条子在终点才出现

很多老教程(以及部分 npm 包的默认实现)通过 monkey-patch history.pushState 来感知路由变化。在 Pages Router 里这没问题,因为 pushState 发生在导航开始时。但在 App Router 里,pushState 推迟到路由准备就绪之后——此时导航已经完成,进度条从 0% 直接跳 100%,对用户毫无意义。

正解

两层并行,缺一不可:

第一层:在点击捕获阶段启动全局进度条

不要等 pushState,要在用户点击的瞬间(capture 阶段)就启动进度条;用 usePathname() 的变化作为"导航完成"信号来收尾。

"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
 
export function RouteProgress() {
  const pathname = usePathname();
  const [visible, setVisible] = useState(false);
  const [progress, setProgress] = useState(0);
  const timers = useRef<any[]>([]);
 
  const clear = () => {
    timers.current.forEach(clearInterval);
    timers.current.forEach(clearTimeout);
    timers.current = [];
  };
 
  const start = () => {
    clear(); setVisible(true); setProgress(8);
    // 指数衰减逼近 90%,永远不会自己走到 100
    timers.current.push(
      setInterval(() => setProgress(p => p >= 90 ? p : p + (90 - p) * 0.12 + 0.5), 200)
    );
    // 安全兜底:hash 跳转 / 同路径点击不会触发 pathname 变化,8s 后自动收
    timers.current.push(setTimeout(() => finish(), 8000));
  };
 
  const finish = () => {
    clear(); setProgress(100);
    timers.current.push(setTimeout(() => { setVisible(false); setProgress(0); }, 240));
  };
 
  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey) return;
      const a = (e.target as HTMLElement)?.closest?.("a");
      const href = a?.getAttribute("href");
      if (!a || !href || a.target === "_blank" || a.hasAttribute("download")) return;
      if (/^(#|mailto:|tel:)/.test(href)) return;
      let url: URL;
      try { url = new URL(href, location.href); } catch { return; }
      // 外链 / 同路径不触发
      if (url.origin !== location.origin || url.pathname === location.pathname) return;
      start();
    };
    document.addEventListener("click", onClick, { capture: true });
    return () => document.removeEventListener("click", onClick, { capture: true });
  }, []);
 
  // pathname 变化 = 导航完成,收条
  useEffect(() => { if (visible) finish(); }, [pathname]); // eslint-disable-line
  useEffect(() => () => clear(), []);
 
  if (!visible) return null;
  return (
    <div aria-hidden style={{ position: "fixed", top: 0, left: 0, right: 0, height: 2.5, zIndex: 9999, pointerEvents: "none" }}>
      <div style={{
        height: "100%", width: `${progress}%`,
        background: "var(--color-primary)",
        boxShadow: "0 0 8px var(--color-primary)",
        transition: "width 200ms ease",
      }} />
    </div>
  );
}

<RouteProgress /> 挂在根 layout.tsx 里(放在主题 Provider 内部,CSS token 才能解析)。

第二层:在叶节点放 loading.tsx,而不是根节点

app/
  components/
    layout.tsx          ← 侧边栏(导航时保持挂载)
    [slug]/
      page.tsx          ← 内容
      loading.tsx       ← 只有这个格子换成骨架屏

不要app/loading.tsx(根级)放骨架,那会导致每次导航都把整个布局(包括侧边栏)替换成骨架——太激进。叶节点的 loading.tsx 只占内容槽,父级布局保持不动,体验更自然。骨架屏镜像真实内容的外轮廓,保持无卡片包裹(参见"骨架屏不要用 Card 包"原则)。

正解:捕获点击启动条 + usePathname 收尾 + 叶节点 loading.tsx

两层配合:进度条解决"点了没反应"的感知问题,loading.tsx 解决"旧内容残留"的视觉问题。注意进度条是感知优化,不是加速——真正的性能优化要靠代码分割和按需预取。

一句话外卖

App Router 的 pushState 在导航完成时才触发,做进度条必须在点击捕获阶段就起跑,用 usePathname 变化收尾。