← 返回观点

为什么只修微信还不够

结合 OmniPost 的真实事故与源码,解释为什么微信公众号空列表项不能只在 weixin 适配器里打补丁,而应把结构空白修复上移到 markdownToHtml 共享出口,再保留平台级兜底。

先说结论:如果同一份 Markdown 会流向多个 HTML 平台,那么“结构空白修复”只补在微信公众号适配器里并不够,最终还是要上移到 markdownToHtml 这种共享出口。 微信只是第一个把问题暴露得最明显的平台,不代表问题只属于微信。

这正是 OmniPost 最近一轮修复里最值得复盘的工程决策:先在 weixin.js 里止血,再把通用逻辑上移到 src/lib/markdown.js,最后保留平台级兜底,专门覆盖绕过共享渲染链的特殊输入。对 OmniGoAI 的 OmniPost 这种多平台分发工具来说,这比“把一个 bug 修掉”更重要,因为它决定了后续新平台接进来时,你是在复用一套共享防护,还是继续复制补丁。

如果你只想记一句话,可以记这个:共享产物的结构噪声,应该优先在共享出口清理;平台适配器保留兜底,但不应该承担全部修复责任。

这次争论的核心,不是“微信要不要修”,而是“修在哪里”

如果只看事故表面,答案似乎很简单:微信新编辑器会把列表项之间的空白解析成空列表项,那就在微信适配器里压掉空白就行。

但只要把问题往上追一层,就会发现真正的争议点根本不是“要不要修”,而是:

  1. 这个缺陷来自微信专属字段,还是来自共享 HTML 中间产物?
  2. 如果共享产物本身带结构噪声,是否应该在共享出口统一净化?
  3. 上移之后,平台适配器还要不要留本地兜底?

OmniPost 这轮代码给出的答案很清楚。

src/adapters/platforms/weixin.js 里,微信适配器并不是自己维护一套完全独立的清洗逻辑,而是直接从共享渲染库导入:

import { markdownToHtml, collapseStructuralWhitespace } from '../../lib/markdown.js';

而在正文处理路径里,微信也是先走:

let content = article.html || (await markdownToHtml(article.markdown));

这两行其实已经说明了大半个结论:微信最终消费的并不是“微信专属 Markdown”,而是共享 markdownToHtml 产出的 HTML。 既然问题来自共享产物,那么只在 weixin.js 末端补丁,天然就不是最优解。

微信为什么会先把这个问题暴露出来

因为它是目前最敏感的导入型编辑器之一。

OmniPost 的更新说明已经把事故写得非常具体:微信公众号新版编辑器会把列表项之间的空白解析成空列表项,出现“4 项列表显示成 9 项、奇数编号全空”的现象,后来连表格结构也一并做了同步防护。

也就是说,微信的问题不是“看起来丑一点”,而是已经进入“编辑器把结构噪声误当内容节点”的级别。此前那篇文章《公众号草稿列表出现空项?微信新编辑器把空白解析成了列表项》讲的是事故本身:为什么官网预览正常、别的平台正常,偏偏公众号后台会炸。

但如果只停在“微信特殊,所以微信单独修”,就会漏掉一个更重要的事实:微信暴露的是共享 HTML 结构卫生问题,不只是微信自己的显示问题。

为什么“只修微信”从工程上不够

因为其它 HTML 平台走的是同一条渲染链。

OmniPost 的 src/lib/markdown.js 里,markdownToHtml(md) 已经明确成为共享出口:

export async function markdownToHtml(md) {
  if (!md) return '';
  const fn = await loadMarked();
  if (fn) {
    try {
      return collapseStructuralWhitespace(fn(md));
    } catch {
      /* fall through to builtin */
    }
  }
  return collapseStructuralWhitespace(fallbackMarkdown(md));
}

这里最关键的不是“调用了一个函数”,而是:无论走 marked 还是 fallback,最终都统一经过 collapseStructuralWhitespace() 这说明修复已经从“微信补丁”升级成“共享产物净化”。

源码注释把理由说得更直白:

  1. marked 的 pretty-print 输出会在 <li> 之间、<ol>/<ul> 首尾带换行;
  2. 按 HTML 规范这些空白应被忽略;
  3. 但微信新版编辑器(ProseMirror)会把它们解析成空列表项;
  4. 知乎、头条等 HTML 平台的编辑器行为也不完全可预测;
  5. 与其逐个平台赌行为,不如在源头统一压掉。

