mcpField
N
nextjs-static-shells

nextjs-static-shells

github.com/joelhooks开发6555 次安装1 天前更新
代码仓库

关于 nextjs-static-shells

nextjs-static-shells 是面向 Next.js 16 开发者的模式指南,介绍以静态外壳为主、将请求相关逻辑隔离为动态部分的架构。内容涵盖缓存、Suspense、RSC 边界与客户端交互的组织方式。

主要功能

  1. 01静态外壳与动态插槽架构
  2. 02使用 'use cache' 管理缓存
  3. 03通过 Suspense 流式呈现动态内容
  4. 04缓存标签与生命周期管理
  5. 05组织客户端 Provider 岛

适用场景

  1. 01构建静态内容与个性化控件并存的页面
  2. 02隔离请求相关逻辑,避免整页动态化
  3. 03迁移旧缓存配置与 unstable_cache
  4. 04检查 RSC 边界和 Suspense 用法

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/joelhooks/joelclaw --skill nextjs-static-shells

安装后在 Claude Code 中输入/nextjs-static-shells确认已加载。

附带文件agents/openai.yamlassets/small-logo.svg

详细介绍

静态优先的 Next.js 16 模式

先构建一个静态外壳,再在需要个性化或请求特定行为的地方开辟小型动态区域。

  • 静态外壳 = 确定性、可缓存、首次绘制速度快
  • 动态区域 = 通过 Suspense 流式传输的隔离请求/用户行为
  • 客户端交互性 = Provider 岛,而非全局客户端代码蔓延

路由架构:入口 + 静态内容 + 插槽

模式
  1. 入口组件(服务器端,感知请求)
    • 读取 params/search/auth/session/cookies
    • 验证访问权限、解析 ID、准备动态 props
  2. 静态渲染器(服务器端,'use cache')
    • 渲染确定性的布局/内容
    • 接受作为插槽 props(ReactNode)传入的动态 UI
  3. 动态插槽
    • 从入口组件注入
    • 在静态外壳中渲染时使用 Suspense 包裹
这样做的原因
  • 静态外壳保持可缓存
  • 动态行为显式且范围明确
  • 流式传输让 UI 保持响应
  • 不会意外导致整条路由动态回退
import { Suspense, type ReactNode } from 'react';

type PageProps = { params: Promise<{ slug: string }> };

/** Request-aware server entry. */
export default async function PageEntry({ params }: PageProps) {
  const { slug } = await params;

  const staticData = await getStaticData(slug); // deterministic
  const userData = await getUserData(); // request-dependent

  const dynamicPanel = <PersonalizedPanel userData={userData} />;

  return <PageStatic data={staticData} panel={dynamicPanel} />;
}

type PageStaticProps = {
  data: StaticData;
  panel?: ReactNode;
};

/** Cached static shell. Keep request-volatile reads out. */
async function PageStatic({ data, panel }: PageStaticProps) {
  'use cache';

  return (
    <main>
      <Hero data={data.hero} />
      <Content data={data.content} />
      <Suspense fallback={<PanelSkeleton />}>{panel}</Suspense>
    </main>
  );
}

缓存组件的设置与机制

启用
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

此功能取代旧的 experimental.ppr 标志。

三种内容类型
类型特征示例
静态同步、纯计算<header><h1>我们的博客</h1></header>
缓存('use cache')异步,但对于给定输入具有确定性使用 cacheLife('hours') 的 db.posts.findMany()
动态(Suspense)运行时/请求特定,必须保持最新cookies()、用户会话、通知
'use cache' 作用域级别
// File level — entire module cached
'use cache'
export default async function Page() { /* ... */ }

// Component level
export async function CachedComponent() {
  'use cache'
  const data = await fetchData()
  return <div>{data}</div>
}

// Function level
export async function getData() {
  'use cache'
  return db.query('SELECT * FROM posts')
}
使用 cacheLife() 配置缓存配置文件
import { cacheLife } from 'next/cache'

