首页
/ React Router Link 组件深度解析:渐进增强导航与预取机制全解

React Router Link 组件深度解析:渐进增强导航与预取机制全解

2026-09-06 22:35:10作者:郁楠烈Hubert

本文基于 react-router 官方 API 文档 Link 及其源码实现,系统讲解 <Link> 组件的全部 Props、点击处理的底层调用链,以及 prefetch / discover 两个预取机制在 framework mode 下的真实工作方式。读完你可以掌握:如何正确配置链接的预取与懒路由发现行为、理解 data-discover<link rel="prefetch"> 的渲染时机、以及 mask 等高级导航场景的落地方式。

一、Link 是什么:一个渐进增强的 <a> 封装

官方文档的定义非常简洁:Link 是一个"渐进增强的 <a href> wrapper to enable navigation with client-side routing"。也就是说,它最终渲染出来的就是一个普通的 <a> 标签,但额外接管了点击事件以启用客户端路由。

文档给出的基础用法:

import { Link } from "react-router";

<Link to="/dashboard">Dashboard</Link>;

<Link
  to={{
    pathname: "/some/path",
    search: "?query=string",
    hash: "#hash",
  }}
/>;

to 既可以是字符串,也可以是部分 Path 对象(pathname / search / hash 三个可选字段)。这一文档标注的适用模式为 [MODES: framework, data, declarative],即三种路由模式下都可用。

从源码看(lib.tsx),Link 是一个 React.forwardRef 组件,默认值在函数参数解构中直接给出:

// packages/react-router/lib/dom/lib.tsx
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    {
      onClick,
      discover = "render",   // discover 默认为 "render"
      prefetch = "none",     // prefetch 默认为 "none"
      relative,
      reloadDocument,
      replace,
      mask,
      state,
      target,
      to,
      preventScrollReset,
      viewTransition,
      defaultShouldRevalidate,
      ...rest
    },
    forwardedRef,
  ) {

两个关键判断决定了渲染出的 <a> 是否走客户端路由(lib.tsx):

let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);
let parsed = parseToInfo(to, basename);

let isSpaLink = !(parsed.isExternal || reloadDocument);
let link = (
  <a
    {...rest}
    {...prefetchHandlers}
    href={(isSpaLink ? maskedHref : undefined) || parsed.absoluteURL || href}
    onClick={isSpaLink ? handleClick : onClick}
    ref={mergeRefs(forwardedRef, prefetchRef)}
    target={target}
    data-discover={!isAbsolute && discover === "render" ? "true" : undefined}
  />
);

由此可以确认三个行为:

  1. 外部链接tohttp(s):// 开头)会被识别为 isExternal,此时 isSpaLinkfalse,组件直接透传用户自己的 onClick,不做任何拦截——行为与原生 <a> 完全一致;
  2. reloadDocument 链接同样被视为非 SPA 链接,点击时浏览器执行整页跳转;
  3. data-discover 属性只在 to 是站内相对路径且 discover="render" 时才渲染为 true,它是 framework mode 下懒路由发现的钩子(见第四节)。

由于 forwardedRef 被合并后挂到 <a> 上,你可以用 ref 直接拿到 HTMLAnchorElement,这也是 prefetch="viewport" 能观察元素位置的前提。

二、Props 总览

LinkProps 接口定义在 lib.tsx,继承自 React.AnchorHTMLAttributes<HTMLAnchorElement> 并移除 hrefhrefto 计算得出,不可直接指定)。完整 Props 一览:

Prop 类型 默认值 适用模式 作用
to string | Path 必填 framework / data / declarative 导航目标
discover "render" | "none" "render" framework 懒路由发现时机
prefetch "none" | "intent" | "render" | "viewport" "none" framework 数据/模块预取时机
relative "route" | "path" "route" 全部 相对路径解析基准
reloadDocument boolean false 全部 点击时整页跳转
replace boolean 自动 全部 替换而非压栈
state any 全部 附加客户端 location state
preventScrollReset boolean false framework / data 点击后不重置滚动位置
viewTransition boolean false framework / data 为该导航启用 View Transition
defaultShouldRevalidate boolean 标准行为 全部 控制导航后的 loader 再验证
mask string | Path framework / data 导航到一处、URL 栏显示另一处

下面按主题分组深入讲解。

三、relative:路径解析的基准

<Link to=".." /> // 默认 "route"
<Link relative="route" />
<Link relative="path" />

文档用一个路由层级示例说明两种基准的区别:父路由 pattern 为 "blog",子路由 pattern 为 "blog/:slug/edit"

  • route(默认):相对 路由 pattern 解析。上例中 to=".." 会一次性去掉 :slug/edit 两段,回到 "/blog"
  • path:相对 实际 URL 解析。上例中 to=".." 只去掉最后一个 URL 段,得到 "/blog/:slug" 对应的实际路径(如 /blog/hello)。

