- 发布日期
第 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推导、placeholderblur 与 LQIP。 - 区分
<Script>的 4 种 strategy(beforeInteractive / afterInteractive / lazyOnload / worker)执行时机。 - 在 fixture 中观察网络请求与 React DevTools 上的内部组件。
1. <Link>:客户端导航的"用户接口"
packages/next/src/client/link.tsx 大致 700 行,但核心逻辑可拆 3 块:
prefetch(router, href, ...)模块级函数。Link组件本身(React.forwardRef),合并 ref + 收口 prop 校验。- 两类触发:viewport(
useIntersectionObserver)与交互(onMouseEnter/onTouchStart)。
1.1 模块级 prefetch & 已访问集合
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(...):
// 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
onMouseEnter: true,
onTouchStart: true,
如果 viewport 没触发(链接还没滚到视口),用户鼠标移上去也会触发。这两条触发链是叠加的,不是排他的——保险起见两边都监听。
2. <Form>:把表单变成路由
packages/next/src/client/form.tsx 200 行不到,但语义颇深。
2.1 action 类型决定行为
const actionProp = props.action
const isNavigatingForm = typeof actionProp === 'string'
// Validate `action`
action是字符串 → 走客户端导航路径,等同于<Link>的 form 版本。action是函数 → Server Action,由 React 接管,序列化参数走 RSC payload。
2.2 字符串 action 的提交流程
const actionHref = addBasePath(actionProp)
return (
<form
ref={ref}
{...rest}
action={actionHref}
onSubmit={(event) =>
onFormSubmit(
// ...
)
}
/>
)
export default Form
onFormSubmit 内部会读取表单字段:
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 })
要点:
- 读 form 元素,把字段拼成 query string(
createFormSubmitDestinationUrl)。 - 用
router.push或router.replace触发客户端导航。 - 整个过程不走真正的 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 下无效":
'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
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 可以是 string 或 import('img.png') 静态导入对象。后者由 SWC / webpack 在编译期生成 { src, height, width, blurDataURL }:
- 静态导入:自动得到尺寸 → 保留 layout 不抖动 → 自动 blur LQIP。
- 动态字符串:必须显式传
widthheight或fill,否则报错。
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:
function handleLoading(
img: ImgElementWithDataProp,
placeholder: PlaceholderValue,
// ...
) {
// ...
if (placeholder !== 'empty') {
// ...
3.5 priority 与 fetchpriority
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
function ImagePreload({
// ...
}) {
// ...
}
只在 priority 为 true 时挂出,使用 react-dom/server 的 preload() 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
export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
| Strategy | 执行时机 | 典型场景 |
|---|---|---|
beforeInteractive | 在 React hydrate 之前(首屏 HTML 内联) | polyfill / consent banner |
afterInteractive(默认) | hydrate 之后立刻加载 | 分析、错误监控 |
lazyOnload | window load 事件后通过 requestIdleCallback 加载 | 客服弹窗、A/B 测试 |
worker | 通过 Partytown 走 Web Worker(需配置) | 第三方追踪降低主线程压力 |
4.2 ScriptCache + LoadCache
const ScriptCache = new Map()
const LoadCache = new Set()
ScriptCache 记录"src → loaded promise";LoadCache 记录"已经触发过加载"。两层防止重复加载——多个组件挂同一个 <Script> 也只下载一次。
4.3 lazyOnload 的延迟
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>
);
}
观察这套组合的网络瀑布图:
- HTML 流式输出(含
<link rel="preload">用于 priority 图)。 - Hydrate 完成后
analytics.js异步加载(不阻塞 LCP)。 - 卡片进入视口 →
<Link>触发 prefetch RSC。 - 用户点击 "加入购物车" → 函数 action 序列化 FormData → POST
/products/...走 RSC payload。
生产排查提示:购物车加入按钮"点了没反应"?先看 DevTools Network 面板有没有 RSC 请求;没有则 server action 没注册——多半是组件没在 server bundle,可能是被
'use client'误标。
6. 重难点
6.1 Link 的 prefetch 机制不在 Link 内
很多人误以为 <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 个示例:
app/example-1-link/— 三种 prefetch 取值对比(auto / true / false)。app/example-2-form/— 字符串 action 与函数 action 各一例。app/example-3-image/— placeholder blur、priority、sizes 演示。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. 检验问题
<Link>的两条 prefetch 触发链分别是什么?哪些 prop 关掉?prefetch="auto"/true/false各对应什么行为?区别如何反映到 segment cache?<Form>区分字符串 action 与函数 action 的代码在哪一行?<Form action={fn}>中的replacescroll为什么会被忽略?<Image>静态导入与动态 src 的关键差异是什么?priority属性触发了哪些 HTML 属性 / preload?- 4 种 Script strategy 的执行时机分别是?为什么
lazyOnload用双重延迟? - ScriptCache 与 LoadCache 各自防什么?
- dev 模式的 prefetch 行为为什么与 prod 不同?
- 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 串起来。