CMS 前端内容渲染边界:Markdown、接口和页面如何配合

0阅读13分钟

内容来源会越来越多,而且会来得很快

去年年初我接手的内容站,最初只对接一个后端 CMS。产品经理在后台写文章,前端调接口渲染,逻辑清晰。半年后,技术团队想在文档站里放 Markdown 文件,用 Git 管理;运营又提出要做自动生成内容——每天从数据服务拉一批行业报告,转成可阅读的文章。到了年末,我数了一下,这个站的内容来源已经有五个。

问题不是「来源多」本身,而是每加一个来源,渲染规则就多一份歧义。列表页按时间排序,Markdown 文件的发布时间是本地的,CMS 文章的发布时间是 createdAt 字段,自动生成内容的发布时间是同步时间。三类内容混在一起,排序结果是错的。分类页也有类似问题——本地 Markdown 用的是手动分类,CMS 返回的是带 ID 的分类对象。侧边栏里出现了两个「前端」入口,一个是字符串「前端」,一个是 { id: 12, name: "前端" }

这类问题不修复可以撑几个月,但拖到下一次新增内容来源时,改起来就不是加一个适配器的事了。

我后来和几个做内容站的朋友聊过,发现这个问题非常普遍。大多数团队的应对策略是「先跑通再说」——每个新来源接入时在最近的页面组件里加几个 if 分支,反正能渲染就行。直到某一天,产品说要换 CMS,才发现全项目有 23 个文件在直接消费 CMS 的原始数据结构。

渲染边界的本质是数据归一

「渲染边界」这个词听起来像在讨论页面布局,实际上更接近数据架构的问题。我在做了几轮内容源接入后,总结出一个规律:页面上所有的不一致,根因都在数据层没有被归一化。

Vercel 的 Next.js 官方文档在讨论渲染策略时明确提出,SSG、SSR、ISR、CSR 的选择取决于内容更新频率和数据新鲜度要求。但这个选择的前提是——我已经把来自不同源头的内容统一到了一个可以消费的数据结构里。如果数据层本身是混乱的,我选哪种渲染策略都一样混乱。

Astro 在 Content Collections 文档里描述了这个问题的解法:用一个统一 API 来访问频繁更新的数据,来源可以是 CMS、API、数据库,但消费端看到的是一致的类型定义。Uniform.dev 的 Entry Patterns 文档也提出了类似思路——同一个内容类型可以配置多个入口模式,允许不同内容类型从不同来源拉取,但对外暴露统一 schema。

这些工具的具体实现不同,核心思路一致:把来源差异消化在数据层,让渲染层只关心「这个内容长什么样」,不关心「它从哪里来」。

我把这个思路拆成三层来理解:

┌─────────────────────────────────────────────────────┐
│  渲染层:页面组件、SEO 标签、列表排序、分类展示        │
├─────────────────────────────────────────────────────┤
│  归一层:统一 Schema、分类映射、来源标识、排序归一     │
├─────────────────────────────────────────────────────┤
│  来源层:CMS API、本地 Markdown、自动生成、外部同步    │
└─────────────────────────────────────────────────────┘

归一层是大多数内容站缺失的部分。来源层直接对接渲染层,页面组件里到处是 if (item.source === 'cms') 的分支逻辑。分类合并靠前端组件自己判断,排序在渲染层临时处理,SEO 数据从不同的数据源分别取。这种架构能跑,但每新增一个来源就是一次小型重构。

我在做第一版归一层时犯过一个错误——试图一步到位设计一个「完美」的统一 Schema,把所有来源的字段都塞进去。结果 UnifiedArticle 有 40 多个字段,其中一半是可选的。后来我把思路调整为「只归一渲染必需字段」:标题、作者、发布时间、内容 HTML、分类、封面。来源特有的字段放在 raw 里保留,渲染层需要时再访问。这个策略让统一接口保持简洁,同时不丢失任何原始数据。

案例一:分类合并的唯一入口

场景

我维护的博客有本地 Markdown 文章和 CMS 接口文章两套来源。侧边栏需要展示分类列表,包含每个分类下的文章总数。本地 Markdown 的分类是 YAML frontmatter 里的字符串数组 categories: ['前端', 'CMS'],CMS 返回的是分类对象数组 [{ id: 1, name: '前端' }]

翻车

