先看标题里一个 & 造成的静默失败
用字符串模板拼 XML 是最省事的写法,也是最容易在某一天突然坏掉的写法。假设当天的文章标题是「Q&A:一次真实的对齐问题」,拼出来的结果是:
<item>
<title>Q&A:一次真实的对齐问题</title>
<link>https://www.chengyunlai.top/zh/blog/some-post</link>
</item>在 XML 里 & 是实体引用的起始字符,&A 不是合法实体,于是整份文档不再良构(well-formed)。XML 1.0 规范对良构性违反的后果说得很直接:这属于 fatal error,处理器必须检测并向应用报告;一旦检测到,就不得继续正常处理(XML 1.0 第五版,§1.2、§2.1,访问于 2026-09-28)。
后果不是「这一条坏了」,而是「整份源坏了」:
查看 Mermaid 源码
flowchart LR
a["标题里的一个未转义字符"] --> b["生成的 XML 不再良构"]
b --> c["解析器报 fatal error"]
c --> d["整份 feed 读不出来<br/>item 数量为 0"]
classDef cause fill:#fff7ed,stroke:#ea580c,color:#0f172a
classDef effect fill:#eff6ff,stroke:#2563eb,color:#0f172a
class a,b cause
class c,d effect错误只发生在第 2 行,坏掉的是整份文档。阅读器通常只会显示一句「无法读取此源」,没有任何一行指向那个 &——这就是它被称为静默失败的原因。
一个够用的转义函数
要修的是「往外写的时候有没有做转义」,所以最小构件是一个转义函数:
const XML_ENTITIES = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
function escapeXml(value) {
return String(value).replace(/[&<>"']/g, (char) => XML_ENTITIES[char]);
}三个补充说明:
- 一次扫描胜过五次替换。 如果写成五次链式
replace,顺序必须让&排在其它字符之前,否则先转出的<会被再转一次,变成&lt;。用一条正则一次扫描,这个顺序问题自然消失。 - 哪些是硬要求。
&与<在文本内容里必须转义;>只在构成]]>时必须(XML 1.0 §2.4)。"与'只对属性值有要求,但属性值就在这份文件里——enclosure url="..."就是——所以一并转掉更省心。 - 不要对已经是实体的内容再转一次。 RSS 2.0 规范允许
description里放 entity-encoded HTML,也就是说&这种文本可能本来就该原样出现。把「带实体的 HTML」再送进escapeXml,结果是&amp;,读者看到的是一串字面量。我的判断是:先把输入约定成纯文本,所有字段一律走转义;确实需要放 HTML 时,用另一个明确命名的函数,而不是让所有字段共用同一条路径。
CDATA 的边界
XML 还提供了另一条路。CDATA 段以 <![CDATA[ 开始、以 ]]> 结束(XML 1.0 §2.7):段内只有 ]]> 会被当作标记,< 与 & 可以字面出现,并且不能用实体转义,CDATA 段也不能嵌套。
<description><![CDATA[<p>带 <b>标签</b> 的摘要,& 符号照写</p>]]></description>本站就用这条路径写摘要(见 src/app/api/rss/route.ts 里写 <description> 的那一处)。
这样只剩一个问题:内容里真的出现了 ]]>。段内不能转义,也不能嵌套,唯一的办法是在这一处断开,拼成两个相邻的 CDATA 段:
// 中间的 "]]>" 处断开,得到 <![CDATA[a]]]]><![CDATA[>b]]>
const safe = value.replace(/]]>/g, "]]]]><![CDATA[>");有一个细节值得记住:RSS 2.0 规范全文没有出现 CDATA,它只说 description 允许 entity-encoded HTML(RSS 2.0 规范,访问于 2026-09-28)。CDATA 属于 XML 层面的表达能力,用它并不意味着规范给了额外的许可——该不该在摘要里放 HTML 仍然是内容策略问题。
enclosure:同样是属性值,坏得同样安静
enclosure 是 item 的子元素,用来挂一个附件。规范对它的措辞是「it has three required attributes」:url 说明位置、length 说明它有多少字节、type 说明它的 MIME 类型,并且 url 必须是一个 http url。
原始用途是播客音频,本站用它挂文章配图(见 src/app/api/rss/route.ts 里写 <enclosure> 的那一处):
<enclosure url="https://oss.chengyunlai.top/..." type="image/jpeg" />length 是规范列的三个必需属性之一,所以它不是「能给就给」的加分项 —— 三个属性要么齐全,要么这个元素整个不出现。真正的约束在别处:你得真的知道那个文件的字节数。本站只在能读到真实大小时才写出 enclosure —— 本地 public/ 下的图读文件大小;外链(OSS / CDN)读不到,就整条省略。
留一个只有 url 和 type 的残件比省略更危险:残缺的 enclosure 在严格阅读器里是「不合规的附件」,而不写它只是「这条没有附件」—— 后者是合法状态。省略是信息少一点,残件是签了个不能兑现的承诺。
还有一个只在实现里才看得见的顺序问题:判断「能不能拿到字节数」必须在把图片地址转成绝对地址之前做。读文件的函数只认以 / 开头的站点相对路径;转换之后它拿到的是 https://…,会一律返回「读不到」。结果是连本地图也永远拿不到长度,而且不报任何错 —— 每一条 enclosure 都静静地退化成那个只有两个属性的残件,本地开发时看起来一切正常。
enclosure 值得在这里单独说一句,是因为它的 url 是属性值。属性里的 & 同样会被解析,而属性值如果被拼接了两段地址,得到的会是一个语法合法、语义完全错误的 URL:XML 校验不会报错,只有真的去打开那个地址时才发现是 404。第四章的第二个坑正是这样来的。
构建时生成,还是运行时生成
同样的 XML,可以有两种产生时机。它们不是性能差异,而是失效模式不同:
| 维度 | 构建时静态生成 | 运行时动态生成 |
|---|---|---|
| 内容来源 | 构建时能读到的文件 | 运行时能读到的文件、数据库或上游 API |
| 内容更新后 | 需要重新构建并部署 | 下一次请求即可生效 |
| 单次请求成本 | 接近零,直接返回静态字节 | 读取、拼装,可能还有查询 |
| 缓存与校验器 | 生成端只能把校验器给出去(ETag / Last-Modified);能不能落成 304 由部署层决定,见第二章实测 | 要自己写 ETag 与 Cache-Control,否则每次都是 200 |
| 典型失效模式 | 构建产物是旧的 | 运行时依赖缺失——容器里没有那个目录、数据库连不上 |
| 适合 | 内容来自版本库的静态博客 | 内容库频繁更新,或要聚合多个来源 |
本站选了构建时,理由不止性能:生产镜像里根本没有内容目录,运行时读不到文章(第四章详述)。这里要带走的是那张表最后两行的区别——静态生成坏在「产物是旧的」,动态生成坏在「运行时缺东西」,而后者往往在本地 next start 时完全正常。
让阅读器和爬虫找到这份文件
三件收尾的事。
自动发现。 在 HTML 的 head 里放一个 link 元素,浏览器和插件就能自己找到订阅地址(RSS Autodiscovery,RSS Advisory Board v1.0,2006-11-27,访问于 2026-09-28):
<link rel="alternate" type="application/rss+xml" title="RSS" href="https://www.chengyunlai.top/api/rss">规范里的硬约束:必须放在 head 内;rel 必须是 alternate(小写,且不能掺其它关键字);RSS 1.0 与 2.0 的 type 必须是 application/rss+xml(小写);href 建议写完整 URL;一个页面只放一个自动发现链接,多放时第一个应当是站点主源。
响应类型。 内容本身要有一个 XML 类型。本站给的是 Content-Type: application/rss+xml; charset=utf-8(见 route.ts 的响应头)—— 规范推荐的具体类型;通用类型 application/xml 也能用,主流阅读器看的是 XML 结构,但既然能说具体就别含糊。真正必须严格写成 application/rss+xml 的是上面那个 link 元素的 type:那是阅读器(和浏览器)用来识别自动发现的依据,写错就直接不认。
验证用真解析器。 不要用正则判断「像不像 XML」,也不要用自己写的解析器去验收自己写的东西。命令行上最省事的是 xmllint(libxml2 自带):
xmllint --noout .next/server/app/api/rss.body它只回答一个问题:这份文档良构吗。良构之外的业务事实(条目数量、链接前缀、地址有没有被拼坏)要另外断言,那是下一章的做法。
这两句「不要」都不是空话。配套实验在仓库的 examples/rss-subscription/,两个公开入口的分工是固定的:生成走 src/lib/rss.ts(线上那份代码),验收走系统里的成熟解析器(Python 标准库的 expat,以及装了就跑的 xmllint)。实验自己另写一份生成器、或者用自己的解析器当判据,都只能是自证 —— 前者证明的是「实验作者会写转义」,后者证明的是「我同意我」。
node examples/rss-subscription/user_code/main.mjs它跑出一张三种判据并排的表,结果值得原样抄在这里:一条只找漏转义 & 的正则,在 5 份非法文档上漏报 4 份(�、原始 U+0001、裸露的 ]]>、标签不匹配全都放过),又在 3 份合法文档上误报 2 份(它认不出 CDATA 里的字面 & 是合法的)。两个方向同时错,这才是「正则判 XML」的完整成绩单:它既不安全,也不准确,只是恰好对最常见的那一种错误有效。
参考资料
- XML 1.0(第五版):良构性与 fatal error §2.1、预定义实体与
]]>§2.4、CDATA 段 §2.7(官方规范,访问于 2026-09-28) - RSS 2.0 Specification:
enclosure的三个必需属性、guid与description的定义(官方规范,访问于 2026-09-28) - RSS Autodiscovery:link 元素与
rel/type/href的约束(RSS Advisory Board,访问于 2026-09-28)