文档特别提示:index 路由和 layout 路由没有路径,因此不参与相对路径计算。链接最终通过 useHref(to, { relative }) 计算 hreflib.tsx),点击导航时同样以该基准解析(见第五节 useLinkClickHandler 中的 useResolvedPath(to, { relative }))。

四、discover:懒路由发现的开关

discover 控制 lazy route discovery 行为,是 framework mode 特有的 Props:

  • render(默认)——链接渲染时就发现(discovery)其指向的路由;
  • none——不主动发现,只有点击时才触发发现。
<Link />                // 默认 "render"
<Link discover="render" />
<Link discover="none" />

源码层面的实现在 fog-of-war.tsuseFogOFWarDiscovery 中,可以确认它的完整工作机制:

// packages/react-router/lib/dom/ssr/fog-of-war.ts
let debouncedFetchPatches = debounce(fetchPatches, 100);

// scan and fetch initial links
fetchPatches();

// Setup a MutationObserver to fetch all subsequently rendered links/form
let observer = new MutationObserver(() => debouncedFetchPatches());
observer.observe(document.documentElement, {
  subtree: true,
  childList: true,
  attributes: true,
  attributeFilter: ["data-discover", "href", "action"],
});
  • 首次渲染后,它执行 document.querySelectorAll("a[data-discover], form[data-discover]") 扫描初始 DOM(fog-of-war.ts),把各链接 href 的 pathname 注册为待发现路径,再调用 fetchAndApplyManifestPatches 拉取 manifest 补丁,通过 router.patchRoutes 动态注入路由;
  • 之后通过 MutationObserver 监听 data-discover / href / action 属性变化,100ms 防抖后重新扫描,因此 SPA 内后续渲染出的 <Link> 也会被自动发现;
  • 一个值得注意的细节:发现机制在 navigator.connection.saveData === true(用户开启了省流量模式)时直接跳过(fog-of-war.ts),这是对移动端流量的保护。

测试侧同样验证了这个渲染契约:SSR 输出断言链接带 data-discover="true" 属性,见 data-static-router-test.tsx,客户端测试 data-browser-router-test.tsx 中也有大量相同断言。

因此可以总结为一条清晰的因果链:discover="render"<a data-discover="true"> → 客户端发现器扫描并 patch 路由;设为 "none" 时属性不渲染,发现推迟到点击导航那一刻。

五、prefetch:四种预取时机及其实现

prefetch 同样是 framework mode 特有 Props,类型定义在 components.tsx

export type PrefetchBehavior = "intent" | "render" | "none" | "viewport";
取值 触发时机
none(默认) 不预取
intent 用户 hover 或 focus 链接时
render 链接渲染完成时
viewport 链接进入视口时(对移动端很有用)

文档强调预取是通过 HTML <link rel="prefetch"> 标签实现的,且插入在链接之后

<a href="..." />
<a href="..." />
<link rel="prefetch" /> // might conditionally render

这带来一个实际的 CSS 陷阱:如果你用 nav :last-child 之类的选择器,当最后一个链接条件渲染出 prefetch 标签后,样式会"掉下来"。文档建议改用 nav :last-of-type 等同类选择器。这个渲染结构在源码中可以对应确认:

// packages/react-router/lib/dom/lib.tsx
return shouldPrefetch && !isAbsolute ? (
  <>
    {link}
    <PrefetchPageLinks page={href} />
  </>
) : (
  link
);

<PrefetchPageLinks> 只在 shouldPrefetch 为真且不是绝对 URL 时渲染在 <a> 之后,并且是"条件渲染"的——这正是 :last-child 失效的原因。

各时机的底层实现都在 usePrefetchBehavior 中,可以逐条印证:

React.useEffect(() => {
  if (prefetch === "render") {
    setShouldPrefetch(true);                    // 渲染即预取
  }

  if (prefetch === "viewport") {
    let callback: IntersectionObserverCallback = (entries) => {
      entries.forEach((entry) => {
        setShouldPrefetch(entry.isIntersecting);
      });
    };
    let observer = new IntersectionObserver(callback, { threshold: 0.5 });
    if (ref.current) observer.observe(ref.current);
    return () => { observer.disconnect(); };
  }
}, [prefetch]);
  • renderuseEffect 中直接置位,挂载后立即预取;
  • viewport:用 IntersectionObserverthreshold: 0.5——元素至少 50% 可见才预取,移出视口后 shouldPrefetch 会重新置 false,prefetch 标签随之移除(预取是动态开关的);
  • intent:给元素挂上 onFocus / onBlur / onMouseEnter / onMouseLeave / onTouchStart 处理器,触发后先置 maybePrefetch,再经一个 100ms 定时器才真正 setShouldPrefetch(true)components.tsx);onBlur / onMouseLeave 则同时取消两个状态。这个 100ms 延迟意味着用户只是快速掠过链接(鼠标扫过列表等)时不会触发预取,只有"悬停停留"这种真实意图才会。