这就是为什么“只修微信”不够:

  1. 它只能保护微信这一路;
  2. 不能保证知乎、搜狐、头条、Ghost、WordPress 之类共享 HTML 输入的平台永远没事;
  3. 每新增一个 HTML 平台,你都得重新回忆“这个历史坑补在哪儿”;
  4. 最后维护会退化成“哪个平台先爆雷,就在哪个平台补一刀”。

这种维护方式短期看很快,长期看最贵。

为什么共享出口修复更像“正确的第一落点”

因为它同时满足“覆盖广”和“语义稳”两个条件。

先看覆盖面。只要平台适配器是通过 markdownToHtml 拿 HTML,修复就自动继承过去,不需要每个平台各写一遍。这样一来,公众号空列表事故暴露出来之后,受益的不只是公众号链路,而是整条 HTML 分发链。

再看语义边界。collapseStructuralWhitespace(html) 压掉的不是“所有空白”,而是结构标签边界上的空白。源码里主要处理三类位置:

  1. <ul><ol><table><thead><tbody><tr> 开标签后的空白;
  2. 上述容器闭标签前的空白;
  3. </li></tr></td></th> 等闭标签后、下一个标签开始前的空白。

换句话说,这不是 HTML 压缩器,也不是激进的 minify,而是一层很克制的结构空白净化。对守规范的消费方来说,这些 whitespace-only 节点本来就应被忽略;把它们去掉通常不会改变正确语义,却能显著降低“被编辑器误识别成内容节点”的风险。

这也是为什么那篇《为什么要在 markdownToHtml 出口统一压掉标签间空白》会强调:这不是“把微信 bug 补丁挪位置”,而是把共享 HTML 先变干净,再交给不同平台去消费。

那为什么微信适配器里还要保留一道兜底

因为仍然存在绕过共享出口的输入路径。

这正是很多团队最容易走向另一个极端的地方:一看到“逻辑已经上移”,就把平台侧的保护全部删掉,仿佛架构更干净了。

但 OmniPost 没这么做。weixin.js 仍然导入 collapseStructuralWhitespace,本质上是在告诉你:共享层修的是默认路径,平台层兜的是分叉路径。

为什么还需要这层兜底?因为微信支持 article.html 这种直接输入 HTML 的路径。只要用户不是从 article.markdown -> markdownToHtml 这条共享链过来,而是直接给了一段 HTML,就有可能绕开共享出口的那次净化。

所以更稳的分工应该是:

  1. 默认 Markdown 路径:在 markdownToHtml 共享出口统一清理;
  2. 特殊 HTML 直供路径:在平台适配器里再过一道同样的结构空白净化;
  3. 两边复用同一个共享函数,避免逻辑漂移。

这才是真正成熟的分层,而不是“全部上移”或“全部下沉”的二选一。

这次修复为什么特别适合拿来讲多平台架构

因为它几乎是“共享层 vs 平台层边界”最典型的例子。

多平台分发系统最容易积累的一类技术债,就是同一类问题在不同平台里各修各的。一开始看起来只是几个小补丁,后来会逐渐变成:

  1. 同一个 bug 在微信、知乎、头条、搜狐各有一版修法;
  2. 某个平台改了,别的平台忘了同步;
  3. preview 和 publish 看到的行为开始不一致;
  4. 新同事根本搞不清“这个坑到底该在共享层查,还是在某个平台里查”。

而这次 OmniPost 的处理顺序,其实给出了一个可复用的工程规则:

如果多个下游共享同一份中间产物,而问题来自这份中间产物的结构噪声,就先把它在共享出口清干净;如果存在绕过共享出口的特殊输入,再在适配器里保留兜底。

这条规则很朴素,但能帮你少掉很多“修一个平台、漏三个平台”的重复劳动。

preview、平台适配器和共享出口三者该怎么分工

这也是容易混淆的地方。

共享出口负责什么?

负责中间 HTML 的结构卫生。它处理的是“这份 HTML 是否带着下游编辑器可能误解的结构噪声”。

平台适配器负责什么?