async function getData() {
  'use cache'
  cacheLife('hours')  // Built-in: 'default' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max'
  return fetch('/api/data')
}

// Or inline config:
async function getDataCustom() {
  'use cache'
  cacheLife({
    stale: 3600,      // 1h — serve stale while revalidating
    revalidate: 7200, // 2h — background revalidation interval
    expire: 86400,    // 1d — hard expiration
  })
  return fetch('/api/data')
}

内置配置文件快捷方式:仅使用 'use cache' → 5m 过期 / 15m 重新验证。'use cache: remote' → 平台 KV。'use cache: private' → 允许使用运行时 API(合规性应急方案)。

缓存失效
import { cacheTag } from 'next/cache'

async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)
  return db.products.findUnique({ where: { id } })
}

updateTag() — 立即在同一请求中使缓存失效:

'use server'
import { updateTag } from 'next/cache'

export async function updateProduct(id: string, data: FormData) {
  await db.products.update({ where: { id }, data })
  updateTag(`product-${id}`)  // caller sees fresh data
}

revalidateTag() — 后台采用过期后重新验证:

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

export async function createPost(data: FormData) {
  await db.posts.create({ data })
  revalidateTag('posts')  // next request sees fresh data
}
缓存键生成(自动)

缓存键由以下内容生成:构建 ID + 函数位置哈希 + 可序列化参数 + 闭包变量。无需像 unstable_cache 那样手动指定 keyParts。

async function Component({ userId }: { userId: string }) {
  const getData = async (filter: string) => {
    'use cache'
    // cache key = userId (closure) + filter (argument)
    return fetch(`/api/users/${userId}?filter=${filter}`)
  }
  return getData('active')
}

'use cache' 内不能包含的内容

硬性规则: 缓存边界内不得包含任何逐请求变化的内容。

'use cache' 内禁止的内容原因
cookies()、headers()特定于请求
searchParams特定于请求
会话/身份验证读取特定于用户
辅助函数调用中隐藏的用户逻辑隐式请求依赖
与请求生命周期相关的副作用非确定性
Math.random()、Date.now()在缓存中构建时执行一次
修复方法:移到外部,再作为参数传入
// Wrong — runtime API inside 'use cache'
async function CachedProfile() {
  'use cache'
  const session = (await cookies()).get('session')?.value  // Error!
  return <div>{session}</div>
}

// Correct — extract in entry, pass as prop
async function ProfilePage() {
  const session = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={session} />
}

async function CachedProfile({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId becomes part of cache key automatically
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}

例外:在无法实际重构的合规场景中,'use cache: private' 允许使用 cookies() / headers()。


RSC 边界规则

这些规则与静态外壳模式直接相关。

异步客户端组件无效

客户端组件不能是异步的。只有服务器组件可以是异步的。

// Bad
'use client'
export default async function UserProfile() {
  const user = await getUser()  // Cannot await in client
  return <div>{user.name}</div>
}

// Good — fetch in server entry, pass data down
// page.tsx (server)
export default async function Page() {
  const user = await getUser()
  return <UserProfile user={user} />
}

// UserProfile.tsx (client)
'use client'
export function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>
}
不可序列化的 props 会破坏边界

从服务器端传到客户端的 props 必须可序列化为 JSON。

不能传递的内容修复方法
函数(Server Actions 除外)在客户端组件内部定义
Date 对象在服务器端调用 .toISOString()
Map、Set使用 Object.fromEntries() / Array.from()
类实例传递普通对象

Server Actions('use server')可以传递给客户端组件——这是例外。


异步模式(Next.js 15+)

params、searchParams、cookies()、headers() 都是异步的。将其类型声明为 Promise<...>,并在入口组件中使用 await。

type PageProps = {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ query?: string }>
}

export default async function Page({ params, searchParams }: PageProps) {
  const { slug } = await params
  const { query } = await searchParams
  // ...
}

