《TypeScript编程实战》12.3 SSR·ISR 数据流类型

类型能描述数据的形状,却描述不了数据的新鲜度,这正是 SSR 与 ISR 最容易出事的地方。本节从三种渲染模式的数据流差异讲起,拆解 fetch 的缓存语义与 next 扩展类型、返回值收窄与 notFound 的 never、ISR 的 stale-while-revalidate 窗口、流式渲染与 Suspense 的边界,以及服务端与客户端缓存的新鲜度对齐。

本节目标:掌握「数据从哪来、有多新、怎么跨边界」这条链路上的类型规则。读完你能说清 CSR / SSR / SSG / ISR 四种模式在类型上的差异,能正确写出 fetch 的 next 扩展选项与缓存语义,能用 Zod 把 any 收窄成领域类型,能理解 ISR 的再生窗口与 revalidateTag 的关系,并知道新鲜度这种「类型管不到」的性质该靠什么约束。

12.3 SSR·ISR 数据流类型

上一节讲的是「谁能跨边界」。这一节讲「数据跨过来之后有多新」。这两件事看起来相邻,其实属于完全不同的层面:前者是形状问题,类型系统擅长;后者是时间问题,类型系统无能为力。把这两者分开看,很多「本地正常、线上偶尔陈旧」的怪现象就有了解释。

12.3.1 四种渲染模式:类型一样,数据流不一样

模式何时渲染数据获取时机类型上的注意
CSR浏览器客户端 fetch必须有 loading 态,数据初值是 undefined
SSR每次请求服务端 fetch每请求一份新数据,注意请求级缓存
SSG构建时构建期 fetch产物固定,可当常量看
ISR构建时 + 后台再生构建期 + 再生存在「陈旧窗口」,必须容忍旧值

关键认知在最后一行:类型不能表达「数据有多新」。同一个 Post 类型,在 SSR 下是毫秒前的数据,在 ISR 下可能是 5 分钟前的快照。编译器对此完全无感。所以新鲜度只能靠架构与约定表达——写进接口文档、写进数据访问层的注释、写进监控指标,唯独写不进类型。

这也解释了为什么「给 fetch 加个泛型」不能解决问题:泛型只能保证形状,不能保证时效。

把同一次渲染在两种模式下对比,差异立刻显形。客户端获取时,数据初值必然是 undefined,类型必须显式包含这一支:

'use client';

import { useEffect, useState } from 'react';

export function Posts() {
  const [posts, setPosts] = useState<Post[] | undefined>(undefined);
  useEffect(() => {
    fetchPosts().then(setPosts).catch(console.error);
  }, []);
  if (!posts) return <Skeleton />;   // 收窄掉 undefined 这一支
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

换成服务端组件,同一个 UI 的 posts 类型直接就是 Post[],既不需要 undefined,也不需要骨架屏分支——因为 await 保证了数据到手才渲染。同一个界面,两种数据流,两种类型。这个对比是判断「某段代码该放服务端还是客户端」最实用的尺子。

12.3.2 fetch 的缓存语义与 next 扩展

Next.js 扩展了原生 fetch,第二个参数多出 next 字段:

const res = await fetch(`${API}/posts`, {
  next: { revalidate: 60, tags: ['posts'] },
  cache: 'force-cache',
});

这个类型来自 next 包对全局 fetch 的模块增强(declare global 里的接口扩展)。TS 能识别 next.revalidate 的前提是 next-env.d.ts 进了 tsconfig 的 include。如果这里报 Object literal may only specify known properties, and 'next' does not exist in type 'RequestInit',说明类型增强没生效——去检查 next-env.d.ts 是否被误删或排除。

配置语义等价于
cache: 'force-cache'(默认)构建时取一次,长期复用SSG
cache: 'no-store'每次请求都取SSR
next: { revalidate: 60 }最多陈旧 60 秒ISR
next: { tags: ['posts'] }可按标签手动失效配合 revalidateTag

默认值是 force-cache,这是最容易踩的一脚:一个需要实时性的接口(比如余额、库存)如果没显式写 no-store,会被静默缓存到构建产物里,表现为「上线后数据一直不变」。实时数据一律显式 no-store,别依赖记忆。

12.3.3 返回值收窄:从 any 到领域类型

res.json() 的返回类型是 Promise<any>,必须自己收窄。可靠的做法是让运行时校验与静态类型同源:

import { z } from 'zod';

const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  publishedAt: z.string(),
  tags: z.array(z.string()),
});
const PostList = z.array(PostSchema);
export type Post = z.infer<typeof PostSchema>;

