发布日期

第 10 讲|内置组件原理:Link / Form / Image / Script

Link / Form / Image / Script 四大内置组件的实现原理与性能策略

阶段二:App Router 核心机制 · 第 10 / 40 讲 难度:⭐⭐⭐⭐ · 预计耗时:3 小时 配套 fixture:fixtures/lecture-10/

学习目标

  • 理解 <Link> 的两条 prefetch 触发链:viewport(IntersectionObserver)与 hover/touchstart。
  • 看懂 <Form> 的双形态:action 是字符串 → 客户端导航;action 是函数 → Server Action。
  • <Image> 的关键路径:loader 选择、sizes / srcSet 推导、placeholder blur 与 LQIP。
  • 区分 <Script> 的 4 种 strategy(beforeInteractive / afterInteractive / lazyOnload / worker)执行时机。
  • 在 fixture 中观察网络请求与 React DevTools 上的内部组件。

1. <Link>:客户端导航的"用户接口"

packages/next/src/client/link.tsx 大致 700 行,但核心逻辑可拆 3 块:

  1. prefetch(router, href, ...) 模块级函数。
  2. Link 组件本身(React.forwardRef),合并 ref + 收口 prop 校验。
  3. 两类触发:viewport(useIntersectionObserver)与交互(onMouseEnter / onTouchStart)。

1.1 模块级 prefetch & 已访问集合

200:packages/next/src/client/link.tsx
const prefetched = new Set<string>()

/**
 * The function is a wrapper around router.prefetch that determines
 * if it should be called or not based on the current state of the
 * link
 */
function prefetch(
  router: ...,
  href: string,
  as: string,
  options: ...
) {
  // ...
  if (process.env.NODE_ENV !== 'production') {
    // ...
  } else {
    const prefetchedKey = href + '%' + as + '%' + locale

    // If we've already fetched the key, then don't prefetch it again!
    if (prefetched.has(prefetchedKey)) {
      return
    }

    // Mark this URL as prefetched.
    prefetched.add(prefetchedKey)
  }

  // We need to handle a prefetch error here since we may be
  // mid-navigation when the prefetch is queued, in which case we don't
  // want to force navigation since this is only a prefetch
  router.prefetch(href, as, options).catch((err) => {
    // swallow
  })
}

要点:

  • prefetched 是模块级 Set,全局去重——避免同一个 href 被多次 prefetch。
  • dev 模式下不去重(让你看清每次 prefetch 触发;同时 dev 编译懒触发)。
  • 真正干活的还是 router.prefetch——经过第 9 讲讲过的 segment cache 链路。

1.2 prefetch 三态:true / false / 'auto'

prefetchProp 类型 boolean | 'auto' | null

  • 'auto' / null / undefined(默认):静态页面拉全量 RSC;动态页面只拉到最近的 loading 边界。这就是 hasLoadingComponentInTree 在第 8 讲提到的应用场景。
  • true:拉全量 RSC,无视 loading 边界。
  • false:完全关闭——既不在 viewport 触发,也不在 hover 触发。

生产排查提示:列表卡顿、Network 面板被 _rsc 请求刷屏?通常不是 prefetch 太多,而是某些 <Link> 写了 prefetch={true} 强制全量。改回默认 'auto'

1.3 viewport 触发:useIntersectionObserver

Link 内部用 IntersectionObserver 监听自身可见性。一旦进入视口(哪怕滚动几个像素),就调用 prefetch(...)

583:packages/next/src/client/link.tsx
      // If we don't need to prefetch the URL, don't do prefetch.
      if (!isVisible || !prefetchEnabled) {
        return
      }

      prefetch(router, href, as, {
        kind: appPrefetchKind,
      })

1.4 交互触发:hover / touchstart

396:packages/next/src/client/link.tsx
        onMouseEnter: true,
        onTouchStart: true,

如果 viewport 没触发(链接还没滚到视口),用户鼠标移上去也会触发。这两条触发链是叠加的,不是排他的——保险起见两边都监听。

2. <Form>:把表单变成路由

packages/next/src/client/form.tsx 200 行不到,但语义颇深。

2.1 action 类型决定行为

27:packages/next/src/client/form.tsx
  const actionProp = props.action
  const isNavigatingForm = typeof actionProp === 'string'

  // Validate `action`
  • action 是字符串 → 走客户端导航路径,等同于 <Link> 的 form 版本。
  • action 是函数 → Server Action,由 React 接管,序列化参数走 RSC payload。