最初的代码在侧边栏组件里分别取两个数据源,然后各自渲染。本地分类和 CMS 分类同名但类型不同,列表里出现了重复项。更麻烦的是文章计数——本地分类的计数逻辑在页面组件里用 filter 实现,CMS 分类的计数靠接口返回的 count 字段。当同一个分类在两个来源都有文章时,计数被分成了两条记录。

修复

我把分类合并逻辑从页面组件里抽出来,放到数据层的一个独立模块。这个模块的输入是所有来源的原始分类数据,输出是一个统一的分类列表,每个分类有唯一 ID、统一名称和合并后的文章计数。

// ❌ 坏做法:分类合并逻辑散落在页面组件里
// app/[locale]/blog/_components/sidebar.tsx
 
interface SidebarProps {
  cmsCategories: Array<{ id: number; name: string; count: number }>
  localCategories: string[]
  articles: Article[]
}
 
export function Sidebar({ cmsCategories, localCategories, articles }: SidebarProps) {
  // 手动合并两个来源,逻辑和 UI 耦合
  const allCategories = [
    ...cmsCategories.map(c => ({
      label: c.name,
      count: c.count,
      // 这里没有处理同名分类的合并
    })),
    ...localCategories.map(name => ({
      label: name,
      count: articles.filter(a => a.categories?.includes(name)).length,
    })),
  ]
 
  return (
    <nav>
      {allCategories.map(cat => (
        <span key={cat.label}>
          {cat.label} ({cat.count})
        </span>
      ))}
    </nav>
  )
}
// ✅ 好做法:分类合并在数据层完成,页面组件只消费结果
// lib/content/category-merger.ts
 
interface UnifiedCategory {
  // 唯一标识:CMS 分类用原始 ID,本地分类用 slug 化的名称
  id: string
  name: string
  // 合并后的文章计数
  articleCount: number
  // 来源标记,方便调试
  sources: Array<'cms' | 'local'>
}
 
interface CmsCategory {
  id: number
  name: string
  count: number
}
 
export function mergeCategories(
  cmsCategories: CmsCategory[],
  localCategoryNames: string[],
): UnifiedCategory[] {
  const categoryMap = new Map<string, UnifiedCategory>()
 
  // 先写入 CMS 分类
  for (const cat of cmsCategories) {
    const id = `cms-${cat.id}`
    categoryMap.set(cat.name, {
      id,
      name: cat.name,
      articleCount: cat.count,
      sources: ['cms'],
    })
  }
 
  // 本地分类:如果同名已存在则合并计数,否则新增
  for (const name of localCategoryNames) {
    const existing = categoryMap.get(name)
    if (existing) {
      // 同名分类合并:计数相加,标记多来源
      existing.articleCount += 1
      if (!existing.sources.includes('local')) {
        existing.sources.push('local')
      }
    } else {
      // 新分类:用名称生成稳定 ID
      categoryMap.set(name, {
        id: `local-${name.toLowerCase().replace(/\s+/g, '-')}`,
        name,
        articleCount: 1,
        sources: ['local'],
      })
    }
  }
 
  return Array.from(categoryMap.values()).sort(
    (a, b) => b.articleCount - a.articleCount,
  )
}

页面组件变成了纯消费者,不需要知道分类来自哪里:

// ✅ 好做法:页面组件只消费归一化结果
// app/[locale]/blog/_components/sidebar.tsx
 
interface SidebarProps {
  categories: UnifiedCategory[]
}
 
export function Sidebar({ categories }: SidebarProps) {
  return (
    <nav>
      {categories.map(cat => (
        <span key={cat.id}>
          {cat.name} ({cat.articleCount})
        </span>
      ))}
    </nav>
  )
}

这个改动本身不大,但它确立了一个规则:分类合并逻辑只存在于 category-merger.ts 一个文件里。后续新增「自动生成」来源时,只需要在同一个函数里增加一个处理分支,页面组件零修改。

案例二:详情页的来源差异消化

场景

CMS 文章和本地 Markdown 文章共用同一个详情页路由 /blog/[slug]。CMS 文章的 slug 是接口返回的字段,本地 Markdown 的 slug 来自文件名。CMS 文章有 author 对象(包含头像和简介),本地 Markdown 只有 author 字符串。

翻车

