polyxslt:给浏览器补上一个轻量级 XSLT 处理器

• 本文约 3613 字,阅读大致需要 8 分钟 • Development

我给本站的RSS和Atom feed加了一个纯JavaScript实现的XSLT处理器,为浏览器移除内建XSLT支持做准备。

本站在2021年2月5日为RSS feed和sitemap添加了XSLT样式,后来重新设计本站主题 chaos-theme 时也沿用了这一做法。

这些XML文档便于程序读取,但直接给人看就不太直观;XSLT可以把它们转换成HTML,让同一份内容同时服务于程序和人类读者。

最近偶然和小伙伴聊到了这个话题:

来自X (Twitter)

当然,这些XSLT样式表是多年前写的。不过,浏览器端XSLT的使用率很低,Chromium和WebKit所依赖的libxslt又是一个人手不足、安全问题频出的老旧C代码库,因此Chrome已公布弃用和移除XSLT的计划:从Chrome 158(2026年11月17日)起,多数用户的XSLT将停止工作,Chrome 176则会彻底移除。Gecko和WebKit也表态支持移除。各家进度未必相同,但不能再把浏览器内建XSLT当作长期可用的基础设施。

目前可行的一个替代方案是给 XSLTProcessor 提供一个polyfill,其中一种实现是Mason Freed的 xslt_polyfill。它将libxslt和libxml2编译成WebAssembly,功能覆盖较完整,不过引入的体积也相对偏大(未压缩约1.4 MB)。考虑到本站实际用到的XSLT特性其实非常有限,从头实现一个轻量级的XSLT处理器、仅覆盖常用功能子集来提供一个更小的polyfill,会是更合适的做法。

于是我花时间vibe了一份提供 XSLTProcessor 接口子集的简化实现:polyxslt。

polyxslt是什么

polyxslt是一个纯JavaScript实现的轻量级XSLT 1.0处理器,用于在未来不再提供内建XSLT的现代浏览器中补齐这一功能。它的核心目标是支持展示RSS、Atom订阅和sitemap 所需的常用XSLT功能子集。不过,本站的sitemap采用了构建时渲染,原因后面再说。

目前1.0版本支持的指令包括:xsl:template、xsl:apply-templates、xsl:for-each、xsl:sort、xsl:choose / xsl:if、xsl:value-of、xsl:copy-of、xsl:element / xsl:attribute、xsl:variable,以及XPath 1.0的大部分核心函数。

它目前不支持 xsl:import / xsl:include、xsl:call-template / xsl:param、xsl:key、xsl:number 以及 mode 属性。如果遇到了不支持的指令,polyxslt会明确报错中止,而不是静默忽略。完整的指令支持列表见仓库中的 docs/SUPPORTED.md。

体积方面,这个实现只使用了现代浏览器广泛支持的ES2022特性,运行时无外部依赖。代码经Brotli压缩后仅约10 KB。

用法

与xslt_polyfill类似,保留XML文档开头的 <?xml-stylesheet?> 处理指令,在根元素内部加一个XHTML命名空间的 script 元素,类似这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xsl"?>
<rss version="2.0">
  <script xmlns="http://www.w3.org/1999/xhtml"
    src="/js/xslt-polyfill.min.js"></script>
  <channel>
    <title>示例订阅</title>
    <link>https://example.com/</link>
    <description>示例站点的最新文章</description>
  </channel>
</rss>

其中 /js/xslt-polyfill.min.js 是部署后的构建产物路径,/feed.xsl 是已有的样式表。样式表应只输出展示内容,避免把加载polyfill的脚本复制到结果中。

当带有原生XSLT支持的浏览器加载文档时,解析器在遇到开头的 <?xml-stylesheet?> 处理指令后,便会将文档交给内置的XSLT引擎处理,XML的子元素不会进入活跃的DOM渲染树,因此浏览器不会去下载或执行这个 <script> 脚本。而当浏览器关闭或彻底移除了XSLT支持后,它会将XML当作普通文档解析,进而触发执行这个XHTML <script>:脚本会从同源位置拉取样式表,调用polyxslt完成变换,并用渲染出的结果直接替换掉XML文档的内容。

为什么没有为sitemap.xml添加polyfill

本站的RSS和Atom feed,以及 sitemap.xml 都关联了XSLT样式表,但只有RSS和Atom feed添加了polyxslt。

Atom规范(RFC 4287)第6节 允许在未明确禁止的位置使用其他命名空间的扩展元素。对于 atom:feed 下不认识的外部元素,阅读器可以跳过,且不得因为它的存在而改变处理行为。RSS 2.0规范 也允许加入属于其他命名空间的元素和属性。因此,把这个脚本作为feed根元素的子元素,可以利用两种格式已有的扩展机制。