对于需要使用 params 的同步客户端组件,请使用 React.use():

import { use } from 'react'

export default function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = use(params)
}

Suspense 边界要求

useSearchParams 始终需要 Suspense

如果没有 Suspense,整个页面都会变为 CSR:

// Bad — entire page CSR bailout
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchBar() {
  const searchParams = useSearchParams()
  return <div>Query: {searchParams.get('q')}</div>
}

// Good — isolated in Suspense
import { Suspense } from 'react'
export default function Page() {
  return (
    <Suspense fallback={<SearchSkeleton />}>
      <SearchBar />
    </Suspense>
  )
}
快速参考
Hook是否需要 Suspense
useSearchParams()始终需要
usePathname()在动态路由中需要
useParams()不需要
useRouter()不需要

Provider 岛(以简洁方式处理客户端 Provider)

规则

将客户端 Provider 挂载在尽可能低的位置,并且只在需要交互性的地方挂载。

  • 好:特性级 Provider 岛
  • 不好:为局部特性状态挂载全局根 Provider
模式

服务端入口传递带类型的初始状态。客户端 Provider 在 'use client' 边界内解析。Hook 保留在岛内。

'use client';

import { createContext, useContext, useMemo } from 'react';

type FeatureState = { enabled: boolean };
type FeatureContextValue = { state: FeatureState };

const FeatureContext = createContext<FeatureContextValue | null>(null);

export function FeatureProvider({
  children,
  initialState,
}: {
  children: React.ReactNode;
  initialState: FeatureState;
}) {
  const value = useMemo(() => ({ state: initialState }), [initialState]);
  return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
}

export function useFeature() {
  const ctx = useContext(FeatureContext);
  if (!ctx) throw new Error('useFeature must be used within FeatureProvider');
  return ctx;
}

静态外壳模型中的数据获取

决策树
需求模式
在服务端组件中读取数据直接获取——无需 API 层
UI 中的变更操作Server Action('use server')
外部 API / webhook / 移动客户端Route Handler
客户端组件需要数据从服务端父组件传入(首选)或使用 Route Handler
避免瀑布式请求
// Bad — sequential
const user = await getUser();
const posts = await getPosts();

// Good — parallel
const [user, posts] = await Promise.all([getUser(), getPosts()]);

// Better — streaming with Suspense (each section independent)
<Suspense fallback={<UserSkeleton />}><UserSection /></Suspense>
<Suspense fallback={<PostsSkeleton />}><PostsSection /></Suspense>
预加载模式
import { cache } from 'react';

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

export const preloadUser = (id: string) => {
  void getUser(id);  // fire-and-forget, deduped by cache()
};

链接预加载策略

规则
  • 积极预加载静态/常用路由
  • 对个性化/查询密集/易变的 URL 禁用预取
  • 预加载外壳,将用户专属数据延迟到动态边界之后
  • generateStaticParams 可提升常用路径的预取命中质量
import Link from 'next/link';

/** Static/common route: keep default prefetch. */
<Link href={`/docs/${slug}`}>Read next</Link>

/** Personalized or volatile route: disable speculative prefetch. */
<Link href={`/certificate/${userId}?name=${encodeURIComponent(name)}`} prefetch={false}>
  View certificate
</Link>
预加载决策清单
  • 路由是静态且经常访问的吗?→ 预加载
  • 路由是个性化或易变的吗?→ 不要预加载
  • 用户数据是否延迟到 Suspense/动态岛之后?→ 仅预加载外壳
  • 是否有实测的导航改进?→ 保留预取;否则移除

决策矩阵

场景模式
静态内容 + 个性化控件入口(动态)+ 缓存的静态渲染器 + 插槽注入
可缓存的确定性服务端工作'use cache' 边界
纯客户端交互局部 'use client' Provider 岛
更快的导航定向预取 + 静态参数覆盖