最早的做法是在详情页组件里用 try-catch 判断数据来源——先尝试调 CMS 接口,找不到就去读本地文件。这种方式有两个问题:一是每次页面加载都可能触发一次失败的 CMS 请求(对本地 Markdown 文章);二是 CMS 和本地的数据结构差异被推到了渲染组件,组件里充满了可选链 article.author?.avatar

修复

在数据层建一个统一的文章读取器,它知道每种来源的读取方式,对外返回统一的文章结构。来源识别在数据层完成,详情页组件拿到的一定是完整数据。

// ❌ 坏做法:详情页自己处理来源判断和数据转换
// app/[locale]/blog/[slug]/page.tsx
 
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  let article = null
 
  // 先查 CMS,失败再查本地——每次本地文章都要白跑一次接口
  try {
    article = await fetchCmsArticle(params.slug)
  } catch {
    article = await getLocalArticle(params.slug)
  }
 
  if (!article) return <div>Not found</div>
 
  return (
    <article>
      <h1>{article.title}</h1>
      {/* 数据结构不一致,渲染层被迫处理差异 */}
      <div className="author">
        {typeof article.author === 'object' ? (
          <img src={article.author.avatar} alt="" />
        ) : (
          <span>{article.author}</span>
        )}
      </div>
      {/* publishedAt 字段名在本地是 date,在 CMS 是 createdAt */}
      <time>{article.publishedAt || article.date || article.createdAt}</time>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  )
}
// ✅ 好做法:数据层统一来源识别和结构归一
// lib/content/article-resolver.ts
 
interface UnifiedArticle {
  slug: string
  title: string
  // 归一化后的作者信息,统一为对象
  author: {
    name: string
    avatar?: string
    bio?: string
  }
  // 统一的时间字段
  publishedAt: string
  // 统一的内容格式
  contentHtml: string
  // 来源标识
  source: 'cms' | 'local' | 'generated'
  // 原始数据保留,方便特殊场景
  raw: unknown
}
 
// 来源注册表:每种来源有自己的探测和读取逻辑
const resolvers: Array<{
  match: (slug: string) => Promise<boolean>
  read: (slug: string) => Promise<UnifiedArticle>
}> = [
  {
    // CMS 文章:slug 能匹配到接口返回的列表
    match: async (slug) => {
      const cmsSlugs = await getCmsArticleSlugs()
      return cmsSlugs.includes(slug)
    },
    read: async (slug) => {
      const raw = await fetchCmsArticle(slug)
      return {
        slug: raw.slug,
        title: raw.title,
        // CMS 返回的是对象,直接映射
        author: { name: raw.author.name, avatar: raw.author.avatar, bio: raw.author.bio },
        publishedAt: raw.createdAt,
        contentHtml: raw.body,
        source: 'cms',
        raw,
      }
    },
  },
  {
    // 本地 Markdown:文件系统上存在对应文件
    match: async (slug) => {
      const filePath = `content/articles/${slug}.md`
      return fileExists(filePath)
    },
    read: async (slug) => {
      const raw = await readLocalArticle(slug)
      return {
        slug,
        title: raw.frontmatter.title,
        // 本地只有字符串,包装成统一结构
        author: { name: raw.frontmatter.author || '匿名' },
        publishedAt: raw.frontmatter.date,
        contentHtml: raw.html,
        source: 'local',
        raw,
      }
    },
  },
]
 
export async function resolveArticle(slug: string): Promise<UnifiedArticle | null> {
  for (const resolver of resolvers) {
    // 按优先级逐个探测,命中即读取
    const matched = await resolver.match(slug)
    if (matched) {
      return resolver.read(slug)
    }
  }
  return null
}

详情页变成了纯渲染:

// ✅ 好做法:详情页只做渲染,不做数据来源判断
// app/[locale]/blog/[slug]/page.tsx
 
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await resolveArticle(params.slug)
 
  if (!article) notFound()
 
  return (
    <article>
      <h1>{article.title}</h1>
      {/* 所有来源的文章都保证有 author.name */}
      <div className="author">
        {article.author.avatar && <img src={article.author.avatar} alt="" />}
        <span>{article.author.name}</span>
      </div>
      {/* publishedAt 字段名统一,不需要 fallback 链 */}
      <time>{article.publishedAt}</time>
      <div dangerouslySetInnerHTML={{ __html: article.contentHtml }} />
    </article>
  )
}