export async function fetchPosts(): Promise<Post[]> {
  const res = await fetch(`${API}/posts`, { next: { revalidate: 60, tags: ['posts'] } });
  if (!res.ok) throw new Error(`fetch posts failed: ${res.status}`);
  return PostList.parse(await res.json());
}

z.infer<typeof PostSchema> 让类型由 schema 推导而来,改一处两边同步。相比之下 (await res.json()) as Post[] 只是把编译器的嘴堵上,运行时字段缺失照样一路往下传,直到某个组件渲染 post.title.toUpperCase() 时才炸。

单条记录还可能「不存在」,用 notFound() 收窄成 never:

export async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`${API}/posts/${slug}`, { next: { tags: [`post:${slug}`] } });
  if (res.status === 404) notFound();
  if (!res.ok) throw new Error(`fetch post failed: ${res.status}`);
  return PostSchema.parse(await res.json());
}

注意返回类型写的是 Promise<Post> 而不是 Promise<Post | null>。因为 notFound() 是 never,函数在 404 分支上不会返回,剩余路径里就不存在「可能为空」这一支。把「不存在」在数据层消化掉,调用方(页面组件)就不必再写判空——这是数据访问层最有价值的一个设计决定:让「找不到」变成 404 页面,而不是让每个使用方各自处理 null。

12.3.4 ISR 的数据流与再生窗口

页面级 ISR 用一个导出常量声明:

// app/posts/[slug]/page.tsx
export const revalidate = 60;

export async function generateStaticParams() {
  const posts = await fetchPosts();
  return posts.map((p) => ({ slug: p.id }));
}

触发再生有两条路:时间驱动(revalidate: 60,到期后由第一个请求触发后台再生,该请求仍返回旧值)与事件驱动(内容变更时 revalidateTag('posts') 主动失效)。生产上通常两者并用:时间兜底,事件精确。

时间驱动有一个必须理解的窗口,它就是 stale-while-revalidate 的字面意思:

时刻行为用户看到
0s构建产物生效旧数据
60s到期,第一个请求触发再生仍是旧数据(后台再生中)
61s再生完成,缓存更新新数据

所以「发布后立刻刷新看不到新内容」是设计行为而不是 bug。要立刻生效,必须走事件驱动:

'use server';
import { revalidateTag } from 'next/cache';

export async function publishPost(id: string) {
  await db.post.update({ where: { id }, data: { published: true } });
  revalidateTag('posts');
  revalidateTag(`post:${id}`);
}

注意 tag 与数据获取时的 tag 必须逐字一致。上一节说过 tag 是裸字符串,写错不报错——这里就是它最容易出事的地方:revalidateTag('post') 与 tags: ['posts'] 差一个字母,缓存就永远不失效。

除了 revalidate,路由段还有几个同级配置,它们一起决定了这段路由的渲染方式:

// 与 revalidate 同级的几个路由段导出
export const dynamic = 'force-dynamic';   // 强制每次请求渲染,等价于全局 no-store
export const dynamicParams = true;        // 允许未预生成的路径按需渲染
export const fetchCache = 'default-cache'; // 覆盖本段内所有 fetch 的缓存默认值
导出常量取值效果
revalidatenumber | false页面级 ISR 周期,false 表示永不自动再生
dynamic'auto' | 'force-dynamic' | 'error'force-dynamic 让整页走 SSR
dynamicParamsboolean未在 generateStaticParams 中的路径是否按需渲染
fetchCache字面量联合覆盖该段内 fetch 的缓存默认值

较新版本给这几个常量配了字面量类型,拼写错误会被 IDE 拦住。但它们的语义后果不在类型里:dynamic = 'error' 会让任何用到动态 API 的页面在构建时直接失败,dynamicParams = false 会让未预生成的路径返回 404 而不是按需渲染。这些只能在构建与测试里验证。