负责平台专属边界,包括:

  1. 账号态与接口约束;
  2. 平台专有字段;
  3. 平台特有的 HTML/内容限制;
  4. 绕过共享层的特殊输入兜底。

preview 负责什么?

负责你看到的效果是否接近最终提交内容。它解决的是“渲染看起来对不对”,不是“平台编辑器会不会重新解释这份 HTML 的结构”。

所以这三者并不是互相替代关系,而是不同层次的验收:

  1. 共享出口保证原料更干净;
  2. 平台适配器保证进入平台前的边界更稳;
  3. preview 保证人眼看到的效果没有明显偏差。

少了任何一层,都可能留下新的盲区。

一个更实用的判断标准:什么时候该上移,什么时候该留在平台层

可以用下面这个标准快速判断。

适合上移到共享层的情况

  1. 问题来自共享中间产物,而不是平台私有字段;
  2. 修复对守规范消费方语义无损;
  3. 多个平台正在重复消费这份产物;
  4. 新平台未来也大概率会走同一条链路。

结构空白净化正好四条都满足。

适合留在平台层的情况

  1. 问题只发生在某个平台专有 API 或专有编辑器字段;
  2. 修复需要平台上下文才能安全执行;
  3. 输入路径可能绕过共享层;
  4. 平台行为和其它下游没有复用价值。

微信对 article.html 直供的兜底,就属于这种情况。

这件事对“写一次、发多平台”意味着什么

它意味着多平台系统的稳定性,很多时候取决于你有没有把共享层修好,而不是平台层 patch 得够不够勤快。

如果你的系统每天都要把同一份内容发到知乎、CSDN、掘金、博客园、公众号,真正划算的做法不是等每个平台各自爆雷,而是尽量让共享中间产物先变得更干净、更可预测。平台适配器当然仍然重要,但它更适合承接平台特有约束,而不是一直替共享层擦屁股。

这也是为什么这篇文章不是在重复“微信列表空项事故”,也不是重复“为什么 markdownToHtml 出口修复更优”,而是在回答一个更常见的工程决策问题:当单个平台先暴露共享问题时,你到底应该停留在平台补丁,还是继续把修复上移? OmniPost 这次给出的答案是:先止血,再上移,再保留兜底。

常见问题

为什么不能只在 weixin.js 里修掉这个问题?

因为微信消费的 HTML 本身来自共享 markdownToHtml 路径。既然问题来自共享中间产物的结构噪声,只在微信里修只能覆盖一个下游,不能让其它 HTML 平台自动获得同样的防护。

上移到 markdownToHtml 之后,是否说明所有平台都会出空列表项?

不是。更准确地说,是其它平台编辑器行为不完全可预测,所以共享层先做纯防御性净化更稳。是否已经出事故,和是否值得提前防守,不是一回事。

为什么共享层修了之后,微信还要再保留一道逻辑?

因为还有 article.html 这种可能绕过共享渲染链的输入。共享层负责默认路径,平台层负责特殊路径的最后一道兜底,两者并不冲突。

这会不会误伤代码块或正常正文空行?

不会,前提是实现只处理结构标签边界上的空白,而不是粗暴压掉全文空白。OmniPost 目前的实现就是这种有边界的结构净化:处理列表和表格结构,但不碰 <pre> 代码块里的文本换行。

这和 preview 的关系是什么?

preview 解决的是“看起来像不像要提交的内容”,而结构空白净化解决的是“平台会不会把共享 HTML 误解释成额外节点”。两者相关,但不是同一层问题。

把“空白修复”从微信公众号专属补丁,上移成 markdownToHtml 的共享出口净化,再在平台适配器里保留特殊路径兜底,本质上是在做一件非常值钱的事:先把共享产物修干净,再让平台层只处理平台自己的复杂性。 这才是多平台分发工具可持续演进的方式。如果你想把这种本地优先、可控又可扩展的内容分发流程跑起来,可以直接试试 OmniPost:<https://omnigoai.com/zh/download/omnipost/>。

#OmniPost#markdownToHtml#微信公众号#多平台发布

更多文章

11 分钟

为什么超时要贯穿到底层执行器

超时如果只停在助理表层,底层命令、子进程和桌面动作仍可能继续跑。本文解释为什么执行型 AI 助理必须把 timeout 一路传到底层执行器,并用优雅停机替代简单粗暴的直接杀掉。

阅读