我们在超过三年的时间里,在多个客户项目中使用 Sanity 作为主要的 CMS。在 3000 篇文章左右的时候,你不再从文档的角度思考 Sanity,而是从真正在生产环境中能存活下来的东西来思考。这篇文章就是那个大脑倾倒 -- 每一个我们后悔过的 schema 决策、每一个让构建停止的 GROQ 查询,以及每一个让编辑真正想使用 CMS,而不是给我们发送 Word 文档的 Studio 定制。

这不是一份入门指南。如果你在这里,你可能已经设置过 Sanity Studio,创建了一些 schema,也许已经上线了一两个网站。我想分享的是那些只有在处理了真实的内容团队、真实的编辑工作流和真实的规模化性能预算之后才会出现的模式。

目录

Sanity Studio 生产环境秘诀:3000+ 篇文章的经验教训

能存活在真实内容团队中的 Schema 设计

Schema 设计是大多数 Sanity 项目无声失败的地方。不是以戏剧性的方式崩溃 -- 更像是编辑信心的缓慢侵蚀。内容团队开始避开某些字段。他们创建变通方案。六个月后,你一半的结构化内容实际上被塞进了单个富文本块中,因为 schema"太复杂了"。

停止过度嵌套对象

我们最大的早期错误是创建深度嵌套的对象结构。我们会像建模数据库 schema 那样建模内容 -- 规范化、优雅、技术上正确。一篇博文有一个 author 引用,它有一个 bio 对象,它有一个 socialLinks 对象数组,每个对象有一个 platform 引用。

编辑讨厌它。每次他们需要更新作者的 Twitter 句柄时,他们都要点击五次。以下是我们现在的做法:

// 之前:过度设计
export default defineType({
  name: 'author',
  type: 'document',
  fields: [
    defineField({
      name: 'name',
      type: 'string',
    }),
    defineField({
      name: 'bio',
      type: 'object',
      fields: [
        defineField({
          name: 'content',
          type: 'array',
          of: [{ type: 'block' }],
        }),
        defineField({
          name: 'socialLinks',
          type: 'array',
          of: [
            defineArrayMember({
              type: 'object',
              fields: [
                { name: 'platform', type: 'reference', to: [{ type: 'platform' }] },
                { name: 'url', type: 'url' },
              ],
            }),
          ],
        }),
      ],
    }),
  ],
})

// 之后:扁平、编辑友好
export default defineType({
  name: 'author',
  type: 'document',
  fields: [
    defineField({ name: 'name', type: 'string', validation: (r) => r.required() }),
    defineField({ name: 'bio', type: 'array', of: [{ type: 'block' }] }),
    defineField({ name: 'twitter', type: 'url', title: 'Twitter / X URL' }),
    defineField({ name: 'linkedin', type: 'url', title: 'LinkedIn URL' }),
    defineField({ name: 'github', type: 'url', title: 'GitHub URL' }),
  ],
})

是的,扁平版本不那么"纯粹"。它也能 100% 被正确使用。权衡已接受。

积极使用字段组

一旦文档类型有超过 8-10 个字段,编辑就开始滚动并错过东西。Sanity v3 的字段组被低估了。我们在每个超过六个字段的文档类型上都使用它们:

export default defineType({
  name: 'post',
  type: 'document',
  groups: [
    { name: 'content', title: '内容', default: true },
    { name: 'seo', title: 'SEO' },
    { name: 'settings', title: '设置' },
  ],
  fields: [
    defineField({ name: 'title', type: 'string', group: 'content' }),
    defineField({ name: 'body', type: 'array', of: [{ type: 'block' }], group: 'content' }),
    defineField({ name: 'seoTitle', type: 'string', group: 'seo' }),
    defineField({ name: 'seoDescription', type: 'text', rows: 3, group: 'seo' }),
    defineField({ name: 'publishDate', type: 'datetime', group: 'settings' }),
    defineField({ name: 'featured', type: 'boolean', group: 'settings' }),
  ],
})

指导而非限制的验证

我们学会了将验证视为 UX,而不是强制执行。对每个字段的硬 required() 验证意味着编辑无法保存草稿。解释"为什么"某些东西很重要的自定义验证消息能获得比通用错误状态好得多的合规性:

defineField({
  name: 'excerpt',
  type: 'text',
  rows: 3,
  validation: (rule) =>
    rule
      .max(160)
      .warning('超过 160 个字符的摘要在搜索结果和社交卡中会被截断。'),
})

注意这是 warning,不是 error。编辑仍然可以发布。他们只是知道后果。

规模化下的 GROQ 性能:什么才重要

GROQ 很棒,直到它不棒为止。在 500 个文档时,一切都很快。在 3000+ 个文档,带有引用、图像和可移植文本时,你开始注意到一些东西。

投影不是可选的