12.3.5 流式渲染与 Suspense 的边界

// app/posts/page.tsx
import { Suspense } from 'react';

export default function PostsPage() {
  return (
    <main>
      <h1>文章</h1>
      <Suspense fallback={<Skeleton />}>
        <PostList />
      </Suspense>
    </main>
  );
}

async function PostList() {
  const posts = await fetchPosts();
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Suspense 的 fallback 类型是 ReactNode,没有额外约束;真正要小心的是客户端组件的 props 必须可序列化(沿用 12.2 的边界规则)。loading.tsx 是 App Router 给「整段路由」包 Suspense 的语法糖,等价于给 page.tsx 外面套一层 <Suspense fallback={<Loading />}>:

// app/posts/loading.tsx
export default function Loading() {
  return <Skeleton />;
}

两者可以叠加:loading.tsx 负责整段路由的首屏骨架,页面内部再用 <Suspense> 把慢的部分切得更细。但要注意 loading.tsx 只对该段及其子段生效,嵌套路由里每一层都可以有自己的 loading.tsx,粒度太细反而会出现「骨架闪一下又闪一下」的抖动。

流式渲染下组件的类型完全不变,变的是执行顺序:PostList 里的 await 不再阻塞外壳输出,骨架屏先到浏览器,数据到了再替换。因此有一条纪律——不要在 page.tsx 顶层 await 数据:

// ✗ 整页退化成阻塞式 SSR,流式失去意义
export default async function PostsPage() {
  const posts = await fetchPosts();
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

顶层 await 会让整页等到数据齐了才吐出第一个字节,TTFB 变差。正确做法是把 await 下沉到被 Suspense 包裹的子组件里。这一点在类型上没有任何提示,只能靠评审与约定。

12.3.6 时间、时区与 hydration

服务端渲染出的 HTML 与客户端 hydration 时算出的内容必须一致,否则 React 报 hydration mismatch:

// ✗ 服务端与客户端的「现在」不同 → 文本不一致
const unstableTime = <time>{new Date().toLocaleString()}</time>;

// ✓ 固定格式并显式时区
const stableTime = <time dateTime={post.publishedAt}>
  {new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'Asia/Shanghai' })
    .format(new Date(post.publishedAt))}
</time>;

toLocaleString() 的结果依赖运行环境的时区与 locale:服务端容器通常是 UTC,客户端是用户本地时区,两边输出不同字符串,hydration 就崩了。两个修法:显式指定 timeZone,或者把格式化放到客户端组件的 useEffect 里(首帧渲染占位)。

这也是为什么 12.2 建议把时间存成 ISO 字符串而不是 Date:string 在类型层面就告诉你「这是个需要显式解释的文本」,而 Date 看起来是「一个时刻」,容易让人误以为时区已经处理好了。用类型表达「这里的语义尚未确定」,是一种很实用的设计手法。

12.3.7 服务端与客户端缓存的新鲜度对齐

首屏走服务端(SSR / ISR),交互后的增量数据走客户端缓存库。两边必须对齐新鲜度,否则会出现「有时新有时旧」的诡异现象:

// 同一份类型,服务端与客户端共用
export type Post = z.infer<typeof PostSchema>;

// 服务端组件:直接 await
const posts = await fetchPosts();

// 客户端组件:交给缓存库
const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 60_000,   // 与 ISR 的 60s 对齐
});

把 staleTime 设成与 revalidate 相同的值,是让两套缓存不打架的最简单办法。类型系统对此完全无能为力——这是跨层契约,只能靠约定、评审和一处集中的常量表来维护:

export const CachePolicy = {
  posts: { revalidate: 60, staleTime: 60_000 },
} as const;

12.3.8 类型化数据访问层(DAL)

把所有数据获取收拢到 lib/data.ts,对外只暴露已收窄的领域类型,页面就再也见不到 any 或「可能为 null」:

// lib/data.ts
import 'server-only';
import { cache } from 'react';