另外两处实现细节值得注意:

  1. 所有事件处理器经 composeEventHandlers 合成,用户自己传入的 onMouseEnter 等仍会先执行,且只要事件未被 preventDefault 就继续内部逻辑(components.tsx);
  2. usePrefetchBehavior 开头有一个判断:if (!frameworkContext) return [false, ref, {}]components.tsx),注释写明 "No prefetching if not using SSR"——即预取依赖 framework mode 的服务端构建产物(FrameworkContext),data / declarative 模式下不生效,与文档标注的 [modes: framework] 一致。

六、点击处理:shouldProcessLinkClick 与 replace 的自动判断

Link 的点击行为由 useLinkClickHandler 驱动:

export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
  to: To,
  {
    target,
    replace: replaceProp,
    mask,
    state,
    preventScrollReset,
    relative,
    viewTransition,
    defaultShouldRevalidate,
    useTransitions,
  } = {},
) {
  let navigate = useNavigate();
  let location = useLocation();
  let path = useResolvedPath(to, { relative });

  return React.useCallback(
    (event: React.MouseEvent<E, MouseEvent>) => {
      if (shouldProcessLinkClick(event, target)) {
        event.preventDefault();

        // If the URL hasn't changed, a regular <a> will do a replace instead of
        // a push, so do the same here unless the replace prop is explicitly set
        let replace =
          replaceProp !== undefined
            ? replaceProp
            : createPath(location) === createPath(path);

        let doNavigate = () =>
          navigate(to, {
            replace,
            mask,
            state,
            preventScrollReset,
            relative,
            viewTransition,
            defaultShouldRevalidate,
          });

这里有两条从源码确认的重要行为:

(1)什么样的点击才交给路由处理? 判断逻辑在 dom.ts

export function shouldProcessLinkClick(
  event: LimitedMouseEvent,
  target?: string,
) {
  return (
    event.button === 0 && // Ignore everything but left clicks
    (!target || target === "_self") && // Let browser handle "target=_blank" etc.
    !isModifiedEvent(event) // Ignore clicks with modifier keys
  );
}

即:仅处理左键单击target 为空或 _self、且未按住 meta / alt / ctrl / shift 的点击。右键、target="_blank"、Cmd/Ctrl+Click 全部放行给浏览器,这也是渐进增强语义的一部分——任何时刻链接都是真实可用的 <a>

(2)replace 的自动推断。 若未显式传 replace,当目标 URL 与当前 location 完全相同时,replace 自动取 true——与原生 <a> 点击"同 URL 时不产生新历史条目"的行为保持一致。例如点击一个只改变 hash 的链接,或 to="/a" 时已经在 /a,都不会把历史栈撑长。

只有当用户自定义 onClick 没有 preventDefault 时,内部 handler 才会执行(lib.tsxhandleClick 中先调用外部 onClick!event.defaultPrevented 才走 internalOnClick),因此业务代码可以在自己的 onClickevent.preventDefault() 来取消路由导航。

七、导航控制类 Props:replace、state、preventScrollReset、reloadDocument

replace

<Link replace />
# with a history stack like this
A -> B

# normal link click pushes a new entry
A -> B -> C

# but with `replace`, B is replaced by C
A -> C

替换 History 栈中的当前条目而不是压入新条目。结合第六节的源码可知,显式传 replace 会覆盖 URL 相同时的自动推断逻辑。

state

为下一个 location 附加纯客户端路由状态:

<Link to="/somewhere/else" state={{ some: "value" }} />

在目标路由中通过 location 读取:

function SomeComp() {
  const location = useLocation();
  location.state; // { some: "value" }
}

文档明确:该状态基于 history.state 实现,服务端无法访问——SSR 阶段 location.state 始终为 undefined

preventScrollReset

当应用启用了 ScrollRestoration 时,点击 Link 默认会把滚动位置重置到顶部;preventScrollReset 可阻止"新 location 重置滚动"这一行为(但前进/后退导航仍会恢复各自保存的滚动位置)。典型场景是只切换查询参数的导航:

<Link to="?tab=one" preventScrollReset />

reloadDocument

<Link to="/logout" reloadDocument />

点击时绕过客户端路由,由浏览器按普通 <a href> 的方式整页跳转。从源码看它直接把链接标记为非 SPA 链接(isSpaLinkfalse),并优先使用 parsed.absoluteURL 作为 href。适合登出、需要刷新缓存等场景。

八、viewTransition 与 defaultShouldRevalidate

viewTransition

为该次导航启用浏览器 View Transitions API

<Link to={to} viewTransition>
  Click me
</Link>

如需为过渡过程指定样式(如 ::view-transition-old / ::view-transition-new),配合 useViewTransitionState 判断当前是否处于过渡中。源码层面该值经 navigate(to, { viewTransition, ... }) 传递,最终由路由决定是否调用 document.startViewTransition(见 hooks.tsxuseNavigate 的参数说明)。

defaultShouldRevalidate

<Link to="/some/path" defaultShouldRevalidate={false} />

指定本次导航的默认再验证(revalidation)行为

  • 若激活路由上没有任何 shouldRevalidate 函数,则直接使用这个值决定是否重跑 loader;
  • 若存在 shouldRevalidate,该值会作为参数传入,由路由做最终决定。

典型用途:更新 search params 时不想触发所有 loader 重新取数,即 defaultShouldRevalidate={false}。不指定时保持 router 的标准再验证行为。

九、mask:URL 与导航目标分离

mask(framework / data 模式)用于"导航到一处、地址栏显示另一处":

// routes/gallery.tsx
export function clientLoader({ request }: Route.LoaderArgs) {
  let sp = new URL(request.url).searchParams;
  return {
    images: getImages(),
    modalImage: sp.has("image") ? getImage(sp.get("image")!) : null,
  };
}

export default function Gallery({ loaderData }: Route.ComponentProps) {
  return (
    <>
      <GalleryGrid>
       {loaderData.images.map((image) => (
         <Link
           key={image.id}
           to={`/gallery?image=${image.id}`}
           mask={`/images/${image.id}`}
         >
           <img src={image.url} alt={image.alt} />
         </Link>
       ))}
      </GalleryGrid>

      {data.modalImage ? (
        <dialog open>
          <img src={data.modalImage.url} alt={data.modalImage.alt} />
        </dialog>
      ) : null}
    </>
  );
}

这是文档给出的画廊示例:点击某张图片后,路由器加载的是 /gallery?image=xxx(底层画廊上下文保持激活),但 URL 栏显示的是 /images/xxx。用户如果分享这个被 mask 的 URL 或在新标签页打开,只会加载 mask 指向的位置,不会带上底层上下文——这让"模态上下文"拥有了独立可分享的 URL 身份。

文档明确了两个限制:该特性依赖 history.state只面向 SPA 使用,SSR 渲染不会体现 mask。源码中也可以看到 mask 的解析细节:maskedHref 是"以 mask 自身的路径为基准"内联复刻的 useHref 逻辑(lib.tsx),并同样会拼上 basename;导航时 mask 一并传给 navigate,由路由写入 location.mask

十、与相关组件的边界

  • NavLinkLink 的直接超集。NavLinkProps 继承 LinkProps 并新增 isActive / isPending 状态回调(lib.tsx),适合需要高亮当前项的导航菜单;普通跳转用 Link 即可。
  • useLinkClickHandlerLink 内部使用的 hook 也是公开 API(经 index.ts 导出)。当你用原生 <a><button> 自己渲染链接时,直接调用它即可获得完全一致的点击处理(含 shouldProcessLinkClick 过滤与 replace 自动推断)。
  • useHref / useResolvedPath:分别是 Link 计算 href 与解析 to 的底层工具,若需在不渲染链接的地方取 URL 可直接使用。
  • PrefetchPageLinksprefetch="render" / "viewport" 触发后实际渲染 prefetch 标签的组件,一般无需直接使用。

小结

<Link> 看似只是 <a> 的薄封装,但源码揭示了它承载的三层职责:

  1. 渐进增强——外部链接、修饰键点击、非左键点击全部透传给浏览器,链接在任何时刻都是可用的真实锚点(shouldProcessLinkClick);
  2. framework mode 的性能优化管道——discover 通过 data-discover 属性接入 fog-of-war 路由发现prefetch 通过 usePrefetchBehavior + <PrefetchPageLinks> 在 intent/render/viewport 时机条件渲染 <link rel="prefetch">
  3. 导航语义控制——relative / replace / state / preventScrollReset / viewTransition / defaultShouldRevalidate / mask 覆盖从历史栈到 loader 再验证的完整导航行为。

配置时记住三个默认值即可快速上手:discover="render"prefetch="none"relative="route";其余 Props 按需在 framework / data / declarative 三种模式下各取所需。

登录后查看全文
热门项目推荐
相关项目推荐