2.2 字符串 action 的提交流程

88:packages/next/src/client/form.tsx
  const actionHref = addBasePath(actionProp)

  return (
    <form
      ref={ref}
      {...rest}
      action={actionHref}
      onSubmit={(event) =>
        onFormSubmit(
          // ...
        )
      }
    />
  )

export default Form

onFormSubmit 内部会读取表单字段:

170:packages/next/src/client/form.tsx
  let action = actionHref

  if (submitter && submitter.formAction) {
    // from the attributes that react adds for server actions.
    // ...
    // client actions have `formAction="javascript:..."`. We obviously can't prefetch/navigate to that.
    if (!submitterFormAction.startsWith('javascript:')) {
      action = submitterFormAction
    }
  }

  const targetUrl = createFormSubmitDestinationUrl(action, formElement)

  const method = replace ? 'replace' : 'push'

  // ...
  router[method](targetHref, undefined, { scroll })

要点:

  1. 读 form 元素,把字段拼成 query string(createFormSubmitDestinationUrl)。
  2. router.pushrouter.replace 触发客户端导航。
  3. 整个过程不走真正的 HTTP form submit——是用 client router 模拟的。

生产排查提示:搜索框用 <Form action="/search"> 提交后,URL 类似 /search?q=hello。如果你期望 POST 请求,必须用 server action 形态(action={async (formData) => { 'use server'; ... }})。

2.3 函数 action 的提交流程

函数 action 由 React 处理,框架不再插手,但需要警告"replace scroll 在函数 action 下无效":

48:packages/next/src/client/form.tsx
        'Passing `replace` or `scroll` to a <Form> whose `action` is a function has no effect.\n' +
          'See the relevant docs to learn how to control this behavior for navigations triggered from actions:\n' +

业务案例:购物车"立即购买"按钮——既要 server-side 扣库存,又要导航到结算页:

async function buyNow(formData: FormData) {
  "use server";
  await reserve(formData);
  redirect("/checkout"); // 用 next/navigation 的 redirect 触发导航
}

<Form action={buyNow}>
  <input name="productId" value={id} />
  <button type="submit">立即购买</button>
</Form>;

redirect 内部会抛特殊异常,被服务端 React 捕获,最终引导客户端 router 走 navigate。

3. <Image>:图像优化的 14 件事

packages/next/src/client/image-component.tsx 是个 600+ 行的"瑞士军刀"。看主路径就好。

3.1 默认 loader

37:packages/next/src/client/image-component.tsx
import defaultLoader from 'next/dist/shared/lib/image-loader'

const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete

__NEXT_IMAGE_OPTS 来自构建期的 define-env(参考第 6 讲)。defaultLoader 知道怎么把 src 转为优化后的 URL(如走 /_next/image proxy)。如果用户配置了第三方 loader(Cloudinary、imgix),构建期会替换这个常量。

3.2 src 类型分支

src 可以是 stringimport('img.png') 静态导入对象。后者由 SWC / webpack 在编译期生成 { src, height, width, blurDataURL }

  • 静态导入:自动得到尺寸 → 保留 layout 不抖动 → 自动 blur LQIP。
  • 动态字符串:必须显式传 width heightfill,否则报错。

3.3 srcSet 推导

generateImgAttrs 利用 deviceSizes + imageSizes 配置(来自 next.config.js)生成多档 srcSet。前端浏览器自己挑合适的尺寸。

sizes 属性是关键提示——告诉浏览器"这张图占多少视口宽度"。如果不写,默认是 100vw(最大档位都拉),常见的 LCP 大问题。

3.4 placeholder 的两种实现

  • placeholder="blur" + 静态导入:用编译期生成的 blurDataURL(base64 8x8 缩略图)做 inline data URI。
  • placeholder="blur" + 动态 src:必须显式传 blurDataURL,否则报错。

加载完成后通过 handleLoading 移除 blur:

