
一句话:导航体验由六个维度决定——「即时反馈(进度条)、数据预取(hover 预加载)、过渡动画(View Transitions)、滚动恢复(后退回到原位)、加载态(骨架而非 spinner)、错误恢复(失败可重试)」;其中投入产出比最高的是「预取」和「滚动恢复」,而 View Transitions 是近年最值得关注的新能力。
一、六个维度与它们的「真实体感权重」
| 维度 | 用户感知 | 实现成本 | 优先级 |
|---|---|---|---|
| ⭐ 数据预取(hover/进入视口时预加载) | ⭐⭐⭐「点进去就有」= 瞬时 | 中(路由库/框架支持) | 最高 |
| ⭐ 滚动恢复(后退回到原位) | ⭐⭐⭐ 「怎么回到顶部了?」很恼人 | ⭐ 低(但要处理细节) | 高 |
| 即时反馈(点了立刻有反应) | ⭐⭐⭐ 没有反馈 = 「是不是没点上」 | 低(进度条/isPending) |
高 |
| 加载态设计(骨架 vs spinner) | ⭐⭐ 骨架显著减少「跳变感」 | 低 | 中 |
| 过渡动画(View Transitions) | ⭐⭐ 提升「连贯感」 | 中(新 API + 降级) | 中 |
| 错误恢复(失败可重试、不白屏) | ⭐⭐ 只在出错时体现 | 低 | 中 |
⭐⭐⭐ 一条最重要的认知:
【「导航快」的体感来自「预取」,而不是「优化加载」】
· 你把接口从 300ms 优化到 200ms → 用户感知「差不多」
· 你在「hover 时就开始请求」 → 点进去数据已在 → ⭐ **感知是「零延迟」**
⭐ 结论:「**把等待藏到用户『还没点击』的那段时间里**」是导航体验的第一杠杆。
(这也是 Next.js 的 `<Link prefetch>`、TanStack Router 的
`defaultPreload: 'intent'`、Remix 的 `prefetch="intent"` 都在做的事。)
二、维度 1:数据预取
// ✅ 方案 A:hover / focus 时预取(⭐ 最实用)
import { useCallback, useRef } from 'react';
export function usePrefetchOnIntent<T>(prefetch: () => Promise<T>, options: { delayMs?: number } = {}) {
const { delayMs = 60 } = options;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const doneRef = useRef(false);
const cancel = useCallback(() => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
}, []);
/** ⭐ 60ms 的「intent 判定」:避免「鼠标划过一排链接」时全部预取 */
const onPointerEnter = useCallback(() => {
if (doneRef.current) return;
cancel();
timerRef.current = setTimeout(() => {
timerRef.current = null;
doneRef.current = true; // ⭐ 只预取一次
void prefetch().catch(() => { /* 预取失败静默(真正点击时会再试) */ });
}, delayMs);
}, [prefetch, delayMs, cancel]);
return {
onPointerEnter,
onPointerLeave: cancel,
onFocus: onPointerEnter, // ⭐ 键盘用户也要预取
onBlur: cancel,
};
}
// 用法
function ProductLink({ id, name }: { id: string; name: string }) {
const queryClient = useQueryClient();
const intentProps = usePrefetchOnIntent(
useCallback(
// ⭐ 预取「详情接口」+「把列表数据塞进详情页需要的缓存」
() => queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) }),
[queryClient, id]
)
);
return (
<Link to={`/products/${id}`} {...intentProps}>
{name}
</Link>
);
}
// ✅ 方案 B:进入视口就预取(适合「列表里的每一项」)
export function usePrefetchOnVisible<T>(prefetch: () => Promise<T>, options: { rootMargin?: string } = {}) {
const ref = useRef<HTMLElement | null>(null);
const doneRef = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
// ⭐ 用 IntersectionObserver 而不是 scroll 事件(见 1.14)
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting || doneRef.current) continue;
doneRef.current = true;
// ⚠️ 注意「不要预取太多」:视口内几十个链接会一起请求
void prefetch().catch(() => {});
io.disconnect();
}
},
{ rootMargin: options.rootMargin ?? '200px' } // ⭐ 提前 200px 开始
);
io.observe(el);
return () => io.disconnect();
}, [prefetch, options.rootMargin]);
return ref;
}
⭐⭐⭐ 预取的四条纪律(做错反而更慢):
① 【要「延迟触发」(intent),不要「一 hover 就取」】
⭐ 用户「鼠标划过一排链接」时,如果每个都预取 → **带宽被瞬间打满**
→ 真正点击的那一个反而更慢(⭐ 这叫「预取抢占」)
✅ 约 50~100ms 的延迟(「鼠标停下来」才开始)
② 【要「限制并发」与「优先级」】
⭐ 预取应该是「低优先级」的(不要和「当前页面正在用的请求」抢)
✅ 数据层通常有「并发上限 + 优先级」配置(React Query 的 `prefetchQuery` 天然低优先级)
③ 【预取「数据」也要预取「代码」】
⭐ 「路由级 code splitting」的项目里,页面的 JS 也是「要等的资源」
✅ 预取数据时「同时」触发 `import('./page')`(⭐ TanStack Router / Next.js 的 preload 会一起做)
④ 【要「避免预取无用内容」】
⚠️ 「视口内所有卡片」都预取 → 浪费
✅ 只预取「列表前 N 项」或「hover 的那一项」
三、维度 2:即时反馈与进度条
// ✅ 导航进度条(NProgress 风格):让「点击」立刻有反馈
import { useEffect, useState, useRef } from 'react';
export function useNavigationProgress({ isNavigating }: { isNavigating: boolean }) {
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const rafRef = useRef(0);
const startedAt = useRef(0);
useEffect(() => {
if (isNavigating) {
setVisible(true);
startedAt.current = performance.now();
setProgress(8); // ⭐ 「立刻」给一点进度(有反馈)
// ⭐ 模拟「渐进增长」(真实进度未知,所以「越来越慢」地爬)
const tick = () => {
setProgress((p) => {
const elapsed = performance.now() - startedAt.current;
// ⭐ 关键心理技巧:快速涨到 ~80%,最后 20% 「慢慢等」
const target = 80 + Math.min(15, elapsed / 200);
if (p >= target) return p;
return p + Math.max(0.5, (target - p) * 0.08);
});
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(rafRef.current);
if (!visible) return;
setProgress(100);
const t = setTimeout(() => { setVisible(false); setProgress(0); }, 220); // ⭐ 完成后快速收尾
return () => clearTimeout(t);
}
return () => cancelAnimationFrame(rafRef.current);
}, [isNavigating, visible]);
return { visible, progress };
}
/** ⭐ 进度条组件(用 transform 避免重排,见 5.17) */
export function TopProgressBar({ isNavigating }: { isNavigating: boolean }) {
const { visible, progress } = useNavigationProgress({ isNavigating });
if (!visible) return null;
return (
<div
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
// ⭐ 无障碍:不要让它「被读出来」(它是视觉反馈;真正的加载态由内容区承担)
aria-hidden="true"
style={{
position: 'fixed', top: 0, left: 0, right: 0, height: 2,
zIndex: 'var(--z-toast, 4000)',
background: 'transparent',
pointerEvents: 'none',
}}
>
<div
style={{
height: '100%',
width: '100%',
background: 'var(--color-primary, #1677ff)',
// ⭐ 用 scaleX(只走合成阶段)
transform: `scaleX(${progress / 100})`,
transformOrigin: 'left',
transition: 'transform 120ms linear',
}}
/>
</div>
);
}
// ✅ 「导航进行中」的状态从哪来?(三个路由库的对应写法)
// ① React Router v6.4+(data router)
const navigation = useNavigation();
const isNavigating = navigation.state === 'loading';
// ⭐ 还能知道「是哪个路由在加载」:navigation.location
// ② TanStack Router
const isNavigating = useRouterState({ select: (s) => s.isLoading || s.isTransitioning });
// ③ Next.js App Router
const [isPending, startTransition] = useTransition(); // ⭐ 配 router.push + transition
⭐⭐⭐ 「即时反馈」的两条要点:
① 【点击后必须「立刻」有变化】(哪怕只是一条进度条)
⚠️ 如果「点了 300ms 内毫无反应」→ 用户会「再点一次」(⭐ 导致重复请求/重复操作)
✅ 「进度条 + 按钮进入 pending 态(禁用 + loading 图标)」
② 【进度条要「诚实」】(不要在 100% 停住)
⭐ 常见的做法是「爬到 80% 然后等」 —— 这是**对的**(因为不知道真实进度)
⚠️ 但「爬到 100% 然后卡住等 2 秒」体验很差(用户以为「卡死了」)
✅ 完成后「快速补到 100% + 淡出」
四、维度 3:过渡动画(View Transitions API)
// ⭐⭐⭐ View Transitions API:浏览器原生的「页面切换动画」
// 它能在「旧 DOM → 新 DOM」之间自动做「淡入淡出 / 位移」的过渡
/**
* ⭐ 封装:带降级的「视图过渡」
* 用法:await withViewTransition(() => navigate(...))
*/
export async function withViewTransition(
update: () => void | Promise<void>,
options: { skipOnReducedMotion?: boolean } = { skipOnReducedMotion: true }
): Promise<void> {
// ⭐ ① 不支持 → 直接执行(降级)
if (typeof document === 'undefined' || !('startViewTransition' in document)) {
await update();
return;
}
// ⭐ ② 尊重「减少动态效果」偏好(见 1.15)
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion && options.skipOnReducedMotion) {
await update();
return;
}
const transition = (document as Document & {
startViewTransition: (cb: () => void | Promise<void>) => { finished: Promise<void>; updateCallbackDone: Promise<void>; skipTransition: () => void };
}).startViewTransition(() => update());
try {
await transition.finished;
} catch {
// ⭐ 过渡被打断(用户快速点击)→ 忽略
}
}
/* ✅ 全局的过渡动画(默认是「淡入淡出」) */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* ⭐ 让「旧页面」快速淡出、「新页面」稍慢淡入(避免「双影」) */
::view-transition-old(root) {
animation-name: fade-out;
}
::view-transition-new(root) {
animation-name: fade-in;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }
/* ⭐⭐ 共享元素过渡(「列表项 → 详情页」的神奇效果) */
/* 原理:给「两个页面里『代表同一个东西』的元素」起相同的 view-transition-name */
.product-card {
/* ⚠️ 必须「唯一」:同一时刻「可见的两个元素不能同名」 */
view-transition-name: var(--product-key); /* 如 `product-p-123` */
}
.product-hero {
view-transition-name: var(--product-key); /* ⭐ 与卡片同名 → 浏览器自动补间 */
}
// ✅ 在 React 里用(⭐ 关键:等到「新页面渲染完」再结束过渡)
function useViewTransitionNavigate() {
const navigate = useNavigate();
const [isPending, startTransition] = useTransition(); // ⭐ 用 transition 让「导航不阻塞输入」
return useCallback(
async (to: string) => {
await withViewTransition(() => {
// ⭐⭐ 关键:过渡的「更新回调」必须「等 React 把新页面渲染出来」
// 所以这里返回一个 Promise(startTransition 的回调不返回 Promise)
return new Promise<void>((resolve) => {
startTransition(() => {
void navigate(to);
// ⚠️ 这个「等待」的写法依赖具体路由库:
// React Router:等 `useNavigation().state === 'idle'`
// TanStack Router:等 `router.state.isTransitioning === false`
// 这里用 rAF + 微任务做一个「尽力而为」的等待
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
});
});
},
[navigate, startTransition]
);
}
⭐⭐⭐ View Transitions 的三个「必须知道」:
① 【它做了什么(以及没做什么)】
✅ 自动:把「旧 DOM 快照」与「新 DOM 快照」做「交叉淡入」(+ 命名元素的「共享补间」)
❌ 不做:它**不负责「数据加载」**(那是 loader/Suspense 的事)
⭐ 正确用法:**先等数据(loader)→ 再在 `startViewTransition` 里「一次性把新 UI 渲出来」**
→ 于是「过渡动画」是「从旧页面到新页面的平滑切换」,而不是「转圈圈等数据」
② 【`view-transition-name` 必须是「唯一且稳定」的】
⚠️ 同一时刻「两个可见元素同名」→ 浏览器会「跳过那个过渡」(或行为异常)
✅ 用「业务 key」生成(如 `product-${id}`);⭐ 列表里「同名的元素只能有一个在视口内」
⚠️ 注意「虚拟滚动」:滚出视口的项如果还有 name,可能冲突
③ 【它需要「配 `prefers-reduced-motion`」(无障碍)】
⭐ 必须降级(见上面的实现);⚠️ 且「大范围的位移动画」对前庭功能障碍用户不友好
⚠️ 兼容性:Chrome/Edge 支持较早,Safari 18+,Firefox 支持较晚
→ ⭐ 必须做「能力检测 + 降级」(本实现已经做了)
五、维度 4:滚动恢复(投入产出比最高)
⭐⭐⭐ 「滚动恢复」的完整需求(三个不同场景):
① 【浏览器前进/后退】→ 恢复「离开时的滚动位置」
⭐ 浏览器的默认行为(`history.scrollRestoration = 'auto'`)在「MPA」下是对的,
但在「SPA」下**不可靠**(因为「新页面是同一个 document」)
✅ 通常要「改成 manual + 自己管理」
② 【从列表进详情再返回】→ 恢复「列表的滚动位置」
⭐ 这是「SPA 里最容易让用户骂」的场景:
「我翻了 5 页,点进一个详情,返回后又回到顶部了」→ 需要重新翻
③ 【前进到「新页面」】→ ⭐ **滚到顶部**(而不是「沿用上一个页面的位置」)
⚠️ 如果不区分,会出现「点了一个新页面,但滚动条停在中间」的诡异体验
// ✅ 一个「区分 push/pop」的滚动恢复实现
import { useEffect, useRef } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
/** ⭐ 把「滚动位置」按「key」存起来(key 通常用 location.key) */
const positions = new Map<string, number>();
export function useScrollRestoration(options: {
/** 滚动容器(默认 window;内部滚动列表要传自己的容器) */
getContainer?: () => HTMLElement | Window;
/** ⭐ 用哪个 key 存(默认 location.key——它「每次 push 都不同,pop 时复用」) */
getKey?: (location: Location) => string;
} = {}) {
const location = useLocation();
const navigationType = useNavigationType(); // 'PUSH' | 'POP' | 'REPLACE'
const getContainer = options.getContainer ?? (() => window);
const getKey = options.getKey ?? ((loc: Location) => loc.key);
/** ⭐⭐ 关键 1:关掉浏览器的自动恢复(否则会「和我们打架」) */
useEffect(() => {
if ('scrollRestoration' in history) {
const prev = history.scrollRestoration;
history.scrollRestoration = 'manual';
return () => { history.scrollRestoration = prev; };
}
}, []);
/** ⭐⭐ 关键 2:离开前「记录当前位置」 */
useEffect(() => {
const key = getKey(location);
return () => {
// ⚠️ 用「清理函数」在「离开这个 location 时」保存
// 但清理函数「拿不到『离开时的 DOM』」—— 所以这里用「滚动监听」持续记录更稳
};
}, [location, getKey]);
/** ✅ 更稳的做法:滚动时「节流记录」位置 */
useEffect(() => {
const container = getContainer();
const key = getKey(location);
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
const top = container === window
? window.scrollY
: (container as HTMLElement).scrollTop;
positions.set(key, top);
});
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [location, getContainer, getKey]);
/** ⭐⭐⭐ 关键 3:根据「导航类型」决定「恢复位置」还是「滚到顶部」 */
useEffect(() => {
const container = getContainer();
const key = getKey(location);
if (navigationType === 'POP') {
// ⭐ 后退/前进 → 恢复位置
const saved = positions.get(key) ?? 0;
// ⚠️ 用 rAF 确保「新页面已渲染」(否则「目标位置还不存在」)
requestAnimationFrame(() => {
if (container === window) window.scrollTo(0, saved);
else (container as HTMLElement).scrollTop = saved;
});
return;
}
// ⭐ 新导航(PUSH/REPLACE)→ 滚到顶部
// ⚠️ 注意:如果「同一个路由只改了 search」(如翻页),可能想「保留位置」→ 可配开关
if (container === window) window.scrollTo(0, 0);
else (container as HTMLElement).scrollTop = 0;
}, [location, navigationType, getContainer, getKey]);
}
⭐⭐⭐ 滚动恢复的四个「魔鬼细节」:
① 【必须区分「POP(后退)」与「PUSH(新导航)」】⭐
· POP → 恢复位置
· PUSH → 滚到顶部
⚠️ 不区分 → 「返回时回顶部」(恼人)或「新页面停在中间」(诡异)
② 【要「关掉浏览器的自动恢复」】
`history.scrollRestoration = 'manual'`
⚠️ 不关的话,浏览器会和你的逻辑「打架」(有时恢复两次、有时跳到 0)
③ 【要处理「内部滚动容器」】
⭐ 「长列表在自己容器里滚动」时,`window.scrollY` 永远是 0
✅ 用「滚动容器自身」的 `scrollTop` 存取(本实现的 `getContainer`)
④ 【「恢复时目标还没渲染完」的时序问题】⭐
⚠️ 如果「列表是异步加载的」,返回时**数据还没到** → 恢复位置时「页面高度不够」
→ 滚动被截断(看起来「没恢复」)
✅ 两种做法:
· 「等数据到了再恢复」(数据层加载完成后触发一次 restore)
· ⭐ 「虚拟列表/分页列表」用「记住『可见项的 key』+ 恢复到那一项」
(比「记住像素位置」更可靠 —— 因为「内容高度可能变」)
六、完整实现:一个导航体验套件(250 行)
// navigation-experience.tsx —— 把六个维度组装成一个「体验层」
import { useEffect, useRef, useState, useCallback, createContext, useContext, useMemo } from 'react';
interface NavigationExperienceValue {
/** ⭐ 导航是否进行中(供进度条使用) */
isNavigating: boolean;
/** ⭐ 带「视图过渡 + pending」的导航函数(业务代码用它代替裸 navigate) */
push: (to: string) => Promise<void>;
}
const Ctx = createContext<NavigationExperienceValue | null>(null);
/**
* ⭐ 需要「路由库的适配」:不同路由库拿 isNavigating / navigate 的方式不同。
* 这里用「适配器」把它抽出来(与 10.15 的跨平台适配层是同一个思路)。
*/
export interface RouterAdapter {
getIsNavigating: () => boolean;
navigate: (to: string, opts?: { replace?: boolean }) => void | Promise<void>;
getLocationKey: () => string;
getNavigationType: () => 'PUSH' | 'POP' | 'REPLACE';
subscribeNavigation: (fn: () => void) => () => void;
}
export function NavigationExperienceProvider({
adapter,
children,
}: {
adapter: RouterAdapter;
children: React.ReactNode;
}) {
const [isNavigating, setIsNavigating] = useState(adapter.getIsNavigating());
// ⭐ 订阅路由库的导航状态
useEffect(() => adapter.subscribeNavigation(() => setIsNavigating(adapter.getIsNavigating())), [adapter]);
// ⭐ 组装:滚动恢复(维度 4)
useScrollRestorationByAdapter(adapter);
const push = useCallback(
async (to: string) => {
// ⭐ 带「视图过渡」的导航(内部已做「能力检测 + reduced-motion 降级」)
await withViewTransition(() => adapter.navigate(to));
},
[adapter]
);
const value = useMemo(() => ({ isNavigating, push }), [isNavigating, push]);
return (
<Ctx.Provider value={value}>
{/* ⭐ 进度条(维度 2:即时反馈) */}
<TopProgressBar isNavigating={isNavigating} />
{children}
</Ctx.Provider>
);
}
export function useNavigationExperience() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useNavigationExperience 必须在 NavigationExperienceProvider 内使用');
return ctx;
}
/** 滚动恢复(用 adapter 拿 location key / navigation type) */
function useScrollRestorationByAdapter(adapter: RouterAdapter) {
const positionsRef = useRef(new Map<string, number>());
const currentKeyRef = useRef(adapter.getLocationKey());
useEffect(() => {
if ('scrollRestoration' in history) {
const prev = history.scrollRestoration;
history.scrollRestoration = 'manual';
return () => { history.scrollRestoration = prev; };
}
}, []);
useEffect(() => {
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
positionsRef.current.set(currentKeyRef.current, window.scrollY);
});
};
window.addEventListener('scroll', onScroll, { passive: true });
// ⭐ 每次导航变化时:先「保存旧 key 的位置」,再决定「恢复 or 置顶」
const unsubscribe = adapter.subscribeNavigation(() => {
const nextKey = adapter.getLocationKey();
if (nextKey === currentKeyRef.current) return;
// 保存「离开时」的位置(⭐ 用「上一次记录的值」即可,因为我们一直在记)
const prevKey = currentKeyRef.current;
positionsRef.current.set(prevKey, window.scrollY);
currentKeyRef.current = nextKey;
if (adapter.getNavigationType() === 'POP') {
const saved = positionsRef.current.get(nextKey) ?? 0;
// ⭐ 等「新页面渲染」后再恢复(两次 rAF 是「尽力而为」,真实项目里
// 更好的做法是「等数据加载完成」再恢复,见下文说明)
requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo(0, saved)));
} else {
window.scrollTo(0, 0);
}
});
return () => {
window.removeEventListener('scroll', onScroll);
unsubscribe();
if (raf) cancelAnimationFrame(raf);
};
}, [adapter]);
}
/** ============ 适配器实现:React Router ============ */
export function useReactRouterAdapter(): RouterAdapter {
const navigation = useNavigation();
const location = useLocation();
const navigate = useNavigate();
const navigationType = useNavigationType();
// ⭐ useNavigation 的 state 变化会「触发重渲染」→ 用它做「订阅」
const listenersRef = useRef(new Set<() => void>());
const navStateRef = useRef(navigation.state);
useEffect(() => {
if (navStateRef.current !== navigation.state) {
navStateRef.current = navigation.state;
listenersRef.current.forEach((fn) => fn());
}
if (navStateRef.current !== navigation.state) return;
// ⭐ location 变化也算「导航事件」
listenersRef.current.forEach((fn) => fn());
}, [navigation.state, location.key]);
return useMemo<RouterAdapter>(
() => ({
getIsNavigating: () => navStateRef.current === 'loading',
navigate: (to, opts) => { void navigate(to, { replace: opts?.replace }); },
getLocationKey: () => location.key,
getNavigationType: () => (navigationType as 'PUSH' | 'POP' | 'REPLACE'),
subscribeNavigation: (fn) => {
listenersRef.current.add(fn);
return () => { listenersRef.current.delete(fn); };
},
}),
[navigate, location.key, navigationType]
);
}
/** ============ 适配器实现:TanStack Router ============ */
export function tanstackAdapter(router: {
state: { isLoading: boolean; isTransitioning: boolean; location: { href: string; state?: { __TSR_index?: number } }; history: { location: { state?: { __TSR_index?: number } } } };
navigate: (opts: { to: string; replace?: boolean }) => Promise<void>;
subscribe: (event: string, cb: (state: unknown) => void) => () => void;
}): RouterAdapter {
let prevIndex = router.state.location.state?.__TSR_index ?? 0;
return {
getIsNavigating: () => router.state.isLoading || router.state.isTransitioning,
navigate: (to, opts) => router.navigate({ to, replace: opts?.replace }),
getLocationKey: () => router.state.location.href,
getNavigationType: () => {
// ⭐ TanStack Router 用「history index」判断前后:
// 新 index > 旧 index → PUSH;< → POP
const idx = router.state.location.state?.__TSR_index ?? 0;
const type = idx < prevIndex ? 'POP' : idx > prevIndex ? 'PUSH' : 'REPLACE';
prevIndex = idx;
return type;
},
subscribeNavigation: (fn) => router.subscribe('onResolved', () => fn()),
};
}
/** ============ 业务侧使用(极简) ============ */
function ProductList() {
const { push } = useNavigationExperience();
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{/* ⭐ 用「预取 + 过渡」的导航 */}
<PrefetchLink to={`/products/${p.id}`} onClick={() => void push(`/products/${p.id}`)}>
{p.name}
</PrefetchLink>
</li>
))}
</ul>
);
}
function PrefetchLink({
to, onClick, children,
}: { to: string; onClick: () => void; children: React.ReactNode }) {
const queryClient = useQueryClient();
// ⭐ 维度 1:hover 预取(数据 + 代码)
const intentProps = usePrefetchOnIntent(
useCallback(async () => {
const id = to.split('/').pop()!;
// ⭐ 同时预取「数据」和「页面代码」
await Promise.all([
queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) }),
import('./ProductDetailPage'), // ⭐ 代码分割的 chunk
]);
}, [to, queryClient])
);
return (
<a
href={to}
{...intentProps}
onClick={(e) => { e.preventDefault(); onClick(); }}
>
{children}
</a>
);
}
⭐⭐⭐ 这个「体验层」的五个设计要点:
① 【用「适配器」把路由库的差异隔离】
`getIsNavigating` / `navigate` / `getLocationKey` / `getNavigationType` ——
⭐ 业务代码只依赖「这套接口」,换路由库时「只改适配器」。
② 【滚动恢复必须区分 PUSH / POP】⭐
而且「判断 navigationType」在不同路由库里方式不同:
· React Router:`useNavigationType()`
· TanStack Router:比较 `history.state.__TSR_index`(⭐ 前后比较)
⭐ 这就是「为什么需要适配器」的典型例子。
③ 【进度条与视图过渡是「互补」的】
· 进度条解决「**点击后没反应**」(即时反馈)
· 视图过渡解决「**新旧页面切换生硬**」(连贯感)
⭐ 两者都要 ≠ 浪费(它们解决不同的问题)。
④ 【预取要「数据 + 代码」一起】
⭐ 路由级 code splitting 的项目里,「JS 下载」往往比「数据请求」更慢。
只预取数据 → 点进去仍要「等 chunk」→ 预取的价值大打折扣。
⑤ 【「恢复滚动」的时序要「等数据」】
⚠️ 本实现用「两次 rAF」是「尽力而为」——
如果列表是「异步加载 + 高度不定」,rAF 时「目标位置还不存在」。
✅ 更好的做法:「数据加载完成后」再恢复一次
(⭐ 或「记住可见项 key + 滚到那一项」,对「高度变化」更鲁棒)。
七、本篇特有的坑
// ① hover 立刻预取(鼠标划过一排链接 → 带宽被打满,真正点的那个更慢)
onMouseEnter={() => prefetch()} // ❌
// ✅ 延迟 50~100ms(intent 判定)
// ② 只预取「数据」不预取「代码」(点进去仍要等 chunk)
prefetchQuery(...) // ⚠️ 只做了一半
// ✅ 同时 import('./page')
// ③ 「视口内所有元素」都预取(几十个请求同时发)
// ✅ 只预取「前 N 项」或「hover 的」
// ④ 点击后「没有任何即时反馈」(用户会重复点击)
// ✅ 进度条 + 按钮 pending 态
// ⑤ 进度条爬到 100% 后卡住等(用户以为卡死)
// ✅ 爬到 ~80% 等,完成后快速补到 100% 并淡出
// ⑥ 不区分「POP / PUSH」的滚动行为
// ⚠️ 症状:「返回时回到顶部」或「新页面停在中间」
// ✅ 区分(见上文)
// ⑦ 没关掉 `history.scrollRestoration`(和浏览器打架)
// ✅ 设成 'manual'
// ⑧ 只处理 `window.scrollY`(内部滚动容器失效)
// ✅ 支持「自定义滚动容器」
// ⑨ 「恢复滚动」时「数据还没到」(列表高度不够 → 恢复失败)
// ✅ 等数据加载完再恢复;或用「记住可见项 key」
// ⑩ 用 `view-transition-name` 时「同名冲突」(同屏两个元素同名)
// ⚠️ 浏览器会跳过过渡(或行为异常)
// ✅ 用「业务 key」保证唯一;⭐ 注意「虚拟滚动」与「列表项」的冲突
// ⑪ 视图过渡没做「能力检测 / reduced-motion 降级」
// ✅ 检测 `document.startViewTransition` + `prefers-reduced-motion`
// ⑫ 「过渡动画期间」用户又点击了(过渡被打断)
// ⚠️ 可能抛出 `AbortError` / 「过渡卡在中间」
// ✅ try/catch + `transition.skipTransition()`
// ⑬ 「加载态」用「全屏 spinner」而不是「骨架」
// ⚠️ 全屏 spinner 会导致「整页跳变」(内容区从「空」变成「满」)
// ✅ 骨架屏(**尺寸与真实内容接近**,见 5.15 的 CLS 思路)
// ⑭ 导航失败「白屏」(没有错误边界)
// ✅ 路由级 errorElement/errorComponent + 「重试」按钮
// ⑮ 「导航中的请求」在「离开页面」后仍在跑(竞态)
// ✅ 数据层「取消」或「用路由的生命周期 abort」(TanStack loader 自带)
// ⑯ 把「进度条」用 `width` 做动画(每帧重排)
width: `${progress}%` // ❌
// ✅ transform: scaleX(progress)(只走合成)
// ⑰ 「预取失败」当成「页面加载失败」(用户在 hover 时看到错误提示)
// ⚠️ 预取失败应该「静默」(真正点击时会重新请求)
// ✅ prefetch 的错误「只上报、不提示」
// ⑱ 「Link 的 prefetch」在「登录后/权限变化后」失效(缓存了 401 的结果)
// ⚠️ 症状:预取拿到 401 页面,点进去是错误页
// ✅ 「登出/登录时清掉路由缓存」;⭐ 预取「不要缓存认证失败的响应」
// ⑲ 「过渡动画」用在「同一页面内的筛选」上(每次都淡入淡出 → 很晃)
// ⚠️ 过滤器/分页这类「原地更新」不该用「页面级过渡」
// ✅ 判据:「**是否换了『页面心智』**」——换了才用页面过渡
// ⑳ 忽略「低端设备」(动画 + 预取 + 过渡叠加 → 掉帧)
// ✅ 用 CPU 节流测;⭐ 对低端设备「关掉过渡动画」(可以按 `navigator.hardwareConcurrency` 或「首帧耗时」判断)
// ⑨ 的完整处理(等数据加载完再恢复滚动)
function useScrollRestoreAfterData(ready: boolean) {
const pendingRestoreRef = useRef<number | null>(null);
// ① 在导航时「记下要恢复的位置」
useEffect(() => {
const saved = positions.get(location.key);
if (saved !== undefined && saved > 0) pendingRestoreRef.current = saved;
}, [location.key]);
// ② ⭐ 数据「就绪后」再恢复(此时页面高度才够)
useEffect(() => {
if (!ready) return;
const target = pendingRestoreRef.current;
if (target === null) return;
pendingRestoreRef.current = null;
// ⭐ 等布局完成(两次 rAF 保证「高度已应用」)
requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo(0, target)));
}, [ready]);
}
// ⑳ 的完整处理(按设备能力「降级动画」)
function shouldAnimate(): boolean {
// ① 尊重用户偏好
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;
// ② 低端设备(核数少 / 内存小)→ 关掉「非必要动画」
const cores = navigator.hardwareConcurrency ?? 4;
const memory = (navigator as unknown as { deviceMemory?: number }).deviceMemory ?? 4;
if (cores <= 2 || memory <= 2) return false;
// ③ 用「首帧耗时」粗判(可选)
return true;
}
// 在 withViewTransition 里:
if (!shouldAnimate()) { await update(); return; }
// ⑱ 的完整处理(登录态变化时清掉路由缓存)
// React Query 的 queryClient 清了「数据缓存」,
// 但「路由级的 preload(Next.js / TanStack)」需要显式处理:
useEffect(() => {
if (!isLoggedIn) return;
// ⭐ 登录成功后「失效所有预取结果」(避免「用匿名状态的预取结果渲染已登录页面」)
void router.invalidate();
queryClient.clear();
}, [isLoggedIn]);
八、面试延伸
- 「怎么让 SPA 的导航『感觉很快』?」
⭐ 关键是「把等待藏到用户『还没点击』的那段时间里」:① ⭐ 数据预取(hover/focus/进入视口时提前请求 + 同时预取页面 chunk)——这是第一杠杆,因为它把「点击后的 300ms 等待」变成「点击前的空闲时间」;② ⭐ 即时反馈(进度条 + 按钮 pending 态)——让「点击」立刻有反应,⭐ 否则用户会重复点击(导致重复请求);③ 加载态用骨架(而不是全屏 spinner)——减少「整页跳变」;④ 过渡动画(View Transitions)——提升「新旧页面的连贯感」;⑤ ⭐ 滚动恢复(后退回到原位);⑥ 错误恢复(路由级错误边界 + 重试)。一句话:「预取 + 反馈 + 骨架 + 滚动恢复」是性价比排序的前四名。 -
「预取有哪些坑?」
四条:① ⭐ 「一 hover 就取」会造成「预取抢占」——鼠标划过一排链接时,每个都预取 → 带宽被打满 → 真正点击的那个反而更慢(✅ 延迟 50~100ms 的 intent 判定);② ⭐ 「只预取数据、不预取代码」——路由级 code splitting 的项目里「JS 下载」往往比数据请求更慢,只预取数据 → 点进去仍要等 chunk(✅Promise.all([prefetchQuery, import('./page')]));③ 预取太多——「视口内所有元素」都预取 → 几十个请求同时发(✅ 只预取「前 N 项」或「hover 的那一项」);④ ⭐ 预取失败的展示——预取失败应该静默(真正点击时会重试),❌ 不该在 hover 时给用户弹错误;另外「登录态变化时要清掉预取缓存」(否则可能出现「用匿名预取的结果渲染已登录页面」)。 -
「滚动恢复为什么难?」
四个「魔鬼细节」:① ⭐ 必须区分「POP(后退)」与「PUSH(新导航)」——POP 要恢复位置、PUSH 要滚到顶部(⚠️ 不区分会出现「返回时回顶部」或「新页面停在中间」);② ⭐ 必须关掉history.scrollRestoration(设成'manual'),否则浏览器会和你的逻辑打架;③ ⭐ 要支持「内部滚动容器」(长列表在自己容器里滚时window.scrollY永远是 0);④ ⭐ 「恢复时目标还没渲染」的时序问题——如果列表是异步加载的,返回时数据还没到 → 页面高度不够 → 恢复被截断(看起来「没恢复」)。解法:等数据加载完成后再恢复,或者更好——「记住可见项的 key + 滚到那一项」(对「内容高度变化」更鲁棒)。 -
「View Transitions API 是什么?怎么用?」
它是浏览器原生的「页面切换动画」能力:在document.startViewTransition(() => update())里更新 DOM,浏览器会自动把「旧 DOM 快照」与「新 DOM 快照」做交叉淡入,⭐ 并能让「同名(view-transition-name)的元素」做「共享元素补间」(如「商品卡片 → 详情大图」的神奇效果)。三个必须知道:① ⭐ 它不负责数据加载(那是 loader/Suspense 的事)——正确用法是「先等数据就绪,再在过渡回调里一次性渲染新 UI」,这样过渡是「页面平滑切换」而不是「转圈等数据」;② ⭐view-transition-name必须「唯一且稳定」(同屏两个元素同名会跳过过渡,⚠️ 注意虚拟滚动);③ ⭐ 必须做「能力检测 +prefers-reduced-motion降级」。兼容性:Chrome/Edge 早、Safari 18+、Firefox 较晚 → 必须降级。 -
「加载态该怎么设计?」
三条:① ⭐ 用「骨架」而不是「全屏 spinner」——全屏 spinner 会导致「整页跳变」(内容区从「空」到「满」),而骨架让用户「预期到即将出现的内容」;② ⭐ 骨架的尺寸要与真实内容接近(⭐ 否则「骨架 → 真内容」本身就会造成布局跳动,与 CLS 问题同源,见 5.15);③ ⭐ 「局部加载」用局部骨架(如「只加载表格的行」就只做「行骨架」,而不是「整页转圈」);另外:① 「导航中」用顶部进度条(不阻塞内容);②aria-busy+role="status"让屏幕阅读器知道「正在加载」(见 10.12);③ 不要「闪现」——如果加载在 100ms 内完成,不显示任何加载态(否则用户看到「骨架一闪」比「什么都不显示」更差,⭐ 这与 4.16 的 Suspense 节流是同一个道理)。 -
「导航失败怎么处理?」
三层:① ⭐ 路由级错误边界(React Router 的errorElement/ TanStack Router 的errorComponent)——避免白屏,并显示「出错了 + 重试按钮」(⭐reset()重跑 loader);② ⭐ 区分错误类型——「网络失败」→ 重试、「401」→ 跳登录、「403」→ 403 页、「404」→ 404 页、「500」→ 报错提示(⭐ 见 6.12 的「403 与 404 之分」);③ ⭐ 「失败后保留用户上下文」——不要「清空用户填的内容」(如「提交失败后表单还在」)。另外:① 「重试」要能真的重试(不能只是刷新页面);② 「导航中的请求」在离开时要取消(避免「旧页面的结果写进新页面」);③ 「离线时的导航」要有明确提示(不是「转圈圈直到超时」,见 10.11)。 -
「过渡动画什么时候该用、什么时候不该用?」
⭐ 判据是「是否换了『页面心智』」:① 该用——「列表 → 详情」「A 页面 → B 页面」这类「用户认为是『换了地方』」的导航(⭐ 尤其配「共享元素」(卡片 → 大图)时效果惊艳);② ⭐ 不该用——「同一页面内的筛选/分页/排序」这类「原地更新」——如果每次都淡入淡出,页面会「很晃」,正确的做法是「只更新变化的区域」(或用更轻的「内容淡入」)。另外三条:① 必须做prefers-reduced-motion降级(无障碍);② 低端设备要关掉(CPU 节流下「动画 + 预取」叠加会掉帧,✅ 可以用navigator.hardwareConcurrency/deviceMemory粗判);③ 要处理「过渡被打断」(用户快速连点 →AbortError,✅try/catch+skipTransition())。 -
「这一整套体验优化,投入产出比怎么排?」
⭐ 按「体感提升 / 成本」排序:① ⭐⭐ 滚动恢复(成本最低、抱怨最多——「我翻了 5 页点进详情,返回又回顶部了」);② ⭐⭐ 预取(体感从「300ms 等待」变成「瞬时」,成本中等——路由库往往内建);③ ⭐⭐ 即时反馈(进度条 + pending 态,成本低,⭐ 它还能防止用户重复点击);④ ⭐ 骨架加载态(成本低、减少跳变);⑤ ⭐ 错误恢复(只在出错时体现,但「白屏」的代价很高);⑥ ⭐ 过渡动画(提升「高级感」,但成本中等且要注意降级)。一句话:「先把『滚动恢复 + 预取 + 即时反馈 + 骨架』做掉,再考虑过渡动画」——前四个决定了「快不快、稳不稳」,「过渡动画」决定「好不好看」。
一句话速记
导航体验的六个维度:① 数据预取(⭐ 体感杠杆最大——把等待藏到「还没点击」时)+ ② 即时反馈(进度条 + pending,⭐ 还能防重复点击)+ ③ 加载态(骨架而非全屏 spinner,且尺寸要接近真实内容避免跳变)+ ④ ⭐ **滚动恢复(必须区分 POP 恢复位置 / PUSH 滚到顶部 + 关掉
history.scrollRestoration+ 支持内部滚动容器 + 「等数据就绪再恢复」或「记住可见项 key」) + ⑤ 过渡动画(View Transitions:能力检测 +prefers-reduced-motion降级 +view-transition-name唯一 + ⚠️ 它不负责数据加载——应先等数据再一次性渲染,且「原地更新」不该用页面过渡)+ ⑥ 错误恢复(路由级错误边界 + 区分 401/403/404/500 + 重试可用 + 保留用户上下文);预取的四条纪律:延迟 50~100ms 的 intent 判定(否则「预取抢占」让真正点击的更慢)、数据与代码(chunk)一起预取、限制并发与数量、失败静默(+ 登录态变化时清缓存);用「适配器」隔离路由库差异(getIsNavigating/navigate/getLocationKey/getNavigationType—— 尤其navigationType各库判断方式不同);⭐ **投入产出比排序:滚动恢复 ≈ 预取 ≈ 即时反馈 > 骨架 > 错误恢复 > 过渡动画。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!