这个模式在 Uniform.dev 的文档里叫做「multiple entry patterns per content type」——同一个内容类型,不同的读取入口,统一的消费接口。区别只是我没有用 Uniform,而是用一个 200 行的 TypeScript 模块实现了同样的思路。

案例三:SEO 数据和内容保持一致

场景

文章详情页需要生成 &lt;title&gt;&lt;meta description&gt;og:image 和 JSON-LD 结构化数据。这些数据必须和页面可见内容保持一致——搜索引擎看到的信息和用户看到的信息应该是同一份。

翻车

列表页和详情页分别取数据。列表页从 CMS 接口拿 summary 字段作为文章摘要,详情页从本地缓存拿 excerpt 字段。两个字段在大部分文章里内容相同,但有些文章被运营改过摘要,只有 CMS 里的 summary 是最新的。结果搜索引擎在列表页抓到的摘要和用户在详情页看到的正文对不上。

修复

SEO 数据和页面内容从同一个数据源的同一次读取中获取,不允许分别取。

// ❌ 坏做法:SEO 数据和页面内容从不同来源分别取
// app/[locale]/blog/[slug]/page.tsx
 
export async function generateMetadata({ params }: { params: { slug: string } }) {
  // 这里从缓存里取,可能和页面渲染时的数据不同步
  const cached = await getFromCache(`article:${params.slug}`)
 
  return {
    title: cached?.seoTitle || cached?.title,
    description: cached?.excerpt, // 列表页用 summary,这里用 excerpt,不一致
    openGraph: {
      images: [{ url: cached?.cover || '/default-og.png' }],
    },
  }
}
 
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  // 这里从 resolveArticle 取,和 metadata 不是同一次数据读取
  const article = await resolveArticle(params.slug)
  // ...
}
// ✅ 好做法:SEO 数据和页面内容共享同一个数据读取结果
// app/[locale]/blog/[slug]/page.tsx
 
// 在 layout 或上层统一读取,数据向下传递
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  // 和页面内容用同一个 resolveArticle
  const article = await resolveArticle(params.slug)
  if (!article) return {}
 
  return {
    title: article.title,
    // description 和 contentHtml 来自同一次数据读取
    description: extractExcerpt(article.contentHtml),
    openGraph: {
      // 封面数据也从统一结构取
      images: article.raw.cover ? [{ url: article.raw.cover.url }] : [],
      published_time: article.publishedAt,
      authors: [article.author.name],
    },
  }
}
 
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await resolveArticle(params.slug)
  if (!article) notFound()
 
  // JSON-LD 也从同一个 article 对象生成,保证和可见内容一致
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: extractExcerpt(article.contentHtml),
    datePublished: article.publishedAt,
    author: { '@type': 'Person', name: article.author.name },
    image: article.raw.cover?.url,
  }
 
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* ... */}</article>
    </>
  )
}

Google 的 结构化数据文档明确要求:JSON-LD 中的数据必须与页面上的可见内容一致。违反这条规则,轻则结构化数据不被识别,重则被标记为误导性结构化数据。

这条规则的执行难度在单来源内容站里几乎为零,但在多来源场景下很容易被忽视。因为不同来源的数据在列表页和详情页的获取路径不同,开发时如果只测了一种来源的文章,很容易漏掉其他来源的字段缺失问题。我现在的做法是在归一层的单元测试里覆盖所有来源,确保每种来源返回的 UnifiedArticle 结构完全一致——特别是 author.namepublishedAt 这两个字段,它们同时出现在页面正文、meta 标签和 JSON-LD 里,缺失任何一个都会导致渲染异常或 SEO 问题。

内容渲染的完整流程

流程图画布 · 115%
Mermaid 流程图加载中...

这张图的关键点不在流程本身,而在于归一化发生在渲染之前generateMetadataArticlePage、JSON-LD 三个输出都从同一个 UnifiedArticle 对象生成。数据读取和归一化只做一次,渲染层不做任何来源判断。

这里有一个容易忽略的陷阱:generateMetadataArticlePage 在 Next.js App Router 中是两个独立的函数,它们各自会触发一次数据获取。如果不刻意保证它们调用同一个 resolveArticle 并传入同一个 slug,就有可能拿到不同的数据版本。我在实际项目中遇到过一种情况——CMS 在两次调用之间更新了文章,导致 metadata 和页面正文不一致。解决方法是在更上层(比如 layout)做一次数据读取,把结果通过 pageProps 传给两个函数。