常见故障模式及修复方法

  1. 整个路由意外变为动态

    • 原因:与请求绑定的读取(cookies()、headers())泄漏到静态外壳中
    • 修复:将这些读取移至入口组件,传递插槽 props
  2. 客户端水合负担过重

    • 原因:为特性局部状态在根部挂载全局 Provider
    • 修复:将 Provider 下移至特性/布局片段
  3. 预取浪费且网络请求嘈杂

    • 原因:预取个性化/查询密集的链接
    • 修复:对易变 URL 使用 prefetch={false}
  4. 动态工作阻塞静态外壳

    • 原因:动态组件未通过 Suspense 接缝渲染
    • 修复:在动态插槽外包裹 Suspense,并提供简短的回退内容
  5. 数据流归属不明确

    • 原因:静态/动态逻辑混杂在同一组件中
    • 修复:通过严格的 props 契约,强制拆分 Entry 和 Static 渲染器
  6. useSearchParams 导致整页 CSR 回退

    • 原因:读取搜索参数的组件缺少 Suspense 边界
    • 修复:始终将使用 useSearchParams 的组件包裹在 Suspense 中
  7. Date/Map/class props 悄然导致客户端组件出错

    • 原因:非可序列化的 props 跨越 RSC→客户端边界传递
    • 修复:在服务端进行序列化(.toISOString()、Object.fromEntries()、纯对象)
  8. 代码库中仍有 unstable_cache

    • 原因:v16 之前的缓存模式尚未迁移
    • 修复:替换为 'use cache' + cacheTag() + cacheLife()——无需手动维护键数组

从先前版本迁移

旧配置替换项
experimental.pprcacheComponents: true
dynamic = 'force-dynamic'移除(默认行为)
dynamic = 'force-static''use cache' + cacheLife('max')
revalidate = NcacheLife({ revalidate: N })
unstable_cache()'use cache' 指令
unstable_cache → 'use cache'
// Before
const getCachedUser = unstable_cache(
  async (id) => getUser(id),
  ['my-app-user'],
  { tags: ['users'], revalidate: 60 }
)

// After
async function getCachedUser(id: string) {
  'use cache'
  cacheTag('users')
  cacheLife({ revalidate: 60 })
  return getUser(id)
}

主要区别:无需手动设置缓存键(根据参数和闭包自动生成),通过 cacheTag() 设置标签,通过 cacheLife() 重新验证。


限制

  • 不支持 Edge 运行时——需要 Node.js
  • 不支持静态导出——需要服务端
  • 非确定性值(Math.random()、Date.now())在 'use cache' 内会在构建时执行一次

如需在缓存外生成请求时的随机值:

import { connection } from 'next/server'

async function DynamicContent() {
  await connection()  // defer to request time
  const id = crypto.randomUUID()
  return <div>{id}</div>
}

实施顺序

  1. 按路由识别静态与动态输入
  2. 将页面拆分为 Entry + 缓存的 Static 渲染器
  3. 将个性化部分转换为有类型的插槽
  4. 在插槽渲染位置周围添加 Suspense
  5. 将 providers 重构为客户端岛
  6. 为导航链接应用预取规则
  7. 为高流量静态路由添加静态参数
  8. 测量变更前后数据(TTFB、shell 绘制时间、导航延迟、预取流量)

PR 验收标准

  • 静态 shell 无需等待用户专属数据即可渲染
  • 缓存边界内不包含随请求变化的读取操作
  • 动态 UI 仅通过显式插槽边界呈现
  • 客户端 providers 限定在功能范围内,除非有充分理由将其设为全局
  • 个性化/易变链接已显式禁用预取
  • 导航到常见静态路由时已预加载,且速度有可测量的提升
  • useSearchParams 使用方已用 Suspense 包裹
  • 没有不可序列化的 props 跨越 RSC→客户端边界
  • 不再使用 unstable_cache——已迁移为 'use cache' 指令

评论

支持 Markdown