Next.js App Router 点击没反应?进度条 + loading.tsx 双层方案
用 App Router 做了一个组件展示站,左侧边栏点击切换内容。测试时发现一个"假 bug":点击侧边栏条目之后,右侧内容区静止不动,过了两三秒才猛地换成新页面。用户反馈:"点了没反应,以为卡死了。"——但开发者知道什么都没坏,只是没有反馈。
现象
- 点击
<Link>后界面没有任何变化,像是没响应 - 几秒后页面突然切换到目标路由(内容越重、网络越慢,等待越久)
- 整个过程中既没有顶部进度条,也没有骨架屏
根因
App Router 的导航模型和 Pages Router 截然不同:在目标路由的 JS chunk 和 RSC payload 全部下载完毕之前,路由器会一直保留当前页面的渲染,等数据就绪后才做"原子切换"。这是有意为之的设计——避免半空状态闪烁——但代价是零中间反馈。
重点在于:如果你想加进度条,监听 history.pushState 完全没用。因为 App Router 的 pushState 只在导航完成后才调用(即目标 chunk 已经拿到),在此时才起动进度条等于在终点升起发令枪。
很多老教程(以及部分 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 包"原则)。
两层配合:进度条解决"点了没反应"的感知问题,loading.tsx 解决"旧内容残留"的视觉问题。注意进度条是感知优化,不是加速——真正的性能优化要靠代码分割和按需预取。
一句话外卖
App Router 的
pushState在导航完成时才触发,做进度条必须在点击捕获阶段就起跑,用usePathname变化收尾。