陌上人如玉
公子世无双

6.13 导航体验工程化

6.13 导航体验工程化

一句话:导航体验由六个维度决定——「即时反馈(进度条)、数据预取(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]);

八、面试延伸

  1. 「怎么让 SPA 的导航『感觉很快』?」
    ⭐ 关键是「把等待藏到用户『还没点击』的那段时间里」:① ⭐ 数据预取(hover/focus/进入视口时提前请求 + 同时预取页面 chunk)——这是第一杠杆,因为它把「点击后的 300ms 等待」变成「点击前的空闲时间」;② ⭐ 即时反馈(进度条 + 按钮 pending 态)——让「点击」立刻有反应,⭐ 否则用户会重复点击(导致重复请求);③ 加载态用骨架(而不是全屏 spinner)——减少「整页跳变」;④ 过渡动画(View Transitions)——提升「新旧页面的连贯感」;⑤ ⭐ 滚动恢复(后退回到原位);⑥ 错误恢复(路由级错误边界 + 重试)。一句话:「预取 + 反馈 + 骨架 + 滚动恢复」是性价比排序的前四名。

  2. 「预取有哪些坑?」
    四条:① ⭐ 「一 hover 就取」会造成「预取抢占」——鼠标划过一排链接时,每个都预取 → 带宽被打满 → 真正点击的那个反而更慢(✅ 延迟 50~100ms 的 intent 判定);② ⭐ 「只预取数据、不预取代码」——路由级 code splitting 的项目里「JS 下载」往往比数据请求更慢,只预取数据 → 点进去仍要等 chunk(✅ Promise.all([prefetchQuery, import('./page')]));③ 预取太多——「视口内所有元素」都预取 → 几十个请求同时发(✅ 只预取「前 N 项」或「hover 的那一项」);④ ⭐ 预取失败的展示——预取失败应该静默(真正点击时会重试),❌ 不该在 hover 时给用户弹错误;另外「登录态变化时要清掉预取缓存」(否则可能出现「用匿名预取的结果渲染已登录页面」)。

  3. 「滚动恢复为什么难?」
    四个「魔鬼细节」:① ⭐ 必须区分「POP(后退)」与「PUSH(新导航)」——POP 要恢复位置、PUSH 要滚到顶部(⚠️ 不区分会出现「返回时回顶部」或「新页面停在中间」);② ⭐ 必须关掉 history.scrollRestoration(设成 'manual'),否则浏览器会和你的逻辑打架;③ ⭐ 要支持「内部滚动容器」(长列表在自己容器里滚时 window.scrollY 永远是 0);④ ⭐ 「恢复时目标还没渲染」的时序问题——如果列表是异步加载的,返回时数据还没到 → 页面高度不够 → 恢复被截断(看起来「没恢复」)。解法:等数据加载完成后再恢复,或者更好——「记住可见项的 key + 滚到那一项」(对「内容高度变化」更鲁棒)。

  4. 「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 较晚 → 必须降级。

  5. 「加载态该怎么设计?」
    三条:① ⭐ 用「骨架」而不是「全屏 spinner」——全屏 spinner 会导致「整页跳变」(内容区从「空」到「满」),而骨架让用户「预期到即将出现的内容」;② ⭐ 骨架的尺寸要与真实内容接近(⭐ 否则「骨架 → 真内容」本身就会造成布局跳动,与 CLS 问题同源,见 5.15);③ ⭐ 「局部加载」用局部骨架(如「只加载表格的行」就只做「行骨架」,而不是「整页转圈」);另外:① 「导航中」用顶部进度条(不阻塞内容);② aria-busy + role="status" 让屏幕阅读器知道「正在加载」(见 10.12);③ 不要「闪现」——如果加载在 100ms 内完成,不显示任何加载态(否则用户看到「骨架一闪」比「什么都不显示」更差,⭐ 这与 4.16 的 Suspense 节流是同一个道理)。

  6. 「导航失败怎么处理?」
    三层:① ⭐ 路由级错误边界(React Router 的 errorElement / TanStack Router 的 errorComponent)——避免白屏,并显示「出错了 + 重试按钮」(⭐ reset() 重跑 loader);② ⭐ 区分错误类型——「网络失败」→ 重试、「401」→ 跳登录、「403」→ 403 页、「404」→ 404 页、「500」→ 报错提示(⭐ 见 6.12 的「403 与 404 之分」);③ ⭐ 「失败后保留用户上下文」——不要「清空用户填的内容」(如「提交失败后表单还在」)。另外:① 「重试」要能真的重试(不能只是刷新页面);② 「导航中的请求」在离开时要取消(避免「旧页面的结果写进新页面」);③ 「离线时的导航」要有明确提示(不是「转圈圈直到超时」,见 10.11)。

  7. 「过渡动画什么时候该用、什么时候不该用?」
    ⭐ 判据是「是否换了『页面心智』」:① 该用——「列表 → 详情」「A 页面 → B 页面」这类「用户认为是『换了地方』」的导航(⭐ 尤其配「共享元素」(卡片 → 大图)时效果惊艳);② ⭐ 不该用——「同一页面内的筛选/分页/排序」这类「原地更新」——如果每次都淡入淡出,页面会「很晃」,正确的做法是「只更新变化的区域」(或用更轻的「内容淡入」)。另外三条:① 必须做 prefers-reduced-motion 降级(无障碍);② 低端设备要关掉(CPU 节流下「动画 + 预取」叠加会掉帧,✅ 可以用 navigator.hardwareConcurrency/deviceMemory 粗判);③ 要处理「过渡被打断」(用户快速连点 → AbortError,✅ try/catch + skipTransition())。

  8. 「这一整套体验优化,投入产出比怎么排?」
    ⭐ 按「体感提升 / 成本」排序:① ⭐⭐ 滚动恢复(成本最低、抱怨最多——「我翻了 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 各库判断方式不同);⭐ **投入产出比排序:滚动恢复 ≈ 预取 ≈ 即时反馈 > 骨架 > 错误恢复 > 过渡动画。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.13 导航体验工程化

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