《互联网产品的客户端架构·内容、社交与实时互动》3.1 结构化内容:把正文当成数据而不是 HTML 字符串

正文不该是一段 HTML 字符串,而是一份可校验、可降级的块文档。本节定义段落、图片、视频、代码、引用五类块与行内标记,给出未知块降级、未来版本拒绝与确定性序列化的 TypeScript 实现,并用测试覆盖迁移、round-trip 与坏字段。

先把边界说清楚

这一节只回答一个问题:服务端交给客户端的正文,应该用什么形状表示。 它不讨论 XSS 过滤,那是 3.2 的主题;也不讨论图片占位与懒加载,那是 3.3 的主题;上传与编辑器交互留到第四章。本节结束时,你会得到一个不依赖框架、可以在 Node 里直接跑测试的块文档模型。

卷一讨论过内容面有哪些页面、信息流容器怎么存状态。那些决定回答的是「数据放在哪、活多久」。这一节往下一层:单条内容的正文本身,应该如何被表达。 如果这一步做错,后面所有渲染优化都只能在一个错误的表示上打补丁。

「回声」的第一版把正文存成一段 HTML 字符串。理由很朴素:编辑器输出 HTML,WebView 直接渲染,服务端不用理解内容结构。这个决定在只有一种客户端、只需要展示时看起来成立,但很快遇到三类问题:

  • 摘要不可控。 推送和分享卡片需要前 80 个字符,直接 slice 会把 <img src="... 切成半截,用户看到裸露的标签碎片。
  • 未知标签裸奔。 服务端上线了新的 <poll> 组件,旧版客户端不认识,页面上直接出现 <poll data-id="7"> 这样的原文。
  • 多面复用的成本极高。 搜索要纯文本,翻译要按段落,无障碍要朗读结构,信息流要首图,详情要完整正文。每个消费方都得重新解析同一段 HTML。

问题不在某一段解析代码,而在表示本身。HTML 是渲染结果,不是内容事实。把渲染结果当事实存下来,等于让每个客户端反向猜测编辑器的意图。

五类块,而不是一个富文本字符串

块模型的第一个决定是「块」的粒度。我们只保留五类在客户端有独立渲染与独立数据需求的块:

块类型关键字段典型用途主要消费面
paragraphspans正文段落信息流、详情、搜索
imagesrc、alt、可选宽高配图、封面信息流、详情
videosrc、可选 poster、时长视频内容详情
codelanguage、code代码示例详情
quotespans、可选署名引用、金句详情、分享卡片

没有 heading、list、table 这些块,不是因为它们不重要,而是因为第一版产品里它们可以由段落和行内标记表达。块类型越少,客户端要覆盖的渲染分支越少,未知类型的概率也越低。等到确实出现「列表需要独立交互」的需求,再加第六类块,而不是一开始就把编辑器 schema 全量搬过来。

行内结构用的是扁平的 span 数组,而不是嵌套的节点树:每个 span 是一段同质文本,marks 描述 bold、italic、code、link 四类标记。链接不是一种块,而是行内标记,因为它可以出现在段落中间;代码也不是块,行内代码用 code 标记,独立代码段才用 code 块。

扁平结构有一个实际好处:截断和纯文本化不需要遍历树。 摘要只要按顺序拼 span.text,到了字符上限就在 span 边界停下,不会切坏标签。嵌套树当然表达力更强,但也会把「取前 80 字」变成一次需要处理开闭标签的递归。

图片块保留 width 和 height 不是为了让服务端做布局,而是把固有尺寸随内容一起传下去。3.3 会用它预留占位盒;如果这里只存 URL,客户端就必须等图片头部返回才知道比例,首屏必然抖一下。

未知块不是错误,是版本信号

结构化模型必须回答一个逃不掉的问题:客户端遇到不认识的块怎么办?

有三种常见做法:

  1. 整篇拒绝渲染。 安全,但一个新增块会让旧客户端整页空白,不可接受。
  2. 把原始 JSON 当 HTML 塞进页面。 更糟,既显示原始文本,又引入了注入面。
  3. 降级为占位块,并保留可读文本。 这是本节采用的做法。