export const getPost = cache(async (slug: string): Promise<Post> => {
  const res = await fetch(`${API}/posts/${slug}`, { next: { tags: [`post:${slug}`] } });
  if (res.status === 404) notFound();
  if (!res.ok) throw new Error(`fetch post failed: ${res.status}`);
  return PostSchema.parse(await res.json());
});

两个细节值得展开。import 'server-only' 让「客户端组件误引用服务端模块」在编译期直接报错,而不是把密钥、数据库连接打包进浏览器——这是类型系统能提供的最有价值的保护之一。cache() 来自 React,做的是单次请求内的 memo:同一请求里 generateMetadata 和 page 都调用 getPost(slug),实际只会发一次请求。

层次职责返回类型
fetch 层发请求、判状态码Response
parse 层校验形状Post(失败抛错)
DAL组装、缓存、notFoundPromise<Post>
页面组件渲染JSX.Element

分层之后,类型错误的定位变得非常快:形状不对是 parse 层的问题,404 没接住是 DAL 的问题,渲染崩了是页面组件的问题。

12.3.9 常见坑速查

症状原因修法
线上数据一直不变fetch 默认 force-cache实时数据显式 no-store
next.revalidate 类型报错类型增强未生效确认 next-env.d.ts 在 include 里
发布后页面仍旧ISR 再生窗口未到用 revalidateTag 主动失效
revalidateTag 无效tag 与 next.tags 不一致用常量表统一
hydration mismatch时间 / 随机数两边不同显式时区,或放到 useEffect
TTFB 很高页面顶层 await 数据把 await 下沉进 Suspense
密钥进了前端包客户端误引服务端模块加 import 'server-only'

12.3.10 与本书其它章节的衔接

本节的数据获取与缓存建立在上一节的边界规则之上,'use server'、序列化与 revalidate 的基础见 《TypeScript编程实战》12.2 Next.js App Router 类型(Server Actions / Route Handlers) 。客户端缓存的推导与失效见 《TypeScript编程实战》14.1 TanStack Query 类型推导 、《TypeScript编程实战》14.2 乐观更新与缓存失效 与 《TypeScript编程实战》14.3 分页、无限滚动与预取 。服务端缓存与 Redis 分层见 《TypeScript编程实战》8.1 缓存层次与键设计 与 《TypeScript编程实战》8.3 穿透·击穿·雪崩防护 。构建产物体积与首屏性能见 《TypeScript编程实战》15.2 代码分割与 tree-shaking 与 《TypeScript编程实战》15.3 构建性能诊断与包体积治理 。

站内延伸阅读:Vercel ISR 指南 、Next.js 渲染模型 、Next.js 数据获取 、前端 SSR 与 SSG 、Jamstack / SSR / SPA 架构对比 、Vite SSG 预渲染 。

小结

本节的核心是一句话:类型能守住形状,守不住新鲜度。

四种渲染模式在类型上完全一样,差别全在「数据什么时候取、能陈旧多久」。因此判断一段代码是否安全,不能只看它有没有类型标注,还要问:这份数据允许陈旧吗?默认的 force-cache 是否合适?失效用的是时间还是事件?这三问的答案写不进类型,必须写进约定。

具体到手法,四条最有用。第一,fetch 的 next 扩展依赖 next-env.d.ts 的类型增强,实时数据一律显式 no-store。第二,返回值收窄必须用 z.infer 让类型与校验同源,禁止 as;「不存在」用 notFound() 收成 never,让 DAL 对外只暴露非空类型。第三,ISR 的再生窗口是设计行为,要立刻生效就得靠 revalidateTag,而 tag 必须与 next.tags 逐字一致。第四,服务端与客户端两套缓存要显式对齐新鲜度,并把 import 'server-only' 当作编译期护栏。

本章从 Vue 的组合式 API 讲到 Next.js 的 App Router,再到 SSR / ISR 的数据流,主线始终是同一件事:类型系统能表达的边界要写足,表达不了的边界要用编译期标记、常量表和约定兜住。下一章转入表单:当用户输入从「不可信」变成「必须校验」时,Zod 与 React Hook Form 会把这套思路推到极致。

阅读导航:上一节:12.2 Next.js App Router 类型(Server Actions / Route Handlers) · 下一节:13.1 React Hook Form + Zod 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes