TanStack Start 中文文档
渲染

增量静态再生成(ISR)

增量静态再生成(Incremental Static Regeneration,ISR)让你从 CDN 提供静态生成的内容,同时在后台定期重新生成。这样既能获得静态站点的性能优势,又能获得动态内容的新鲜度。

ISR 在 TanStack Start 中如何工作

TanStack Start 的 ISR 方案很灵活,它利用适用于任何 CDN 的标准 HTTP 缓存头。与框架专属的 ISR 实现不同,这种方案让你在页面和数据两个层面都能完全控制缓存行为。

核心概念很简单:

  1. 静态预渲染:页面在构建时生成
  2. CDN 缓存:缓存头控制 CDN 缓存 HTML 多长时间
  3. 重新验证:缓存过期后,下一个请求触发重新生成
  4. 过期再验证(Stale-While-Revalidate):在后台获取新数据的同时,先提供过期内容

译者注:ISR 与 SSG/SSR 的区别

ISR 常被拿来和 SSG、SSR 对比:SSG 构建一次就永远不变(更新要重新构建);SSR 每个请求都实时渲染(最新但最慢);ISR 介于两者之间——首屏是构建时的静态 HTML(快、便宜),到期后 CDN 上的下一次请求会触发后台重新生成。TanStack Start 没有「专有 ISR 机制」,它靠标准 Cache-Control 头让 CDN 实现同样的效果,因此与任何 CDN 都兼容。

缓存头策略

基于时间的重新验证

最常见的 ISR 模式是在 Cache-Control 头中使用 max-ages-maxage 指令:

vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        routes: ['/blog', '/blog/posts/*'],
        crawlLinks: true,
      },
    }),
  ],
})
// routes/blog/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/blog/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  headers: () => ({
    // Cache at CDN for 1 hour, allow stale content for up to 1 day
    'Cache-Control':
      'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
  }),
})

export default function BlogPost() {
  const { post } = Route.useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

理解 Cache-Control 指令

  • public:任何缓存都可以缓存该响应(CDN、浏览器等)
  • max-age=3600:内容在 3600 秒(1 小时)内是新鲜的
  • s-maxage=3600:为共享缓存(CDN)覆盖 max-age
  • stale-while-revalidate=86400:在后台重新验证的同时,最多 24 小时内提供过期内容
  • immutable:内容永不变化(用于基于哈希的静态资源)

用服务器函数做 ISR

服务器函数也可以为动态数据端点设置缓存头:

// routes/api/products/$productId.ts
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/api/products/$productId')({
  server: {
    handlers: {
      GET: async ({ params, request }) => {
        const product = await db.products.findById(params.productId)

        return Response.json(
          { product },
          {
            headers: {
              'Cache-Control':
                'public, max-age=300, stale-while-revalidate=600',
              'CDN-Cache-Control': 'max-age=3600', // Cloudflare-specific
            },
          },
        )
      },
    },
  },
})

用中间件设置缓存头

对于 API 路由,可以用中间件设置缓存头:

// routes/api/products/$productId.ts
import { createFileRoute } from '@tanstack/react-router'
import { createMiddleware } from '@tanstack/react-start'

const cacheMiddleware = createMiddleware().server(async ({ next }) => {
  const result = await next()

  // Add cache headers to the response
  result.response.headers.set(
    'Cache-Control',
    'public, max-age=3600, stale-while-revalidate=86400',
  )

  return result
})

export const Route = createFileRoute('/api/products/$productId')({
  server: {
    middleware: [cacheMiddleware],
    handlers: {
      GET: async ({ params }) => {
        const product = await db.products.findById(params.productId)
        return Response.json({ product })
      },
    },
  },
})

对于页面路由,直接用 headers 属性更简单:

// routes/blog/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/blog/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    return { post }
  },
  headers: () => ({
    'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
  }),
})

按需重新验证

虽然基于时间的重新验证在大多数情况下表现良好,但你可能需要立即使特定页面失效(比如内容被更新时):

// routes/api/revalidate.ts
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/api/revalidate')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const { path, secret } = await request.json()

        // Verify secret token
        if (secret !== process.env.REVALIDATE_SECRET) {
          return Response.json({ error: 'Invalid token' }, { status: 401 })
        }

        // Trigger CDN purge via your CDN's API
        await fetch(
          `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
          {
            method: 'POST',
            headers: {
              Authorization: `Bearer ${CF_API_TOKEN}`,
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              files: [`https://yoursite.com${path}`],
            }),
          },
        )

        return Response.json({ revalidated: true })
      },
    },
  },
})

CDN 专属配置

Cloudflare Workers

Cloudflare 遵循标准的 Cache-Control 头,并提供额外的控制:

export const Route = createFileRoute('/products/$id')({
  headers: () => ({
    'Cache-Control': 'public, max-age=3600',
    // Cloudflare-specific header for finer control
    'CDN-Cache-Control': 'max-age=7200',
  }),
})

Netlify

Netlify 使用 Cache-Control 头,也支持 _headers 文件:

# public/_headers
/blog/*
  Cache-Control: public, max-age=3600, stale-while-revalidate=86400

/api/*
  Cache-Control: public, max-age=300

Vercel

部署到 Vercel 时,使用它们的 Edge Network 缓存头:

export const Route = createFileRoute('/posts/$id')({
  headers: () => ({
    'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
  }),
})

把 ISR 与客户端缓存结合

TanStack Router 的内置缓存控制可以与 CDN 缓存协同工作:

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    return fetchPost(params.postId)
  },
  // CDN caching (via headers)
  headers: () => ({
    'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
  }),
  // Client-side caching (via TanStack Router)
  staleTime: 60_000, // Consider data fresh for 60 seconds on client
  gcTime: 5 * 60_000, // Keep in memory for 5 minutes
})

这形成了一个多层级缓存策略:

  1. CDN 边缘:1 小时缓存,过期再验证 24 小时
  2. 客户端:60 秒新鲜数据,内存保留 5 分钟

常见 ISR 模式

博客文章

export const Route = createFileRoute('/blog/$slug')({
  loader: async ({ params }) => fetchPost(params.slug),
  headers: () => ({
    // Cache for 1 hour, allow stale for 7 days
    'Cache-Control': 'public, max-age=3600, stale-while-revalidate=604800',
  }),
  staleTime: 5 * 60_000, // 5 minutes client-side
})

电商产品页

export const Route = createFileRoute('/products/$id')({
  loader: async ({ params }) => fetchProduct(params.id),
  headers: () => ({
    // Shorter cache due to inventory changes
    'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
  }),
  staleTime: 30_000, // 30 seconds client-side
})

营销落地页

export const Route = createFileRoute('/landing/$campaign')({
  loader: async ({ params }) => fetchCampaign(params.campaign),
  headers: () => ({
    // Long cache for stable content
    'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
  }),
  staleTime: 60 * 60_000, // 1 hour client-side
})

用户专属页面

export const Route = createFileRoute('/dashboard')({
  loader: async () => fetchUserData(),
  headers: () => ({
    // Private cache, no CDN caching
    'Cache-Control': 'private, max-age=60',
  }),
  staleTime: 30_000,
})

最佳实践

1. 从保守开始

先用较短的缓存时间,随着你了解自己的内容更新模式再逐步加长:

// Start here
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'

// Then move to
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400'

2. 用 ETag 重新验证

ETag 帮助 CDN 高效地重新校验内容:

import { createMiddleware } from '@tanstack/react-start'
import crypto from 'crypto'

const etagMiddleware = createMiddleware().server(async ({ next }) => {
  const result = await next()

  // Generate ETag from response content
  const etag = crypto
    .createHash('md5')
    .update(JSON.stringify(result.data))
    .digest('hex')

  result.response.headers.set('ETag', `"${etag}"`)

  return result
})

3. 按查询参数区分缓存

当内容因查询参数而变化时,把它们纳入缓存 key:

export const Route = createFileRoute('/search')({
  headers: () => ({
    'Cache-Control': 'public, max-age=300',
    Vary: 'Accept, Accept-Encoding',
  }),
})

4. 监控缓存命中率

追踪 CDN 性能以优化缓存时间:

const cacheMonitoringMiddleware = createMiddleware().server(
  async ({ next }) => {
    const result = await next()

    // Log cache status (from CDN headers)
    console.log('Cache Status:', result.response.headers.get('cf-cache-status'))

    return result
  },
)

5. 与静态预渲染结合

在构建时预渲染以获得即时首屏,然后用 ISR 处理更新:

vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    tanstackStart({
      prerender: {
        routes: ['/blog', '/blog/posts/*'],
        crawlLinks: true,
      },
    }),
  ],
})

调试 ISR

检查缓存头

用浏览器 DevTools 或 curl 检查缓存头:

curl -I https://yoursite.com/blog/my-post

# Look for:
# Cache-Control: public, max-age=3600, stale-while-revalidate=86400
# Age: 1234 (time in cache)
# X-Cache: HIT (from CDN)

测试重新验证

强制缓存未命中来测试重新生成:

# Cloudflare: Bypass cache
curl -H "Cache-Control: no-cache" https://yoursite.com/page

# Or use CDN-specific cache purge APIs

监控性能

追踪关键指标:

  • 缓存命中率:由缓存直接响应的请求所占的百分比
  • 重新验证时间:重新生成过期内容所需时间
  • 首字节时间(TTFB):缓存内容应该很低

相关资源

On this page