88:packages/next/src/client/image-component.tsx
function handleLoading(
  img: ImgElementWithDataProp,
  placeholder: PlaceholderValue,
  // ...
) {
  // ...
    if (placeholder !== 'empty') {
      // ...

3.5 priority 与 fetchpriority

181:packages/next/src/client/image-component.tsx
function getDynamicProps(
  fetchPriority?: string,
  // ...
) {
  // ...
  return { fetchpriority: fetchPriority }
}

priority 不只是优先级标记——它会:

  • fetchpriority="high" HTML 属性。
  • 通过 <ImagePreload><head> 里插入 <link rel="preload">
  • 跳过懒加载(loading="eager")。

生产排查提示:LCP 慢?打开 Lighthouse → Diagnostics → "Image elements do not have explicit width and height" 与 "Largest Contentful Paint image was lazily loaded"。前者要静态导入或显式传尺寸;后者要 priority

3.6 ImagePreload

374:packages/next/src/client/image-component.tsx
function ImagePreload({
  // ...
}) {
  // ...
}

只在 priority 为 true 时挂出,使用 react-dom/serverpreload() API 在文档头部下发 <link rel="preload" as="image" imagesrcset="...">,让浏览器与 HTML 同时拉图像。

4. <Script>:4 种 strategy 的执行时机

packages/next/src/client/script.tsx 不到 400 行,但 strategy 行为是面试与排查高频问题。

4.1 strategy 4 选 1

15:packages/next/src/client/script.tsx
export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
  strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
Strategy执行时机典型场景
beforeInteractive在 React hydrate 之前(首屏 HTML 内联)polyfill / consent banner
afterInteractive(默认)hydrate 之后立刻加载分析、错误监控
lazyOnloadwindow load 事件后通过 requestIdleCallback 加载客服弹窗、A/B 测试
worker通过 Partytown 走 Web Worker(需配置)第三方追踪降低主线程压力

4.2 ScriptCache + LoadCache

12:packages/next/src/client/script.tsx
const ScriptCache = new Map()
const LoadCache = new Set()

ScriptCache 记录"src → loaded promise";LoadCache 记录"已经触发过加载"。两层防止重复加载——多个组件挂同一个 <Script> 也只下载一次。

4.3 lazyOnload 的延迟

176:packages/next/src/client/script.tsx
function loadLazyScript(props: ScriptProps) {
  if (document.readyState === 'complete') {
    requestIdleCallback(() => loadScript(props))
  } else {
    window.addEventListener('load', () => {
      requestIdleCallback(() => loadScript(props))
    })
  }
}

注意是"window load 之后再 requestIdleCallback"——双重延迟,最大化保护 LCP。

4.4 beforeInteractive 必须放 layout

beforeInteractive 只在根 layout 中生效——如果你写在 page 里,框架会忽略并警告。原因:它需要在 HTML 流式输出阶段就插入 <script>,page 里写已经太晚了。

生产排查提示beforeInteractive 的脚本 race condition 难调——尽量只用于"必须最先执行"的少数脚本。普通分析、上报放 afterInteractive 就够了。

5. 业务案例:电商商品卡

电商商品卡是典型的 4 个组件齐用:

// app/products/_components/product-card.tsx
import Image from "next/image";
import Link from "next/link";
import Form from "next/form";

export function ProductCard({ product }: { product: Product }) {
  return (
    <article>
      <Link href={`/products/${product.id}`} prefetch={null}>
        <Image
          src={product.cover}
          alt={product.title}
          width={400}
          height={300}
          sizes="(max-width: 768px) 100vw, 33vw"
          placeholder="blur"
          blurDataURL={product.coverBlur}
        />
        <h3>{product.title}</h3>
      </Link>

      <Form action={addToCartAction}>
        <input type="hidden" name="productId" value={product.id} />
        <button type="submit">加入购物车</button>
      </Form>
    </article>
  );
}

加上一个分析脚本:

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://example.com/analytics.js"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

观察这套组合的网络瀑布图:

  1. HTML 流式输出(含 <link rel="preload"> 用于 priority 图)。
  2. Hydrate 完成后 analytics.js 异步加载(不阻塞 LCP)。
  3. 卡片进入视口 → <Link> 触发 prefetch RSC。
  4. 用户点击 "加入购物车" → 函数 action 序列化 FormData → POST /products/... 走 RSC payload。

生产排查提示:购物车加入按钮"点了没反应"?先看 DevTools Network 面板有没有 RSC 请求;没有则 server action 没注册——多半是组件没在 server bundle,可能是被 'use client' 误标。

6. 重难点

很多人误以为 <Link> 自己缓存数据。它不缓存——它只触发 router.prefetch,真正缓存在 segment cache(第 9 讲)。所以"prefetched 没生效"应该看 segment cache 调试,不是 Link 本身。

6.2 dev 模式的 prefetch 行为

dev 下 prefetch 不去重(每次都触发),还会更激进:每次悬停都重新拉。这是为了让你"看到 prefetch 在跑",便于调试。生产环境不要用 dev 行为做基准评估。

6.3 <Form> 的字符串 action 与 form action 属性的兼容

老 React 项目常见写法 <form action="/search">。next/form 兼容这个写法但不要把它和函数 action 混用——一个 form 同时挂字符串与函数 action 行为不可预期。

6.4 Image 的 sizes 推导失败时

如果你写了 fill 但忘了写 sizes,浏览器只能选最大档(如 3840w),导致带宽爆炸。next/image 会在 dev 控制台 warning,但 prod 不警告。code review 时务必检查 <Image fill>sizes

6.5 Script worker strategy 的前置依赖

strategy="worker" 要求安装并启用 Partytown。next.config.js 里需要 experimental.nextScriptWorkers: true。配置不到位时这条 strategy 会 warn 并退化为 afterInteractive。

7. 配套 fixture:四组件演示

fixtures/lecture-10/ 包含 4 个示例:

  1. app/example-1-link/ — 三种 prefetch 取值对比(auto / true / false)。
  2. app/example-2-form/ — 字符串 action 与函数 action 各一例。
  3. app/example-3-image/ — placeholder blur、priority、sizes 演示。
  4. app/example-4-script/ — 4 种 strategy 同页加载,观察执行顺序。

7.1 推荐实验

cd learning/nextjs-40-lectures/fixtures/lecture-10
pnpm install --ignore-workspace
pnpm dev
# 浏览器打开 http://localhost:3010/

实验 A:Link 三态

  • 打开 /example-1,开 DevTools Network 筛 RSC
  • 滚动让三个 Link 都进入视口;只有 auto/true 的会触发请求。
  • 在控制台手动 hover prefetch={false} 的链接,验证不触发。

实验 B:Form 双形态

  • /example-2/string:提交后 URL 变 /example-2/string?q=...
  • /example-2/function:提交后服务端打 console,再 redirect 回首页。

实验 C:Image 加载策略

  • /example-3 含 3 张图:priority / 默认 / fill+sizes。
  • Network 面板看 <link rel="preload"> 是否对 priority 图发出。

实验 D:Script 执行顺序

  • /example-4 加载 4 个脚本,每个脚本里 console.log 自身名字。
  • 观察 console 的输出顺序:beforeInteractive → afterInteractive → lazyOnload。

8. 检验问题

  1. <Link> 的两条 prefetch 触发链分别是什么?哪些 prop 关掉?
  2. prefetch="auto" / true / false 各对应什么行为?区别如何反映到 segment cache?
  3. <Form> 区分字符串 action 与函数 action 的代码在哪一行?
  4. <Form action={fn}> 中的 replace scroll 为什么会被忽略?
  5. <Image> 静态导入与动态 src 的关键差异是什么?
  6. priority 属性触发了哪些 HTML 属性 / preload?
  7. 4 种 Script strategy 的执行时机分别是?为什么 lazyOnload 用双重延迟?
  8. ScriptCache 与 LoadCache 各自防什么?
  9. dev 模式的 prefetch 行为为什么与 prod 不同?
  10. fixture 的实验 D 中,console 输出顺序是怎样的?怎么解释?

9. 延伸阅读

  • 源码:packages/next/src/client/link.tsx
  • 源码:packages/next/src/client/form.tsx
  • 源码:packages/next/src/client/image-component.tsx
  • 源码:packages/next/src/client/script.tsx
  • 文档:docs/01-app/03-api-reference/01-components/{link,form,image,script}.mdx
  • 配套 fixture:fixtures/lecture-10/

下一讲预告

第 11 讲|数据获取:fetch 增强、unstable_cache、'use cache':内置组件解决"展示层"——下一讲我们看"数据层"。Next.js 给原生 fetch 加了哪些扩展(next.revalidate next.tags)?unstable_cache 与新的 'use cache' 指令有什么差别?cookies() headers() 为什么会让一段服务端代码"变动态"?我们将把这些 API 与 fetch cache、AsyncLocalStorage 串起来。