
一句话:PPR 把「页面级的静态/动态取舍」变成「页面内部的取舍」——构建时预渲染出「静态外壳」,请求时立刻从 CDN 返回它,同时把标记为「洞(holes)」的动态部分流式填充进去;于是「TTFB 像 SSG,数据像 SSR」。
一、要解决的问题:静态与动态的「页面级二选一」
传统上,一个路由只能整体选一种渲染方式:
· 整页 SSG/ISR → 快(CDN 直接返回静态 HTML),但**不能有个性化内容**
(一旦要读 cookie/实时数据,整页就得变成动态)
· 整页 SSR → 能个性化,但**慢**(每次请求都要服务端渲染 → TTFB 高)
⭐⭐ 但真实页面往往是「**大部分静态 + 小部分动态**」:
┌────────────────────────────────────────┐
│ 顶部导航(静态) │ ← 全站一样,构建时就能定
│ 商品标题 / 图片 / 描述(静态或 ISR) │ ← 数据变化不快
│ ┌────────────────────────────────────┐ │
│ │ 购物车角标(动态,依赖 cookie) │ │ ← 每次都不同
│ └────────────────────────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ 推荐位(动态,按用户画像) │ │ ← 每次都不同
│ └────────────────────────────────────┘ │
│ 评论区(流式,查询较慢) │ ← 慢,但不该阻塞整页
└────────────────────────────────────────┘
⭐ 为了那两小块动态内容,整页被迫用 SSR → 让「本来可以走 CDN 的 90% 内容」
也变成了「每次都要服务端渲染」。
→ 这就是 PPR 要消除的浪费。
二、PPR 的核心:静态外壳 + 洞
// app/product/[id]/page.tsx(Next.js App Router,开启 PPR 后)
import { Suspense } from 'react';
import { cookies } from 'next/headers';
// ① 静态部分:构建时预渲染
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id); // ⚠️ 如果这里是「构建时可取的数据」 → 进静态外壳
return (
<div>
<ProductHeader product={product} /> {/* 静态:进 shell */}
<ProductImages images={product.images} />{/* 静态:进 shell */}
{/* ② 洞(hole):用 Suspense 标记「动态部分」 */}
<Suspense fallback={<CartSkeleton />}>
<CartBadge /> {/* 动态:依赖 cookies → 运行时填充 */}
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations userId={await getUserFromCookie()} /> {/* 动态 */}
</Suspense>
</div>
);
}
<!-- ⭐ 构建时产出的「静态外壳」(示意) -->
<!DOCTYPE html>
<html>
<head>…(静态 <head> 与样式)…</head>
<body>
<div id="root">
<header>…全站导航…</header>
<h1>商品 A</h1>
<img src="/a.jpg" />
<!-- 洞 1:购物车角标 —— 构建时只放骨架 + 一个占位标记 -->
<div data-ppr-hole="1"><!--$?--><span class="skeleton"></span><!--/$--></div>
<!-- 洞 2:推荐位 -->
<div data-ppr-hole="2"><!--$?--><span class="skeleton"></span><!--/$--></div>
</div>
<script>/* 水合脚本 */</script>
</body>
</html>
⭐⭐ 请求时的两个阶段(这是 PPR 与「流式 SSR」最本质的区别):
阶段 ①:静态外壳立刻可用
· 外壳在【构建时】就生成了,是「静态文件」→ 可以放 CDN
· 用户请求时,CDN 直接返回它 → **TTFB 与 SSG 相同**(几十毫秒级)
· 浏览器立刻渲染出「页面 + 骨架」→ 用户马上看到内容
阶段 ②:洞被填充
· 服务端(或边缘运行时)**恢复**(resume)构建时「暂停」的动态渲染
· 把动态内容用「流式脚本」替换掉占位(与流式 SSR 的替换机制一致)
· 用户看到骨架被真实内容替换
⭐ 一句话对比:
· 流式 SSR:外壳是【每次请求在服务端渲染】的(TTFB 高),洞是流式的
· PPR: 外壳是【构建时静态生成】的(TTFB 极低),洞是流式填充的
三、与四种渲染方式的对比
| 维度 | SSG/ISR | SSR | 流式 SSR | PPR |
|---|---|---|---|---|
| 外壳(shell)何时生成 | 构建时 | 每次请求 | 每次请求 | 构建时 |
| TTFB | 极低(CDN) | 高(等渲染) | 中(等 shell 渲染完) | 极低(CDN) |
| 能否个性化 | ❌ | ✅ | ✅ | ✅ (在洞里) |
| 能否流式 | ❌ | ❌ | ✅ | ✅ |
| 服务端成本 | 极低 | 高 | 高 | 中(只渲染洞) |
| 页面级还是组件级 | 页面级 | 页面级 | 页面级 | 组件级 |
⭐ 三个「容易混淆」的对比:
① PPR vs 流式 SSR
· 都「先给用户看到部分内容、再补上其余」
· 但 PPR 的「外层」是【静态的】(构建时生成、可放 CDN)
· 流式 SSR 的「外层」是【每次请求动态渲染的】
→ PPR 的 TTFB 因此可以接近「静态站点」
② PPR vs ISR
· ISR 是「整页静态 + 定期/按需再生」
· PPR 是「外层静态 + 内部洞动态」——连「再生」都不需要,
因为动态部分本来就是「每次请求现算的」
③ PPR vs 「静态页 + 客户端请求动态内容」
· 后者(CSR 补数据)需要「客户端再发一次请求」→ 有额外往返 + 骨架闪烁
· PPR 是「服务端在响应流里就把洞补上」→ 内容直接出现在 HTML 流里
(更好的首屏、更好的 SEO——爬虫也能看到洞里的内容)
四、实现原理:prerender 与 resume
⭐⭐ PPR 依赖 React 的一个能力:「静态可恢复渲染」(prerender / resume)
① 【构建时:prerender】
· React 渲染整个页面(与普通 SSR 一样)
· 当遇到「需要请求级信息」的部分(读 cookie/headers、未缓存的数据)时,
React **不是报错,而是「暂停(postpone)」这一部分**
· 产物有两份:
a. 静态 HTML 外壳(洞的位置放 fallback + 占位标记)
b. 「恢复所需的状态」(postponed state)——记录「哪些位置被暂停了、
它们对应的组件树片段是什么」
② 【请求时:resume】
· 服务端(或 Edge)拿到「静态外壳 + postponed state」
· 调用 resume(staticShell, postponedState) → 继续渲染「被暂停的部分」
· 用流式输出把结果填空(与流式 SSR 的「替换脚本」机制一致)
// ⭐ 概念示意(真实 API 是实验性的,主要供框架使用)
import { prerenderToNodeStream } from 'react-dom/static';
import { resumeToPipeableStream } from 'react-dom/server';
// ============ 构建时 ============
const { prelude, postponed } = await prerenderToNodeStream(<App />);
// prelude → 静态外壳(HTML 流,含占位)
// postponed → 「恢复所需的状态」(要存起来,供请求时用)
// ============ 请求时 ============
resumeToPipeableStream(postponed, <App />).pipe(response);
// ⭐ 从「暂停的位置」继续渲染,并把结果流式填入洞
⭐ 关键概念:`Postpone`
· React 内部有一个「推迟信号」(类似抛异常的机制)
· 当某个组件「需要请求级数据」时,会「抛出 postpone」
· prerender 阶段捕获它 → 记录「这里要暂停」→ 输出 fallback
· resume 阶段重新执行这个组件(此时能拿到请求信息)→ 输出真实内容
⚠️ 这些 API(`prerender` / `resume`)目前是【实验性】的,
主要由 Next.js 这类框架使用;应用代码通过框架配置开启 PPR,不直接调它们。
以官方文档为准。
五、完整实现:一个 PPR 模拟器(180 行)
下面的代码用「字符串拼接 + 替换脚本」模拟 PPR 的两阶段流程,可以真实跑起来看效果。
// mini-ppr.ts —— 教学版 PPR 模拟器
// ⚠️ 简化演示:用「构建时产出 shell + 运行时填空」复现 PPR 的核心机制
type VNode =
| string
| { type: string; props?: Record<string, unknown>; children?: VNode[] }
| { Dynamic: (ctx: RequestContext) => Promise<string> }; // ⭐ 「洞」的定义
interface RequestContext {
cookies: Record<string, string>;
userId: string | null;
}
/** ============ 1. 构建时:渲染静态外壳,遇到 Dynamic 就留洞 ============ */
export interface PrerenderResult {
/** 静态外壳(含占位标记) */
shell: string;
/** 「洞」的清单:id → 它的渲染函数(真实实现里是「序列化的组件树片段」) */
holes: Map<string, (ctx: RequestContext) => Promise<string>>;
}
export async function prerender(tree: VNode): Promise<PrerenderResult> {
const holes = new Map<string, (ctx: RequestContext) => Promise<string>>();
let holeSeq = 0;
const toHtml = async (node: VNode): Promise<string> => {
if (typeof node === 'string') return escapeHtml(node);
if ('Dynamic' in node) {
// ⭐⭐ 关键:遇到动态部分 → 【暂停】,只输出「骨架 + 占位」
const id = `h${holeSeq++}`;
holes.set(id, node.Dynamic);
// 占位用 <template>(浏览器不渲染它,正好当作「洞」的容器)
return `<template data-hole="${id}"></template><span data-fallback="${id}">加载中…</span>`;
}
const { type, props = {}, children = [] } = node;
const attrs = Object.entries(props)
.filter(([k]) => k !== 'children')
.map(([k, v]) => ` ${k}="${escapeHtml(String(v))}"`)
.join('');
const inner = (await Promise.all(children.map(toHtml))).join('');
return `<${type}${attrs}>${inner}</${type}>`;
};
const shell = await toHtml(tree);
return { shell, holes };
}
/** ============ 2. 请求时:先返回外壳,再流式填空 ============ */
export interface StreamChunk {
/** 'shell' = 先发的外壳;'hole' = 后续填充 */
kind: 'shell' | 'hole';
html: string;
}
export async function* renderWithPpr(
prerendered: PrerenderResult,
ctx: RequestContext
): AsyncGenerator<StreamChunk> {
// ① 立刻吐出静态外壳(真实场景里它来自 CDN,几乎是瞬时的)
yield { kind: 'shell', html: prerendered.shell };
// ② 逐个把洞填上(可以并发,也可以按优先级顺序)
const entries = [...prerendered.holes.entries()];
const results = await Promise.all(
entries.map(async ([id, renderer]) => ({ id, html: await renderer(ctx) }))
);
for (const { id, html } of results) {
// ⭐ 与流式 SSR 一样的机制:
// ① 用 <template> 承载真实内容(不直接渲染)
// ② 用一小段脚本把「占位」替换成「模板内容」
yield {
kind: 'hole',
html:
`<template data-fill="${id}">${html}</template>` +
`<script>` +
`(function(){` +
`var t=document.querySelector('template[data-fill="${id}"]');` +
`var f=document.querySelector('[data-fallback="${id}"]');` +
`if(t&&f&&t.parentNode){f.replaceWith(t.content.cloneNode(true));t.remove();}` +
`})();` +
`</script>`,
};
}
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
}
// ✅ 用它组装一个真实服务(Express 风格示意)
import express from 'express';
import { prerender, renderWithPpr, type VNode, type RequestContext } from './mini-ppr';
const app = express();
/** ============ 页面定义(外壳 + 洞) ============ */
function buildTree(): VNode {
return {
type: 'div',
props: { class: 'product-page' },
children: [
{ type: 'header', children: ['全站导航(静态)'] }, // 静态 → 进 shell
{ type: 'h1', children: ['商品 A(静态)'] }, // 静态 → 进 shell
{ type: 'p', children: ['商品描述(ISR 数据)'] }, // 静态 → 进 shell
// ⭐ 洞 1:购物车角标(依赖 cookie)
{ Dynamic: async (ctx) => `<span class="badge">购物车 ${ctx.userId ? 3 : 0}</span>` },
// ⭐ 洞 2:推荐位(依赖用户 + 慢查询)
{
Dynamic: async (ctx) => {
await delay(120); // 模拟慢查询
return `<ul class="rec">${ctx.userId ? '<li>为你推荐:商品 B</li>' : '<li>热门:商品 C</li>'}</ul>`;
},
},
],
};
}
/** ============ 构建时:预渲染(只在「构建」时跑一次) ============ */
let cachedPrerender: Awaited<ReturnType<typeof prerender>> | null = null;
async function getPrerendered() {
cachedPrerender ??= await prerender(buildTree());
return cachedPrerender;
}
/** ============ 请求时:外壳 + 流式填空 ============ */
app.get('/product/a', async (req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
const prerendered = await getPrerendered(); // ⭐ 真实场景:从 CDN / 磁盘读
const ctx: RequestContext = {
cookies: (req.headers.cookie ?? '').split(';').reduce<Record<string, string>>((acc, part) => {
const [k, v] = part.split('=');
if (k && v) acc[k.trim()] = v.trim();
return acc;
}, {}),
userId: null,
};
ctx.userId = ctx.cookies.sessionUserId ?? null;
for await (const chunk of renderWithPpr(prerendered, ctx)) {
res.write(chunk.html); // 边生成边发
if (chunk.kind === 'hole') await delay(10); // 模拟网络节奏
}
res.end();
});
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
⭐⭐ 观察这个实现的三个关键点:
① 【构建时的产出是「外壳 + 洞清单」,不是「整页 HTML」】
→ 外壳可以放 CDN(`getPrerendered()` 在真实场景里是「读静态文件」)
→ 洞清单在真实实现里是「序列化的组件树片段(postponed state)」
② 【请求时先发外壳】
→ 用户立刻看到「导航 + 标题 + 两个骨架」→ TTFB 与静态站相同
→ 这正是 PPR 的核心收益
③ 【洞用「template + 替换脚本」填充】
→ 与流式 SSR 完全相同的机制(见 9.6)
→ 因此「洞里也能嵌 Suspense、也能再嵌套洞」
⭐ 与流式 SSR 的运行结果对比:
流式 SSR:TTFB = 服务端渲染 shell 的时间(含所有「不挂起」部分的渲染)
PPR: TTFB = 读取静态外壳的时间(几乎为 0)
→ 差异只在「外壳从哪来」,但影响 TTFB 一个数量级
六、边界划分:哪些进外壳、哪些进洞
⭐⭐ 判断标准:**这段渲染是否依赖「请求级信息」?**
✅ 进【静态外壳】:
· 布局、导航、页脚(全站一样)
· 路由参数决定的静态内容(如 /product/a 的商品名与图片——构建时已知)
· 来自「缓存/构建时数据」的内容(`use cache` / ISR 数据)
· 纯 UI 与文案
✅ 进【洞(动态)】:
· 读 cookies / headers(登录态、A/B、语言偏好)
· 读 searchParams(如果它影响渲染)
· 实时数据(库存、价格、在线人数)
· 慢查询(即使数据不个性化,也可能想让它「别阻塞外壳」)
⚠️ 三种「灰色地带」的处理:
① 【数据不个性化但很慢】→ 放洞里(不是为了个性化,而是为了「不阻塞外壳」)
② 【依赖 searchParams 但只影响局部】→ 只把局部放洞(不要因为一个 query 参数让整页动态)
③ 【「看起来静态」但读了 cookie】→ 一定会让那一块变洞(所以要审视「是否真的需要 cookie」)
// ✅ 一个「边界划得好」的例子
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProductCached(id); // ⭐ 缓存数据 → 进静态外壳
return (
<Layout>
<ProductDetail product={product} /> {/* 静态 */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice productId={id} /> {/* 洞:实时价格(慢 + 动态) */}
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartBadge /> {/* 洞:依赖 cookie */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} /> {/* 洞:慢查询 */}
</Suspense>
</Layout>
);
}
⭐ 一个「划错边界」的反面例子:
❌ 在页面顶层读 cookies() 决定「语言」
→ 整页变成动态(所有内容都进不了外壳)
✅ 把「语言」作为【路由段】(如 /[locale]/product/a)
→ 语言成了「构建时已知的维度」→ 每种语言一个静态外壳(可配上 CDN 的 Vary)
七、本篇特有的坑
// ① 以为「开了 PPR 整页就都静态了」
// ⚠️ 只有「壳」是静态的;洞里的渲染仍然在每次请求时执行
// ② 在页面顶层读 cookies()/headers() → 整页变动态,PPR 失效
const theme = (await cookies()).get('theme'); // ❌ 在顶层读
// ✅ 把依赖 cookie 的部分包进 <Suspense>(让它成为洞)
// ③ 用「客户端组件」在顶层包一切 → 静态外壳里全是「待水合的空壳」
'use client'; // ⚠️ 放在 page/layout 顶部
// ④ 洞的 fallback「没有固定高度」→ 填充时布局跳动(CLS)
<Suspense fallback={null}> … </Suspense> // ❌
<Suspense fallback={<PriceSkeleton height={28} />}> // ✅
// ⑤ 洞「太多太碎」→ 首屏出现「一片骨架」
// ✅ 按「数据块」聚合洞(同一时间就绪的放一起)
// ⑥ 洞里的内容「依赖另一个洞的结果」→ 退化成串行流式
// ⚠️ 尽量让洞之间独立(否则一个慢洞会拖住后面的)
// ⑦ 忘记「静态外壳里的 JS 仍要水合」→ 以为 PPR 能省掉 JS
// ⚠️ PPR 优化的是「TTFB 与首屏内容」,不是「JS 体积与可交互时间」
// ⑧ 洞里的内容「对 SEO 不友好」——其实不会:
// PPR 的洞是「服务端流式填充」的,爬虫能读到最终 HTML
// (对比「客户端补数据」才会让爬虫看不到)
// ⑨ 在洞里做「必须串行」的重活(如逐个 fetch)→ 洞本身很慢
// ✅ 洞内也要并行(Promise.all)+ 设超时
// ⑩ 把「用户特定数据」写进静态外壳 → 缓存串用户(严重的隐私问题)
// 这类数据必须放洞里(或走客户端请求)
// ⑪ 用 PPR 处理「需要强一致」的页面(如支付确认页)
// ⚠️ PPR 的洞是「稍后填充」的 → 页面会短暂显示骨架
// 如果「不允许任何中间态」,考虑整页 SSR
// ⑫ 忘记「洞的渲染也要鉴权/校验」
// ⚠️ 洞里的数据可能包含私有信息 → 必须做权限校验(不能因为「在外壳里」就放松)
// ⑬ 用「Edge Runtime」跑洞的渲染,但洞里用了 Node 专属 API
// ⚠️ Edge 没有 fs/部分 Node API(见 9.8)
// ⑭ 认为「PPR 只在 Next.js 有」
// ⚠️ PPR 依赖 React 的 prerender/resume API——
// 任何使用这套 React 能力的框架/自建方案都可以实现(只是 Next.js 先落地)
// ⑮ 开发环境「看不到 PPR 的真实行为」
// ⚠️ dev server 往往不做「构建时预渲染」→ 建议用 `next build && next start` 验证
// ⑩ 的完整说明:为什么「用户特定数据不能进外壳」
// ❌ 错误:在外壳里渲染用户名
export default async function Layout({ children }) {
const user = await getCurrentUser(); // ⚠️ 读 cookie → 但输出进了「共享外壳」
return (
<>
<Header username={user?.name} /> {/* ⚠️ 一旦被静态化 → 所有用户看到同一个名字 */}
{children}
</>
);
}
// ✅ 正确:把用户名放进洞里(或用客户端组件在客户端读取)
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<HeaderSlot /> {/* ⭐ 它是洞 → 每次请求单独渲染 */}
</Suspense>
{children}
</>
);
}
async function HeaderSlot() {
const user = await getCurrentUser(); // 读 cookie(在洞内 → 不影响外壳静态化)
return <Header username={user?.name} />;
}
八、面试延伸
- 「PPR 是什么?它解决了什么问题?」
PPR(Partial Prerendering)把「页面级的静态/动态取舍」变成「页面内部的取舍」:构建时预渲染出静态外壳,请求时立刻从 CDN 返回它,同时把标记为「洞」的动态部分流式填充。它解决的是「为了页面上一小块动态内容,被迫让整页都变成 SSR」的浪费——要让 TTFB 接近静态站点(CDN 级),同时保留个性化与流式能力。 -
「PPR 和流式 SSR 有什么区别?」
关键区别是「外壳从哪来」:① 流式 SSR 的外壳是「每次请求在服务端渲染」的(只是分块发送),所以 TTFB 仍取决于「渲染 shell 的时间」;② PPR 的外壳是「构建时静态生成」的(所以可以放 CDN),请求时先把它发出去(TTFB 与 SSG 相同),再恢复(resume)并流式填充洞。两者「洞的填充机制」是同一套(<template>+ 替换脚本)。 -
「PPR 的实现原理是什么?」
依赖 React 的「静态可恢复渲染」能力:① 构建时prerender——React 渲染整个页面,遇到「需要请求级信息」的部分(读 cookie/headers、未缓存数据)时不是报错,而是「暂停(postpone)」,产出「静态外壳 + 恢复所需状态(postponed state)」;② 请求时resume——服务端拿到「外壳 + 状态」,从暂停的位置继续渲染,把结果流式填空。这些 API(prerender/resume)目前是实验性的,主要由 Next.js 这类框架使用,应用代码通过框架配置开启。 -
「PPR 和 ISR 有什么区别?」
① ISR 是「整页静态 + 定期/按需再生」——它解决的是「内容会变」的问题,但整页都不个性化,且「再生窗口内数据可能旧」;② PPR 是「外壳静态 + 内部洞动态」——它解决的是「页面里有一部分必须请求时才算」的问题,那些洞每次请求都现算(不需要「再生」),而外壳可以永久缓存。两者可以叠加(外壳用 ISR 管理内容更新,洞负责个性化)。 -
「PPR 和『静态页 + 客户端请求动态内容』有什么区别?」
两者都能「先给用户看到部分内容」,但有三个关键差异:① 往返次数——「客户端补数据」要再发一次请求(多一次网络往返 + 骨架闪烁);PPR 是服务端在响应流里就补上(同一连接);② SEO——PPR 洞里的内容会出现在最终的 HTML 流里(爬虫读得到),而客户端补的数据爬虫通常读不到;③ 首屏体验——PPR 的洞填充发生在「页面还在流式到达时」,比「客户端请求回来」更早。 -
「怎么划分 PPR 的边界?」
判断标准是「这段渲染是否依赖请求级信息」:进静态外壳——布局/导航/页脚、路由参数决定的静态内容、来自缓存或构建时的数据、纯 UI 与文案;进洞——读 cookies/headers、读会影响渲染的 searchParams、实时数据、以及「即使不是个性化但很慢的查询」(为了不阻塞外壳)。三个灰色地带:① 不个性化但慢 → 放洞(为了不阻塞);② 只影响局部 → 只把局部放洞;③ 「看起来静态」但读了 cookie → 一定会让那块变洞(要审视「是否真需要 cookie」)。 -
「PPR 有什么坑?」
五个最典型的:① 在页面顶层读cookies()/headers()→ 整页变动态,PPR 失效(交互;要把这类读取放进Suspense让它成为洞);②'use client'放在顶层 → 静态外壳里全是待水合的空壳;③ 洞的 fallback 没有固定高度 → 填充时布局跳动(CLS);④ 把「用户特定数据」写进静态外壳 → 缓存串用户的严重隐私问题(必须放洞里);⑤ 以为 PPR 能省 JS → 它优化的是 TTFB 与首屏内容,静态外壳里的 JS 仍要水合(可交互时间不变)。 -
「PPR 对 SEO 有影响吗?」
正面影响:PPR 的洞是「服务端流式填充」的,最终 HTML 流里包含洞的真实内容,因此爬虫能读到完整内容(与「客户端补数据」不同,后者爬虫通常看不到)。注意两点:① 需要确保「洞的内容在响应流中及时到达」(不要有大延迟);② 「静态外壳」里不能有用户特定数据(否则会串用户,也谈不上 SEO 的正确性)。
一句话速记
PPR = 「构建时预渲染静态外壳 + 请求时流式填充洞」,把「页面级的静态/动态二选一」变成「页面内部的取舍」;与流式 SSR 的关键区别是「外壳从哪来」(PPR 的外壳来自 CDN → TTFB 与 SSG 同级,流式 SSR 的外壳要每次请求渲染);实现依赖 React 的「静态可恢复渲染」(构建时
prerender把请求级部分暂停并产出「外壳 + postponed state」,请求时resume从暂停处继续并流式填空,机制与流式 SSR 的<template>+ 替换脚本一致);边界判断标准是「是否依赖请求级信息」(布局/缓存数据/文案进外壳;cookie/实时/慢查询进洞),灰色地带用「不阻塞外壳」作为第二判据;三大坑是「顶层读 cookies/headers 让整页变动态」「用户特定数据进外壳 → 缓存串用户」「以为 PPR 能省 JS(静态外壳仍要水合)」;洞里内容会被流式写入 HTML,因此 SEO 友好。



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