降级块长这样:

export interface UnsupportedBlock {
  type: "unsupported";
  originalType: string;
  fallbackText: string;
}

originalType 用于埋点和日志,让服务端知道哪个新块在旧客户端上出现得最多;fallbackText 用于展示和搜索,优先取原始块里的 text 字段,截到 140 个字符,没有文本时给一句「不支持的内容块」。

这里有一个容易混淆的区分:未知块和未来版本文档不是一回事。

  • 未知块是「同一版协议里出现了客户端没实现的类型」。它应该降级,因为文档整体仍然可读,其他块还能正常渲染。
  • 未来版本文档是「服务端用了更高的 version」。这时不能猜字段含义,因为连已知块的语义都可能变了。正确反应是拒绝解析,提示升级客户端。

把两者混在一起,会出现两种坏结果:要么对未知块过度反应,整篇不显示;要么对未来版本过度宽容,用旧规则解释新字段,静默丢内容。

序列化也刻意做得保守:只序列化当前版本,版本不匹配直接抛错。这样做不是为了限制服务端,而是为了防止客户端在编辑、缓存、转发时把一个自己不完全理解的文档「洗」成另一个版本。

一个可运行的块模型

下面的文件就是 social-lab 的块模型实现。它属于 L1:全是纯函数,没有时钟、没有网络、没有 DOM。

// file: src/render/blocks.ts
// tier: L1
// verify: node --test test/render/blocks.test.ts
import type { ContentBlock } from "../core/content-model.ts";

export const CURRENT_VERSION = 1;

export type InlineMark =
  | { kind: "bold" }
  | { kind: "italic" }
  | { kind: "code" }
  | { kind: "link"; href: string };

export interface InlineSpan { text: string; marks: InlineMark[] }

export interface ParagraphBlock { type: "paragraph"; spans: InlineSpan[] }

export interface ImageBlock {
  type: "image";
  src: string;
  alt: string;
  width?: number;
  height?: number;
}

export interface VideoBlock {
  type: "video";
  src: string;
  poster?: string;
  durationMs?: number;
}

export interface CodeBlock { type: "code"; language: string; code: string }

export interface QuoteBlock {
  type: "quote";
  spans: InlineSpan[];
  attribution?: string;
}

export interface UnsupportedBlock {
  type: "unsupported";
  originalType: string;
  fallbackText: string;
}

export type RichBlock =
  | ParagraphBlock
  | ImageBlock
  | VideoBlock
  | CodeBlock
  | QuoteBlock
  | UnsupportedBlock;

export interface RichDocument { version: number; blocks: RichBlock[] }

export type ParseOutcome =
  | { ok: true; document: RichDocument; degraded: number; warnings: string[] }
  | { ok: false; reason: "not-json" | "shape" | "future-version"; message: string };

export function span(text: string, marks: InlineMark[] = []): InlineSpan {
  return { text, marks: [...marks] };
}

