先把边界说清楚
这一节只回答一个问题:服务端交给客户端的正文,应该用什么形状表示。 它不讨论 XSS 过滤,那是 3.2 的主题;也不讨论图片占位与懒加载,那是 3.3 的主题;上传与编辑器交互留到第四章。本节结束时,你会得到一个不依赖框架、可以在 Node 里直接跑测试的块文档模型。
卷一讨论过内容面有哪些页面、信息流容器怎么存状态。那些决定回答的是「数据放在哪、活多久」。这一节往下一层:单条内容的正文本身,应该如何被表达。 如果这一步做错,后面所有渲染优化都只能在一个错误的表示上打补丁。
「回声」的第一版把正文存成一段 HTML 字符串。理由很朴素:编辑器输出 HTML,WebView 直接渲染,服务端不用理解内容结构。这个决定在只有一种客户端、只需要展示时看起来成立,但很快遇到三类问题:
- 摘要不可控。 推送和分享卡片需要前 80 个字符,直接
slice会把<img src="...切成半截,用户看到裸露的标签碎片。 - 未知标签裸奔。 服务端上线了新的
<poll>组件,旧版客户端不认识,页面上直接出现<poll data-id="7">这样的原文。 - 多面复用的成本极高。 搜索要纯文本,翻译要按段落,无障碍要朗读结构,信息流要首图,详情要完整正文。每个消费方都得重新解析同一段 HTML。
问题不在某一段解析代码,而在表示本身。HTML 是渲染结果,不是内容事实。把渲染结果当事实存下来,等于让每个客户端反向猜测编辑器的意图。
五类块,而不是一个富文本字符串
块模型的第一个决定是「块」的粒度。我们只保留五类在客户端有独立渲染与独立数据需求的块:
| 块类型 | 关键字段 | 典型用途 | 主要消费面 |
|---|---|---|---|
paragraph | spans | 正文段落 | 信息流、详情、搜索 |
image | src、alt、可选宽高 | 配图、封面 | 信息流、详情 |
video | src、可选 poster、时长 | 视频内容 | 详情 |
code | language、code | 代码示例 | 详情 |
quote | spans、可选署名 | 引用、金句 | 详情、分享卡片 |
没有 heading、list、table 这些块,不是因为它们不重要,而是因为第一版产品里它们可以由段落和行内标记表达。块类型越少,客户端要覆盖的渲染分支越少,未知类型的概率也越低。等到确实出现「列表需要独立交互」的需求,再加第六类块,而不是一开始就把编辑器 schema 全量搬过来。
行内结构用的是扁平的 span 数组,而不是嵌套的节点树:每个 span 是一段同质文本,marks 描述 bold、italic、code、link 四类标记。链接不是一种块,而是行内标记,因为它可以出现在段落中间;代码也不是块,行内代码用 code 标记,独立代码段才用 code 块。
扁平结构有一个实际好处:截断和纯文本化不需要遍历树。 摘要只要按顺序拼 span.text,到了字符上限就在 span 边界停下,不会切坏标签。嵌套树当然表达力更强,但也会把「取前 80 字」变成一次需要处理开闭标签的递归。
图片块保留 width 和 height 不是为了让服务端做布局,而是把固有尺寸随内容一起传下去。3.3 会用它预留占位盒;如果这里只存 URL,客户端就必须等图片头部返回才知道比例,首屏必然抖一下。
未知块不是错误,是版本信号
结构化模型必须回答一个逃不掉的问题:客户端遇到不认识的块怎么办?
有三种常见做法:
- 整篇拒绝渲染。 安全,但一个新增块会让旧客户端整页空白,不可接受。
- 把原始 JSON 当 HTML 塞进页面。 更糟,既显示原始文本,又引入了注入面。
- 降级为占位块,并保留可读文本。 这是本节采用的做法。
降级块长这样:
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 个可读字符」这个概念:标签、属性、实体都不占用户可见宽度,但在字符串里占位置。
当时的临时补丁是先用正则去标签再截断。新问题立刻出现:< 出现在代码块里会被当成标签开头,& 被截成 &am,图片的 alt 文本有时该保留有时该丢弃,正则越写越长。
真正的修复是把表示换掉。正文解析成块文档后,摘要变成三个明确步骤:
- 用
documentText拼出纯文本,跳过空块。 - 按字符截断,不切开任何 span。
- 在末尾补省略号,并保留首图的
alt作为分享卡片描述。
这个过程不需要正则,也不需要知道 HTML 长什么样。事故的根因不是某一行代码,而是「把渲染结果当内容事实」这个表示决定。
版本与迁移的实际约束
块模型不是免费午餐,落地时有三条约束必须提前想清楚。
第一,编辑器和服务端要共用同一份 schema。 如果编辑器能产出第六种块,而服务端校验只认五种,内容会在保存时被静默丢掉。我们的做法是把块类型定义放在共享包里,编辑器、服务端、客户端都从同一处导入;服务端只做字段校验,不做「客户端能不能渲染」的判断。
第二,旧客户端的降级文案要产品同学确认。 「不支持的内容块」是工程兜底,不是最终文案。带投票、商品卡、直播预约的正文,在旧客户端上应该显示什么,需要产品决定,而不是让工程师随手写一句占位。
第三,迁移要有双读窗口。 假设已有一批存量内容只存了 HTML,迁移可以分三步:先让读路径支持「结构化优先、失败回退旧 HTML」;再批量转换并抽样比对;最后才停止写入旧字段。双读窗口的长度取决于发布节奏,没有统一答案,但必须存在,否则一次转换错误就没有回退路径。
什么时候不值得结构化
结构化模型适合长正文、多消费面、需要长期演进的内容。以下情况强行上块模型反而增加成本:
- 评论、私信这类短文本,纯文本加表情就够了,不需要块。
- 一次性的活动页,生命周期只有几天,HTML 模板更直接。
- 完全由运营配置、结构固定的页面,用配置 schema 比通用块模型更合适。
判断标准不是「结构化更先进」,而是同一份内容是否会被两个以上渲染面消费,并且未来还可能增加块类型。如果答案是否定的,简单的表示就是正确的表示。
小结
正文应该是一份带版本、可校验、可降级的块文档,而不是一段 HTML 字符串。块负责结构,行内标记负责样式,未知块降级为可读占位,未来版本明确拒绝,序列化保持确定性。这样摘要、搜索、无障碍和多端渲染才能复用同一份事实。
模型确定后,下一个问题自然出现:如果客户端最终还是要输出 HTML,哪些来源可以写 HTML、哪些必须被白名单过滤?下一节讨论渲染边界与 sanitize。
阅读导航:上一节:2.3 刷新策略:下拉、自动、回到前台该不该拉 · 下一节:3.2 渲染边界与 sanitize:谁能写 HTML 。
常见错误与边界
- 把 HTML 当正文存储,再在客户端用正则反向解析。标签、属性和实体都会让「按字符截断」失效。
- 为未知块直接渲染原始 HTML。既显示标签原文,又把注入面带进了页面。
- 把未知块和未来版本混为一谈。前者应降级,后者应拒绝并提示升级。
- 序列化时漏掉可选字段,导致 round-trip 不再等价。必须用深度相等断言保护。
- 在数据层
trim()文本。空白是否有意义应由展示层决定。 - 让编辑器 schema 与服务端校验各维护一份。新增块会在保存时被静默丢弃。
- 一次性全量迁移,没有双读窗口。转换错误时没有回退路径。
- 对短评论、一次性活动页也强上通用块模型。结构化的成本高于收益。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。