fix: 进度条触发时机提前到点击瞬间(覆盖全部导航)
- 根因:此前靠 history.pushState 触发,但 App Router 在导航提交期 (数据加载完成后)才调用它——点击到提交之间的加载期进度条不出现; View Transitions 的旧页快照还会遮住提交后的一闪 - 修复:document 级 click 捕获(站内 <a> 点击瞬间触发,先于框架逻辑 与 VT 快照)+ history API 兜底(popstate/程序化导航)+ 4s 超时保护 - 回归:顶部导航/footer 链接/语言切换(程序化导航)三类全部触发
This commit is contained in:
@@ -5,9 +5,12 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
/**
|
||||
* # 组件 NavigationProgress
|
||||
* 说明:顶部导航进度条。拦截 history API(pushState/replaceState/popstate)
|
||||
* 触发进度条出现,pathname 变化后完成并淡出。
|
||||
* 纯客户端实现,零依赖;颜色走主题 token(深浅模式自动适配)。
|
||||
* 说明:顶部导航进度条。
|
||||
* - 触发:document 级 click 捕获(点击站内 <a> 的瞬间,先于框架逻辑与
|
||||
* View Transitions 快照)+ history API 兜底(popstate / 程序化导航)。
|
||||
* - 完成:pathname/search 变化后冲刺 100% 并淡出;带 4s 超时保护
|
||||
* (导航被阻止时进度条不悬挂)。
|
||||
* - 颜色走主题 token(深浅模式自动适配),零依赖。
|
||||
*/
|
||||
export function NavigationProgress() {
|
||||
const pathname = usePathname();
|
||||
@@ -15,22 +18,38 @@ export function NavigationProgress() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [fading, setFading] = useState(false);
|
||||
const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout> }>({});
|
||||
const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout>; guard?: ReturnType<typeof setTimeout> }>({});
|
||||
|
||||
// 拦截导航开始
|
||||
useEffect(() => {
|
||||
const start = () => {
|
||||
setVisible(true);
|
||||
setFading(false);
|
||||
setProgress(12);
|
||||
// 缓慢爬升到 70%,营造"进行中"
|
||||
clearTimeout(timers.current.slow);
|
||||
timers.current.slow = setTimeout(() => setProgress(70), 180);
|
||||
// 超时保护:导航未发生时自动收起
|
||||
clearTimeout(timers.current.guard);
|
||||
timers.current.guard = setTimeout(() => {
|
||||
setFading(true);
|
||||
setVisible(false);
|
||||
setProgress(0);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// ① 点击捕获:站内 <a> 点击瞬间触发(先于 next/link 与 View Transitions)
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const anchor = (e.target as HTMLElement).closest?.("a");
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href.startsWith("http") || href.startsWith("//") || href.startsWith("#") || anchor.target === "_blank") return;
|
||||
if (!href.startsWith("/")) return;
|
||||
start();
|
||||
};
|
||||
|
||||
// ② history 兜底:popstate / 程序化 router.push
|
||||
const origPush = window.history.pushState.bind(window.history);
|
||||
const origReplace = window.history.replaceState.bind(window.history);
|
||||
|
||||
window.history.pushState = function (...args: Parameters<typeof origPush>) {
|
||||
start();
|
||||
return origPush(...args);
|
||||
@@ -40,18 +59,21 @@ export function NavigationProgress() {
|
||||
return origReplace(...args);
|
||||
};
|
||||
window.addEventListener("popstate", start);
|
||||
document.addEventListener("click", onClickCapture, true);
|
||||
|
||||
return () => {
|
||||
window.history.pushState = origPush;
|
||||
window.history.replaceState = origReplace;
|
||||
window.removeEventListener("popstate", start);
|
||||
document.removeEventListener("click", onClickCapture, true);
|
||||
clearTimeout(timers.current.slow);
|
||||
clearTimeout(timers.current.done);
|
||||
clearTimeout(timers.current.guard);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 导航完成(pathname/search 变化)→ 冲刺到 100% → 淡出
|
||||
// 导航完成(pathname/search 变化)→ 冲刺 100% → 淡出
|
||||
useEffect(() => {
|
||||
setVisible((visibleNow) => {
|
||||
if (!visibleNow) return visibleNow;
|
||||
|
||||
Reference in New Issue
Block a user