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
|
* # 组件 NavigationProgress
|
||||||
* 说明:顶部导航进度条。拦截 history API(pushState/replaceState/popstate)
|
* 说明:顶部导航进度条。
|
||||||
* 触发进度条出现,pathname 变化后完成并淡出。
|
* - 触发:document 级 click 捕获(点击站内 <a> 的瞬间,先于框架逻辑与
|
||||||
* 纯客户端实现,零依赖;颜色走主题 token(深浅模式自动适配)。
|
* View Transitions 快照)+ history API 兜底(popstate / 程序化导航)。
|
||||||
|
* - 完成:pathname/search 变化后冲刺 100% 并淡出;带 4s 超时保护
|
||||||
|
* (导航被阻止时进度条不悬挂)。
|
||||||
|
* - 颜色走主题 token(深浅模式自动适配),零依赖。
|
||||||
*/
|
*/
|
||||||
export function NavigationProgress() {
|
export function NavigationProgress() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -15,22 +18,38 @@ export function NavigationProgress() {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [fading, setFading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const start = () => {
|
const start = () => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
setFading(false);
|
setFading(false);
|
||||||
setProgress(12);
|
setProgress(12);
|
||||||
// 缓慢爬升到 70%,营造"进行中"
|
|
||||||
clearTimeout(timers.current.slow);
|
clearTimeout(timers.current.slow);
|
||||||
timers.current.slow = setTimeout(() => setProgress(70), 180);
|
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 origPush = window.history.pushState.bind(window.history);
|
||||||
const origReplace = window.history.replaceState.bind(window.history);
|
const origReplace = window.history.replaceState.bind(window.history);
|
||||||
|
|
||||||
window.history.pushState = function (...args: Parameters<typeof origPush>) {
|
window.history.pushState = function (...args: Parameters<typeof origPush>) {
|
||||||
start();
|
start();
|
||||||
return origPush(...args);
|
return origPush(...args);
|
||||||
@@ -40,18 +59,21 @@ export function NavigationProgress() {
|
|||||||
return origReplace(...args);
|
return origReplace(...args);
|
||||||
};
|
};
|
||||||
window.addEventListener("popstate", start);
|
window.addEventListener("popstate", start);
|
||||||
|
document.addEventListener("click", onClickCapture, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.history.pushState = origPush;
|
window.history.pushState = origPush;
|
||||||
window.history.replaceState = origReplace;
|
window.history.replaceState = origReplace;
|
||||||
window.removeEventListener("popstate", start);
|
window.removeEventListener("popstate", start);
|
||||||
|
document.removeEventListener("click", onClickCapture, true);
|
||||||
clearTimeout(timers.current.slow);
|
clearTimeout(timers.current.slow);
|
||||||
clearTimeout(timers.current.done);
|
clearTimeout(timers.current.done);
|
||||||
|
clearTimeout(timers.current.guard);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 导航完成(pathname/search 变化)→ 冲刺到 100% → 淡出
|
// 导航完成(pathname/search 变化)→ 冲刺 100% → 淡出
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisible((visibleNow) => {
|
setVisible((visibleNow) => {
|
||||||
if (!visibleNow) return visibleNow;
|
if (!visibleNow) return visibleNow;
|
||||||
|
|||||||
Reference in New Issue
Block a user