Sitemap其实也允许命名空间扩展。官方的 sitemap.xsd 在 <urlset> 的 <url> 元素之前,以及 <url> 的标准字段之后,都提供了<xsd:any namespace="##other" processContents="strict"> 扩展槽位。不过,strict 要求验证器能够找到扩展元素的模式声明并完成校验。如果只加载官方Sitemap模式,额外加入的XHTML <script> 就不能通过严格校验;这与XML本身是否格式良好是两回事,也不能据此断言所有搜索引擎都会拒绝它。

考虑到sitemap主要是给搜索引擎使用的,我选择保持它的内容不变,避免为浏览器展示引入额外的扩展校验和客户端兼容性问题。这不是polyxslt无法转换sitemap,而是部署方式上的取舍。

为了兼顾好奇的人类访客,主题在构建后的处理阶段用 xsltproc 预先将sitemap渲染成一份伴生HTML(sitemap.xml.html),再由Web服务器根据请求头选择响应。本站仓库中的nginx配置在收到 Sec-Fetch-Dest: document、且 User-Agent 未命中爬虫规则时优先返回这份静态HTML;其他情况仍返回原始XML。配置同时设置 Vary: Sec-Fetch-Dest, User-Agent,让缓存区分两种响应。当然,这些请求头只用于选择展示形式,并不能可靠地证明访问者是人还是爬虫。

其他设计考量

以libxslt的行为为准

有些地方规范允许实现自行选择,还有些地方libxslt本身就和规范不一致。遇到这两种情况时,polyxslt大体沿用了 xsltproc 的实际行为。这样做的理由是,我手头的XSLT样式表当年都是对着libxslt调试出来的,保持行为一致能最大程度避免非预期的渲染偏差。例如:

  • XPath 1.0规范原本不支持形如 1e3 的科学计数法写法,但libxslt能够接受,因此polyxslt也予以支持;
  • 数字转换成字符串时,遵循libxslt的规则来决定采用定点表示还是科学计数法形式;
  • xsl:sort 在未指定 lang 时不使用语言相关的排序规则。不过,libxslt按UTF-8字节顺序排序,polyxslt则按JavaScript字符串的UTF-16码元顺序排序;涉及补充平面字符与U+E000–U+FFFF的比较时,两者可能不同。

在用 xsltproc 生成HTML对照结果时,libxslt先把结果序列化成文本,测试再把这段文本作为HTML解析;polyxslt则是在JavaScript中直接构造DOM树。为了让最终得到的DOM与前一种做法保持一致,它模拟了这一过程中带来的一系列副作用,例如将HTML元素放入XHTML命名空间、为 <table> 下直接出现的行补上 <tbody>、对 href 中的非ASCII字符做百分号转义等。不过,它没有复现HTML解析器的所有纠错行为,也不会补上libxslt输出时可能产生的缩进空白。这些兼容处理和仍然存在的差异都记录在了 docs/DIVERGENCES.md 中。

在XPath解析上,polyxslt采用了一套独立的解析与求值实现,而没有直接使用浏览器的 document.evaluate。主要原因在于 document.evaluate 无法绑定变量、无法指定上下文位置和大小(context position / size),也不能用来匹配模板模式(Pattern Matching),而且各个引擎在边界情况上的求值行为也不完全一致。现在 document.evaluate 仅在测试中作为对照基准使用。

测试

XSLT和XPath对照用例的基准输出由Homebrew的 xsltproc 生成。其中,DOM输出比较先按浏览器的方式解析 xsltproc 的输出,再把双方结果规范化后比较。生成基准时设置了 indent="no",避免把序列化时额外添加的缩进混入比较。所有测试都在Chromium、Firefox和Playwright提供的WebKit三种浏览器引擎中执行,另外还有模糊测试(Fuzzing)和端到端测试,项目要求分支覆盖率保持在90% 以上。本站实际使用的几份样式表,也在本地用同样的方法和 xsltproc 的结果对比过,在上述规范化比较下结果一致。

实现过程没有参考其他第三方实现,只依据W3C规范和 xsltproc 的实际行为。项目采用MIT许可证开源。

关于安全

polyxslt在自动替换XML页面内容的模式下,默认会执行转换结果里的脚本,以兼容原生XSLT的展示行为。直接调用 transform 或 XSLTProcessor 时,返回的结果节点在调用方插入文档之前不会执行其中的脚本。另有一个默认关闭的严格模式,可以通过脚本上的 data-strict 属性,或 transform 的 { strict: true } 选项启用。打开后会从结果里去掉脚本、框架、嵌入对象、事件处理属性和 javascript: URL等内容。不过,这些做法的主要目的并非作为通用的HTML净化器:如果要处理不可信的输入,还是应该配合Content Security Policy(CSP)使用,不能把严格模式当作完整的安全边界。具体限制见项目的 SECURITY.md。

局限

polyxslt主要适用于结构清晰、用途明确的展示型样式表。如果样式表中用到了 xsl:import、具名模板调用(xsl:call-template)或是 xsl:key,目前还是建议使用功能更为完整的xslt_polyfill。此外,它只面向现代浏览器,构建目标是ES2022,不做降级转译。对于仍提供原生XSLT的旧浏览器,本站继续沿用原有的渲染路径。