单一最大的 GROQ 性能杠杆是投影。当你只需要三个字段时,不要获取整个文档。我见过 Next.js 构建从 4 分钟缩短到 90 秒,仅通过修复 generateStaticParams 调用中的 GROQ 投影。

// 慢:获取所有内容,包括可移植文本、图像、引用
*[_type == "post"]

// 快:只获取列表页面实际需要的
*[_type == "post"] | order(publishedAt desc) [0...20] {
  _id,
  title,
  slug,
  publishedAt,
  "authorName": author->name,
  "thumbnailUrl": thumbnail.asset->url
}

那个 author->name 内联解引用很关键。它避免了获取整个作者文档。当你有 3000 篇文章,每篇引用 50 个作者中的一个时,差异是可衡量的。

没有人谈论的 Join 问题

Sanity 的 GROQ 文档展示解引用好像是免费的。它不是。GROQ 查询中的每个 -> 本质上都是一次 join。在返回 100 个结果的列表查询中堆叠三或四个,你就能感受到它。

我们现在在项目中配置每个 GROQ 查询。以下是我们的经验法则:

模式 文档 平均响应时间
简单获取,无引用 3,000 ~120ms
一级 -> 解引用 3,000 ~250ms
两级 -> 3,000 ~600ms
内部有 -> 的嵌套数组 3,000 ~1,200ms+

这些是我们 Sanity API 仪表板在 2025 年中期的真实数字。根据文档大小,你的情况可能不同,但趋势是一致的。

我们经常使用的 GROQ 模式

预览与发布的条件获取:

*[_type == "post" && slug.current == $slug && ($preview || !(_id in path('drafts.**')))] [0] {
  ...,
  "author": author-> { name, slug, image },
  "categories": categories[]-> { title, slug }
}

带计数的分页查询:

{
  "posts": *[_type == "post"] | order(publishedAt desc) [$start...$end] {
    _id, title, slug, publishedAt,
    "authorName": author->name
  },
  "total": count(*[_type == "post"])
}

无 N+1 的相关文章:

*[_type == "post" && slug.current == $slug][0] {
  ...,
  "related": *[_type == "post" && _id != ^._id && count(categories[@._ref in ^.^.categories[]._ref]) > 0] | order(publishedAt desc) [0...3] {
    title, slug, publishedAt
  }
}

那个相关文章查询很复杂,但它在 Sanity 的基础设施中服务器端运行,所以它通常比进行两次往返更快。

值得投入的 Studio 定制

原生 Sanity Studio 对开发者来说还不错。对于每周发布 20 篇文章的内容团队来说,不行。以下是我们在每个项目上定制的内容。

自定义文档操作

默认发布操作在每个设置中都不能可靠地触发 webhook 来进行增量构建。我们包装它:

import { useDocumentOperation } from 'sanity'

export function createPublishWithWebhookAction(originalPublishAction) {
  return function PublishWithWebhook(props) {
    const originalResult = originalPublishAction(props)
    return {
      ...originalResult,
      onHandle: async () => {
        await originalResult.onHandle()
        // 触发 ISR 重新验证或部署钩子
        await fetch('/api/revalidate', {
          method: 'POST',
          body: JSON.stringify({ type: props.type, id: props.id }),
        })
      },
    }
  }
}

用于编辑工作流的结构生成器

默认的 desk 结构在平面列表中显示每个文档类型。在 15+ 个文档类型时,这是混乱。我们使用结构生成器创建面向编辑的导航:

import { StructureBuilder } from 'sanity/structure'

export const structure = (S: StructureBuilder) =>
  S.list()
    .title('内容')
    .items([
      S.listItem()
        .title('博客')
        .child(
          S.list()
            .title('博客')
            .items([
              S.listItem()
                .title('已发布的文章')
                .child(
                  S.documentList()
                    .title('已发布')
                    .filter('_type == "post" && !(_id in path("drafts.**"))')
                ),
              S.listItem()
                .title('草稿')
                .child(
                  S.documentList()
                    .title('草稿')
                    .filter('_type == "post" && _id in path("drafts.**")')
                ),
              S.listItem()
                .title('所有文章')
                .child(S.documentTypeList('post').title('所有文章')),
            ])
        ),
      S.divider(),
      // ... 其他内容类型
    ])

这需要 30 分钟的设置,每周为编辑节省数小时的困惑。

便携式文本自定义组件

一件打击我们很深的事情:编辑从 Google Docs 粘贴内容到便携式文本编辑器。默认块编辑器处理这个还好,但自定义块类型需要明确的序列化器,否则它们会显示为空框,编辑会惊慌失措。

我们为每种块类型注册自定义组件:

defineArrayMember({
  type: 'object',
  name: 'codeBlock',
  title: '代码块',
  fields: [
    defineField({ name: 'code', type: 'text' }),
    defineField({ name: 'language', type: 'string',
      options: { list: ['javascript', 'typescript', 'python', 'bash', 'groq'] }
    }),
  ],
  preview: {
    select: { code: 'code', language: 'language' },
    prepare({ code, language }) {
      return {
        title: `代码 (${language || 'plain'})`,
        subtitle: code?.slice(0, 80) + '...',
      }
    },
  },
})

那个 preview 配置很小但至关重要。没有它,编辑看到空白块,不知道它们是什么。

Sanity Studio 生产环境秘诀:3000+ 篇文章的经验教训 - 架构

内容迁移和数据完整性

我们已经进行了五次主要的内容迁移到 Sanity -- 从 WordPress、Contentful、Prismic、markdown 文件和自定义 Rails CMS。每一次都教了我们一些痛苦的东西。

使用迁移工具,但信任并验证

Sanity 的 @sanity/migrate 包和 CLI 的 sanity documents import 对于直接的情况效果很好。对于任何涉及便携式文本转换的东西,编写自定义脚本。总是。

# 在任何迁移之前导出所有内容以进行备份
sanity dataset export production ./backup-$(date +%Y%m%d).tar.gz

我们在每次迁移、每次 schema 部署,诚实地说,通过 cron 每周一早上都运行这个。数据集是便宜的。丢失的内容不是。

Schema 版本化策略

Sanity 不在数据层强制 schema 版本。这既是一个功能,也是一个脚枪。旧文档不会在你更改 schema 时神奇地更新。我们使用一个简单的模式:

defineField({
  name: 'schemaVersion',
  type: 'number',
  hidden: true,
  initialValue: 2,
  readOnly: true,
})

然后在迁移脚本中,我们可以查询 *[_type == "post" && schemaVersion < 2] 并批量更新文档到新格式。这很粗糙但有效。

部署和环境策略

Sanity 的数据集模型支持多个环境,你应该从第一天开始使用它们 -- 而不是在你第一次生产数据事件后。

我们的标准设置

环境 数据集 Studio URL 用途
生产环境 production studio.client.com 实时内容编辑
测试环境 staging staging-studio.client.com 内容 QA、schema 测试
开发环境 development localhost:3333 Schema 开发

我们每周使用 sanity dataset copy production staging 将生产克隆到测试。这使测试保持逼真,而不会在 schema 实验中冒生产数据的风险。

对于前端,我们的 Next.js 开发项目使用环境变量切换数据集:

const config = {
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  apiVersion: '2025-01-01',
  useCdn: process.env.NODE_ENV === 'production',
}

CDN 与非 CDN

Sanity 的 API CDN 最终一致。对于营销网站上的已发布内容,这没问题 -- CDN 很快,陈旧性窗口通常在 2 秒以下。对于预览/草稿内容,始终绕过 CDN:

const client = sanityClient.withConfig({
  useCdn: false,
  token: process.env.SANITY_PREVIEW_TOKEN,
  perspective: 'previewDrafts',
})

我们见过预览问题花了数小时调试,只是意识到预览客户端在击中 CDN 并显示陈旧数据。对所有预览和草稿读取上下文设置 useCdn: false

生产环境中的监控和调试

GROQ 查询性能分析

Sanity 的管理控制台 (manage.sanity.io) 显示 API 使用指标,但粒度不总是足够的。我们在前端记录慢查询:

async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
  const start = performance.now()
  const result = await client.fetch<T>(query, params)
  const duration = performance.now() - start

  if (duration > 500) {
    console.warn(`慢 GROQ 查询 (${duration.toFixed(0)}ms):`, query.slice(0, 200))
  }

  return result
}

生产环境中任何超过 500ms 的东西都会被调查。通常是一个未投影的查询或一个在代码审查中溜过的嵌套解引用。

Webhook 可靠性

Sanity webhook 可靠但并非绝对可靠。我们在 Sanity 基础设施更新期间看到了偶尔错过的 webhook。对于关键工作流(如在我们的 Astro 开发项目中触发重建),我们实现了一个轮询回退:

// 每 5 分钟检查一次最近的更改作为安全网
const POLL_INTERVAL = 5 * 60 * 1000

setInterval(async () => {
  const lastModified = await client.fetch(
    `*[_type == "post"] | order(_updatedAt desc) [0]._updatedAt`
  )
  if (new Date(lastModified) > lastKnownUpdate) {
    await triggerRebuild()
    lastKnownUpdate = new Date(lastModified)
  }
}, POLL_INTERVAL)

真实项目的性能基准

以下是我们在 2024-2025 年使用 Sanity 与无头前端一起发布的三个生产项目的真实数字:

