陌上人如玉
公子世无双

4.14 Partial Prerendering(PPR)

4.14 Partial Prerendering(PPR)

一句话: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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": '&#39;' })[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} />;
}

八、面试延伸

  1. 「PPR 是什么?它解决了什么问题?」
    PPR(Partial Prerendering)把「页面级的静态/动态取舍」变成「页面内部的取舍」:构建时预渲染出静态外壳,请求时立刻从 CDN 返回它,同时把标记为「洞」的动态部分流式填充。它解决的是「为了页面上一小块动态内容,被迫让整页都变成 SSR」的浪费——要让 TTFB 接近静态站点(CDN 级),同时保留个性化与流式能力。

  2. 「PPR 和流式 SSR 有什么区别?」
    关键区别是「外壳从哪来」:① 流式 SSR 的外壳是「每次请求在服务端渲染」的(只是分块发送),所以 TTFB 仍取决于「渲染 shell 的时间」;② PPR 的外壳是「构建时静态生成」的(所以可以放 CDN),请求时先把它发出去(TTFB 与 SSG 相同),再恢复(resume)并流式填充洞。两者「洞的填充机制」是同一套(<template> + 替换脚本)。

  3. 「PPR 的实现原理是什么?」
    依赖 React 的「静态可恢复渲染」能力:① 构建时 prerender——React 渲染整个页面,遇到「需要请求级信息」的部分(读 cookie/headers、未缓存数据)时不是报错,而是「暂停(postpone)」,产出「静态外壳 + 恢复所需状态(postponed state)」;② 请求时 resume——服务端拿到「外壳 + 状态」,从暂停的位置继续渲染,把结果流式填空。这些 API(prerender / resume)目前是实验性的,主要由 Next.js 这类框架使用,应用代码通过框架配置开启。

  4. 「PPR 和 ISR 有什么区别?」
    ① ISR 是「整页静态 + 定期/按需再生」——它解决的是「内容会变」的问题,但整页都不个性化,且「再生窗口内数据可能旧」;② PPR 是「外壳静态 + 内部洞动态」——它解决的是「页面里有一部分必须请求时才算」的问题,那些洞每次请求都现算(不需要「再生」),而外壳可以永久缓存。两者可以叠加(外壳用 ISR 管理内容更新,洞负责个性化)。

  5. 「PPR 和『静态页 + 客户端请求动态内容』有什么区别?」
    两者都能「先给用户看到部分内容」,但有三个关键差异:① 往返次数——「客户端补数据」要再发一次请求(多一次网络往返 + 骨架闪烁);PPR 是服务端在响应流里就补上(同一连接);② SEO——PPR 洞里的内容会出现在最终的 HTML 流里(爬虫读得到),而客户端补的数据爬虫通常读不到;③ 首屏体验——PPR 的洞填充发生在「页面还在流式到达时」,比「客户端请求回来」更早。

  6. 「怎么划分 PPR 的边界?」
    判断标准是「这段渲染是否依赖请求级信息」:进静态外壳——布局/导航/页脚、路由参数决定的静态内容、来自缓存或构建时的数据、纯 UI 与文案;进洞——读 cookies/headers、读会影响渲染的 searchParams、实时数据、以及「即使不是个性化但很慢的查询」(为了不阻塞外壳)。三个灰色地带:① 不个性化但慢 → 放洞(为了不阻塞);② 只影响局部 → 只把局部放洞;③ 「看起来静态」但读了 cookie → 一定会让那块变洞(要审视「是否真需要 cookie」)。

  7. 「PPR 有什么坑?」
    五个最典型的:① 在页面顶层读 cookies()/headers() → 整页变动态,PPR 失效(交互;要把这类读取放进 Suspense 让它成为洞);② 'use client' 放在顶层 → 静态外壳里全是待水合的空壳;③ 洞的 fallback 没有固定高度 → 填充时布局跳动(CLS);④ 把「用户特定数据」写进静态外壳 → 缓存串用户的严重隐私问题(必须放洞里);⑤ 以为 PPR 能省 JS → 它优化的是 TTFB 与首屏内容,静态外壳里的 JS 仍要水合(可交互时间不变)。

  8. 「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 友好。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.14 Partial Prerendering(PPR)

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