export function fromLegacyBlocks(blocks: ContentBlock[]): RichDocument {
  const converted: RichBlock[] = [];
  for (const block of blocks) {
    if (block.type === "text") {
      const text = block.text ?? "";
      if (text.trim() !== "") converted.push({ type: "paragraph", spans: [span(text)] });
    } else if (block.type === "image") {
      if (block.src) converted.push({ type: "image", src: block.src, alt: block.alt ?? "" });
    } else if (block.type === "video" && block.src) {
      converted.push({ type: "video", src: block.src });
    }
  }
  return { version: CURRENT_VERSION, blocks: converted };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseSpans(value: unknown): InlineSpan[] | null {
  if (typeof value === "string") return [span(value)];
  if (!Array.isArray(value)) return null;
  const spans: InlineSpan[] = [];
  for (const entry of value) {
    if (!isRecord(entry) || typeof entry.text !== "string") return null;
    const marks: InlineMark[] = [];
    for (const mark of Array.isArray(entry.marks) ? entry.marks : []) {
      if (!isRecord(mark) || typeof mark.kind !== "string") continue;
      if (mark.kind === "bold" || mark.kind === "italic" || mark.kind === "code") {
        marks.push({ kind: mark.kind });
      } else if (mark.kind === "link" && typeof mark.href === "string" && mark.href !== "") {
        marks.push({ kind: "link", href: mark.href });
      }
    }
    spans.push({ text: entry.text, marks });
  }
  return spans;
}

function fallbackText(raw: Record<string, unknown>, originalType: string): string {
  if (typeof raw.text === "string" && raw.text.trim() !== "") return raw.text.trim().slice(0, 140);
  return `[不支持的内容块: ${originalType}]`;
}

function normalizeBlock(raw: unknown): { block: RichBlock; warning: string | null } {
  if (!isRecord(raw)) {
    return {
      block: { type: "unsupported", originalType: "unknown", fallbackText: "[内容块格式错误]" },
      warning: "内容块不是对象,已降级为占位文本",
    };
  }
  const type = typeof raw.type === "string" ? raw.type : "unknown";
  const degraded = (warning: string): { block: RichBlock; warning: string } => ({
    block: { type: "unsupported", originalType: type, fallbackText: fallbackText(raw, type) },
    warning,
  });
  if (type === "paragraph" || type === "text") {
    const spans = parseSpans(raw.spans ?? raw.text);
    return spans ? { block: { type: "paragraph", spans }, warning: null } : degraded(`段落格式错误,已降级: ${type}`);
  }
  if (type === "image") {
    if (typeof raw.src !== "string" || raw.src === "") return degraded("图片缺少 src,已降级为占位文本");
    const block: ImageBlock = { type: "image", src: raw.src, alt: typeof raw.alt === "string" ? raw.alt : "" };
    if (typeof raw.width === "number" && Number.isInteger(raw.width) && raw.width > 0) block.width = raw.width;
    if (typeof raw.height === "number" && Number.isInteger(raw.height) && raw.height > 0) block.height = raw.height;
    return { block, warning: null };
  }
  if (type === "video") {
    if (typeof raw.src !== "string" || raw.src === "") return degraded("视频缺少 src,已降级为占位文本");
    const block: VideoBlock = { type: "video", src: raw.src };
    if (typeof raw.poster === "string" && raw.poster !== "") block.poster = raw.poster;
    if (typeof raw.durationMs === "number" && Number.isFinite(raw.durationMs) && raw.durationMs > 0) {
      block.durationMs = raw.durationMs;
    }
    return { block, warning: null };
  }
  if (type === "code") {
    if (typeof raw.code !== "string") return degraded("代码块缺少 code 字段,已降级为占位文本");
    const language = typeof raw.language === "string" && raw.language !== "" ? raw.language : "text";
    return { block: { type: "code", language, code: raw.code }, warning: null };
  }
  if (type === "quote") {
    const spans = parseSpans(raw.spans ?? raw.text);
    if (!spans) return degraded("引用块格式错误,已降级为占位文本");
    const block: QuoteBlock = { type: "quote", spans };
    if (typeof raw.attribution === "string" && raw.attribution !== "") block.attribution = raw.attribution;
    return { block, warning: null };
  }
  return degraded(`未知内容块类型,已降级: ${type}`);
}

export function parseDocument(raw: string): ParseOutcome {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch (error) {
    return { ok: false, reason: "not-json", message: error instanceof Error ? error.message : "无法解析 JSON" };
  }
  if (!isRecord(value)) return { ok: false, reason: "shape", message: "文档必须是对象" };
  if (typeof value.version !== "number" || !Number.isInteger(value.version) || value.version < 1) {
    return { ok: false, reason: "shape", message: "version 必须是正整数" };
  }
  if (value.version > CURRENT_VERSION) {
    return {
      ok: false,
      reason: "future-version",
      message: `文档版本 ${value.version} 高于客户端支持的 ${CURRENT_VERSION},请升级客户端而不是猜测字段`,
    };
  }
  if (!Array.isArray(value.blocks)) return { ok: false, reason: "shape", message: "blocks 必须是数组" };
  const blocks: RichBlock[] = [];
  const warnings: string[] = [];
  for (const rawBlock of value.blocks) {
    const result = normalizeBlock(rawBlock);
    blocks.push(result.block);
    if (result.warning) warnings.push(result.warning);
  }
  const degraded = blocks.filter((block) => block.type === "unsupported").length;
  return { ok: true, document: { version: value.version, blocks }, degraded, warnings };
}

export function serializeDocument(document: RichDocument): string {
  if (document.version !== CURRENT_VERSION) {
    throw new Error(`只能序列化版本 ${CURRENT_VERSION},收到 ${document.version}`);
  }
  return JSON.stringify({ version: document.version, blocks: document.blocks });
}

export function plainText(block: RichBlock): string {
  if (block.type === "paragraph" || block.type === "quote") return block.spans.map((item) => item.text).join("");
  if (block.type === "code") return block.code;
  if (block.type === "image") return block.alt;
  if (block.type === "unsupported") return block.fallbackText;
  return "";
}

export function documentText(document: RichDocument, separator = "\n"): string {
  return document.blocks
    .map((block) => plainText(block).trim())
    .filter((text) => text !== "")
    .join(separator);
}

旧数据通过适配器进入新模型

core 里的 ContentBlock 只有 text、image、video 三种类型,字段也更少。fromLegacyBlocks 不修改旧数据,而是在读取时做一次转换:文本块变成段落,图片和视频补齐默认值,空文本和缺 src 的媒体直接跳过。

这不是「迁移脚本」,而是读路径适配器。它的好处是上线顺序自由:服务端可以先继续返回旧结构,客户端先学会新模型;等服务端切换后,适配器仍然可以处理缓存里的旧数据。真正的一次性数据迁移可以晚一点做,风险也小得多。

注意文本块保留了首尾空白,没有 trim()。去掉空白是展示层的决定,不是数据层的决定。信息流摘要需要 trim(),代码块前后的空行可能是有意义的,模型不应该替调用方做这个选择。

解析失败要区分原因

parseDocument 返回的不是 null,而是一个带 reason 的联合类型:not-json、shape、future-version。这三种失败在客户端对应完全不同的处理:

  • not-json 通常是缓存损坏或传输截断,可以丢弃缓存重新拉取。
  • shape 是协议违约,应该上报服务端,并展示兜底页。
  • future-version 是客户端太旧,应该提示升级,而不是反复重试。

解析成功时还会返回 degraded 和 warnings,让「旧客户端看到多少新内容」变成可上报的数字。如果只返回布尔值,调用方只能把所有失败当成「内容坏了」,最后要么静默空白,要么无限重试。

测试与真实输出

$ node --test test/render/blocks.test.ts
✔ 旧版 text/image/video 能转换为结构化块并跳过空文本 (0.956875ms)
✔ 行内标记保留顺序,纯文本拼接不丢链接文字 (0.834292ms)
✔ 序列化后重新解析得到等价文档 (0.925083ms)
✔ 未知块降级为 unsupported 并保留可读回退文本 (0.136542ms)
✔ 缺 src 的图片降级,合法尺寸才保留 (0.097042ms)
✔ 版本高于客户端支持时拒绝解析而不是猜字段 (0.1605ms)
✔ 非法 JSON、缺 version、blocks 非数组都返回明确原因 (0.145041ms)
✔ documentText 跳过空块并按分隔符拼接 (0.114292ms)
✔ 序列化拒绝非当前版本文档 (0.4265ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 144.789375

九条用例覆盖的是模型最容易退化的行为:旧结构转换、行内标记、序列化 round-trip、未知块降级、坏图片字段、未来版本拒绝、非法 JSON、纯文本拼接和序列化版本保护。它们不测 HTML 长什么样,因为这一层根本不产出 HTML。

其中一个断言值得注意:序列化再解析后,文档必须与原始对象深度相等。这保证了模型没有在读写之间偷偷丢字段。很多「结构化」方案最后退化成半结构化,就是因为序列化时漏掉了可选字段,而测试只检查了块的数量。

一次把正文当字符串切的失败

「回声」上线两周后,分享卡片开始出现一种奇怪的截图:卡片描述里写着 </p><p>,末尾还有半截 src="https://...。排查后发现,分享服务取的是 item.bodyHtml.slice(0, 80)。

这段代码在纯文本文章上工作正常,遇到带图正文就暴露了。更麻烦的是,修复不能只改 slice 的位置,因为 HTML 没有「第 80 个可读字符」这个概念:标签、属性、实体都不占用户可见宽度,但在字符串里占位置。

当时的临时补丁是先用正则去标签再截断。新问题立刻出现:< 出现在代码块里会被当成标签开头,&amp; 被截成 &am,图片的 alt 文本有时该保留有时该丢弃,正则越写越长。

真正的修复是把表示换掉。正文解析成块文档后,摘要变成三个明确步骤:

  1. 用 documentText 拼出纯文本,跳过空块。
  2. 按字符截断,不切开任何 span。
  3. 在末尾补省略号,并保留首图的 alt 作为分享卡片描述。

这个过程不需要正则,也不需要知道 HTML 长什么样。事故的根因不是某一行代码,而是「把渲染结果当内容事实」这个表示决定。

版本与迁移的实际约束

块模型不是免费午餐,落地时有三条约束必须提前想清楚。

第一,编辑器和服务端要共用同一份 schema。 如果编辑器能产出第六种块,而服务端校验只认五种,内容会在保存时被静默丢掉。我们的做法是把块类型定义放在共享包里,编辑器、服务端、客户端都从同一处导入;服务端只做字段校验,不做「客户端能不能渲染」的判断。

第二,旧客户端的降级文案要产品同学确认。 「不支持的内容块」是工程兜底,不是最终文案。带投票、商品卡、直播预约的正文,在旧客户端上应该显示什么,需要产品决定,而不是让工程师随手写一句占位。

第三,迁移要有双读窗口。 假设已有一批存量内容只存了 HTML,迁移可以分三步:先让读路径支持「结构化优先、失败回退旧 HTML」;再批量转换并抽样比对;最后才停止写入旧字段。双读窗口的长度取决于发布节奏,没有统一答案,但必须存在,否则一次转换错误就没有回退路径。

什么时候不值得结构化

结构化模型适合长正文、多消费面、需要长期演进的内容。以下情况强行上块模型反而增加成本:

  • 评论、私信这类短文本,纯文本加表情就够了,不需要块。
  • 一次性的活动页,生命周期只有几天,HTML 模板更直接。
  • 完全由运营配置、结构固定的页面,用配置 schema 比通用块模型更合适。

判断标准不是「结构化更先进」,而是同一份内容是否会被两个以上渲染面消费,并且未来还可能增加块类型。如果答案是否定的,简单的表示就是正确的表示。

小结

正文应该是一份带版本、可校验、可降级的块文档,而不是一段 HTML 字符串。块负责结构,行内标记负责样式,未知块降级为可读占位,未来版本明确拒绝,序列化保持确定性。这样摘要、搜索、无障碍和多端渲染才能复用同一份事实。

模型确定后,下一个问题自然出现:如果客户端最终还是要输出 HTML,哪些来源可以写 HTML、哪些必须被白名单过滤?下一节讨论渲染边界与 sanitize。

阅读导航:上一节:2.3 刷新策略:下拉、自动、回到前台该不该拉 · 下一节:3.2 渲染边界与 sanitize:谁能写 HTML 。

常见错误与边界

  • 把 HTML 当正文存储,再在客户端用正则反向解析。标签、属性和实体都会让「按字符截断」失效。
  • 为未知块直接渲染原始 HTML。既显示标签原文,又把注入面带进了页面。
  • 把未知块和未来版本混为一谈。前者应降级,后者应拒绝并提示升级。
  • 序列化时漏掉可选字段,导致 round-trip 不再等价。必须用深度相等断言保护。
  • 在数据层 trim() 文本。空白是否有意义应由展示层决定。
  • 让编辑器 schema 与服务端校验各维护一份。新增块会在保存时被静默丢弃。
  • 一次性全量迁移,没有双读窗口。转换错误时没有回退路径。
  • 对短评论、一次性活动页也强上通用块模型。结构化的成本高于收益。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查