指标 项目 A (Next.js) 项目 B (Astro) 项目 C (Next.js)
总文档数 3,200 1,800 4,100
文档类型 12 8 18
平均 GROQ 响应 (CDN) 85ms 72ms 130ms
平均 GROQ 响应 (无 CDN) 180ms 145ms 290ms
完整静态构建时间 3m 20s 1m 45s 6m 10s
ISR 重新验证 1.2s 不适用 (静态) 1.8s
月度 API 请求 ~450K ~180K ~1.2M
Sanity 计划成本/月 Growth ($99) 免费 Growth ($99)

项目 C 的更长构建时间完全是由于图像处理,而不是 GROQ。一旦我们移动到 Sanity 的图像管道,使用 @sanity/image-url 和适当的 width/height 参数,构建就停止了下载全分辨率图像。

对于无头 CMS 开发项目,Sanity 的定价是有竞争力的。免费层对于较小的网站来说是真正可用的。Growth 计划在 $99/月 涵盖了大多数中等规模的编辑操作。你只有在非常高的 API 请求量时才开始遇到成本问题,即使这样,积极的 CDN 使用和聪明的缓存也能保持合理。

Sanity 不是正确选择的时候

如果我不提及我们让客户远离 Sanity 的情况,那就太失职了:

  • 高度关系型数据(具有复杂变体关系的产品目录)-- 专用商务平台或甚至 Postgres 更有意义
  • 极其不技术的团队需要 WYSIWYG 页面生成器 -- Sanity 的便携式文本很强大,但它不是 Squarespace
  • 预算受限的项目,带有 >200K 月度 API 请求 -- 成本可能会惊动你

对于一切其他的 -- 特别是编辑内容、营销网站和文档 -- Sanity 一直是我们的首选 CMS。如果你在为无头项目评估选项,联系我们,我们将根据你的特定需求给你一个诚实的评估。

常见问题

Sanity 在性能下降之前可以处理多少个文档? 我们运行了超过 4000 个文档的生产项目,没有明显的性能下降。Sanity 的托管基础设施很好地处理了文档计数,甚至进入数万。性能瓶颈几乎总是在于你如何编写 GROQ 查询 -- 特别是未投影的获取和深引用链 -- 而不是原始文档计数。

我应该将 GROQ 或 GraphQL 与 Sanity 一起使用吗? GROQ,除非你有一个非常具体的理由使用 GraphQL。GROQ 对 Sanity 的文档模型更具表现力,更自然地支持投影,并从 Sanity 团队获得一流的关注。GraphQL API 从你的 schema 自动生成并可以正常工作,但你失去了使 Sanity 强大的一些查询灵活性。

你如何处理与 Sanity 和 Next.js 的草稿预览? 我们使用 Next.js 草稿模式结合 Sanity 的 perspective: 'previewDrafts' 设置。预览客户端绕过 CDN 并使用读令牌。Sanity 的 @sanity/preview-kit 包提供实时监听器,在编辑时更新页面。它需要一些设置,但编辑体验值得。

为 SEO 构建便携式文本的最佳方式是什么? 将你的便携式文本块样式映射到适当的语义 HTML。使用 h2h3h4 样式(不仅仅是"大文本"或"标题")。为常见问题解答部分、操作步骤和代码块等结构化数据添加自定义块类型。我们使用 @portabletext/react 的自定义序列化器将便携式文本呈现为 HTML,输出 schema.org 友好的标记。

你如何使用 Sanity 处理图像优化? Sanity 的图像管道很优秀。使用 @sanity/image-url 生成具有特定尺寸和格式参数的 URL。始终设置 auto=format 让 Sanity 根据浏览器支持提供 WebP 或 AVIF。对于 Next.js 项目,我们将 Sanity 图像加载器与 next/image 一起使用 -- 这给你 Sanity 的 CDN 和 Next.js 的内置图像优化。

Sanity 能否大规模处理本地化/多语言内容? 是的,但你的 schema 设计至关重要。我们使用文档级国际化模式(按共享 i18nId 字段链接的每个语言环境的单独文档),而不是字段级翻译对象。在 3000+ 个文档的三个语言环境中,这使查询保持简单,避免了每个字段包含带有 5+ 语言键的对象时得到的巨大文档大小。

你应该多久更新一次你的 Sanity API 版本? 将你的 API 版本固定到特定日期(如 2025-01-01),并在审查更新日志后每季度更新一次。Sanity 的 API 版本是基于日期的,重大变化很少,但它们确实发生。我们被未记录的 API 版本之间的 GROQ 行为更改咬过 -- 总是在碰撞版本后测试你的关键查询。

一个大型编辑团队的 Sanity 成本是多少? Growth 计划在 $99/月(截至 2025 年中期)包括 1M API 请求、500K API CDN 请求和 20 个用户。对于大多数每周发布 20-50 篇文章的编辑团队,这绰绰有余。主要成本驱动力是 API 请求 -- 来自你前端的每个 GROQ 查询都会计数。积极使用 CDN,在可能的地方缓存,避免随流量倍增的客户端获取。