四种渲染策略在不同内容来源下的选择

不同内容来源的更新频率差异很大,直接决定了渲染策略的取舍。

内容来源更新频率推荐渲染策略原因
本地 Markdown每次部署更新SSG内容在构建时确定,可以完全静态化
CMS 文章运营随时发布ISR (revalidate: 300)需要定期刷新但不要求实时
自动生成内容定时任务拉取SSR 或 ISR (revalidate: 60)数据时效性高,缓存周期短
外部同步内容Webhook 触发SSR + 按需 revalidate更新由外部事件驱动,被动刷新
维度全部 SSG全部 SSR按来源混合
构建时间随内容量线性增长无构建开销静态部分有构建开销
首屏速度最快(CDN 直接返回)取决于服务端性能静态部分最优
内容新鲜度部署时才更新每次请求最新每种来源按自身频率更新
运维复杂度高,需要维护多种缓存策略
适合场景文档站、技术博客新闻站、社交平台多来源内容站
归一层设计优点缺点适用场景
无归一层,页面直接对接来源简单直接,零抽象成本多来源时维护成本爆炸单一来源的内容站
页面内条件分支不需要额外文件逻辑散落,难以测试2 个来源且短期不扩展
独立归一模块逻辑集中,易测试需要维护归一函数3 个及以上来源
独立归一服务可跨项目复用引入运行时依赖多团队、多站点共用
做法数据来源一致性可维护性扩展新来源的成本
列表页和详情页各自取数据❌ 容易不一致❌ 修改一个字段要改多处❌ 每个页面都要适配
统一从归一层读取✅ 保证一致✅ 改一处生效✅ 只在归一层加适配器
用 GraphQL 聚合✅ 保证一致✅ schema 即文档✅ 新增数据源接入 schema

渲染边界的检查清单

我把这些经验整理成了一份检查清单。每次接入新内容来源或新增渲染页面时,我会逐项过一遍。

接入新内容来源前

  • 确认新来源的数据结构,列出和现有统一 Schema 的差异字段
  • 确认新来源的更新频率,决定对应的渲染策略(SSG / ISR / SSR)
  • 在归一模块中新增该来源的读取器,不在页面组件中新增来源判断
  • 确认新来源的 slug 生成规则,不会和现有来源冲突
  • 确认新来源的时间字段格式,统一为 ISO 8601

页面渲染层

  • 页面组件不直接调用任何特定来源的 API,只消费归一化后的数据
  • generateMetadata 和页面渲染使用同一次数据读取的结果
  • JSON-LD 中的所有字段可以从统一数据结构中提取
  • 列表页的排序字段在所有来源中含义一致(同一个 publishedAt
  • 分类合并逻辑在归一层完成,页面组件不做分类去重

上线后验证

  • 每种来源各找一篇文章,验证详情页渲染一致
  • 验证列表页排序跨来源正确
  • 验证 SEO 标签和页面内容一致(用 Lighthouse 或搜索控制台检查)
  • 验证 JSON-LD 可被 Google Rich Results Test 识别
  • 验证分类页的文章计数包含所有来源

写在最后

内容站的渲染边界不是设计出来的,是随着内容来源增加逐步暴露出来的。早期只有一个 CMS 的时候,不需要归一层;加了本地 Markdown,分类合并的问题就浮出来了;加了自动生成内容,时间字段和排序规则的不一致就开始制造 bug。

我在这些坑里踩过的经验是:归一化做得越早,后面接入新来源的成本越低。 归一层不需要一上来就做到完美——一个 TypeScript 模块,几个函数,一个统一接口就够了。关键是这个模块要存在,而且页面组件要依赖它。

等到来源多到归一模块变得臃肿时,再考虑把它拆成独立服务或者引入 Contentlayer、Uniform 这类工具。但在那之前,一个写得好的归一函数比引入任何框架都有效。

关于工具选型,我补充一点个人看法。Contentlayer 和 Uniform 解决的是「归一层怎么做」的问题,但它们的引入成本不低——Contentlayer 需要配置构建管道,Uniform 需要接入它的 SaaS 平台。以我的经验,内容来源不超过三个、团队对 TypeScript 类型系统足够熟悉时,手写归一模块的投入产出比更高。等归一模块超过 500 行、或者需要跨多个项目共享时,再考虑工具化也不迟。

参考资料

评论 0

0 / 1000