乐于分享
好东西不私藏

我们的公众号编辑器上线之后,我又推翻了几件事

我们的公众号编辑器上线之后,我又推翻了几件事

工具上线那一刻,你以为它完成了。真正用起来才发现,它才刚开始告诉你哪里不对。

之前的文章里,我记录了「英台公众号排版助手」从想法到上架的过程。传送门:我们为什么做了一个公众号排版助手

那时候我以为,把工作台、模板、AI 增强、富文本复制这条主线打通,插件就算交付了。

结果上架之后,真正密集的迭代才刚开始。

用户拿它排长文、贴代码、传图片、写回编辑器——每一个真实场景,都在暴露我之前没想清楚的问题。

这篇文章记录的是上线之后的几次重要推翻。它们大多不是「加了个功能」,而是「原来那件事我一开始就想错了」。


一、AI 排版的方向错了:它不该替我改字

最早做「一键 AI 排版」时,我的设想很自然:

把整篇 Markdown 交给模型,让它返回一份排版更好看的版本。

听起来没问题。但用户第一次用完就回来反馈一句话:它把我的原文改了

我去看了一下,确实——模型把「产品力」改成了「产品能力」,把一段口语化的句子润色成了书面语,甚至还自作主张补了一个小标题。

对排版工具来说,这是不可接受的。用户要的是好看,不是替我重写。

于是我开始改提示词。

第一版,我要求模型「保留原文信息,不要扩写」。没用,它还是会微调措辞。

第二版,我把规则加得更狠:「逐字保留,不删字、不改字、不换词、不补字」。模型稍微老实了一点,但偶尔还是会犯。

而且还有一个更麻烦的问题:长文章会被截断。

模型有输出长度上限。一篇一万字的文章,它排着排着就断了,返回一段不完整的 JSON,直接报错。

我花了不少力气在「怎么让它返回得更全」上——提高 maxTokens、强调「必须输出到结尾」、加容错解析……但本质上,我在对抗一个矛盾:

只要我让模型「重写整篇文字」,就同时承担了「它可能改字」和「它可能截断」两个风险。

后来有一天,我想通了一件事:

模型根本不需要碰文字。

它要做的,只是告诉我「这一段是标题、那一段该加粗哪个词、这一块是列表」——至于文字本身,本地有的是原文,我自己拼就行。

于是我重写了整个流程,叫「指令式排版」:

  1. 本地先把文章拆成块(标题、段落、列表、引用、代码)。
  2. 把「编号块列表」发给模型。
  3. 模型只返回每块的指令:第 0 块是 h2,第 1 块是正文、加粗「关键词」,第 2 块保持列表……
  4. 本地拿指令 + 原文,重新组装成带格式的 Markdown。

模型全程没碰到一个汉字。

这个改法带来三个立竿见影的好处:

  • 绝对不会改字:文字来自本地原文,模型想改也改不了。
  • 不会截断:模型只输出几 KB 的指令 JSON,和文章长度无关,一万字、五万字都一样。
  • 全文一致:一次请求看全文,同一个术语在各段都会被加粗。

这次调整让我对「AI 在工具里该扮演什么角色」有了新的理解:

AI 适合做判断(这块是什么、那个词重要不重要),不适合做搬运(把文字重写一遍)。

让模型去做它擅长的判断,把机械的拼装留给本地,这件事的稳定性立刻上了一个台阶。


二、图片上传:绝大多数自媒体人,根本没有图床

排版工具绕不开图片。一开始我没太重视这块,因为「图片上传」听起来是个标准需求——配个图床就行。

但真正去想用户场景时,我发现一个尴尬的事实:

绝大多数写公众号的人,手里既没有阿里云 OSS,也没有腾讯云 COS。

让他们为了传张图,去注册云服务、配置密钥、开 bucket、绑域名——这个门槛比排版本身还高。

那公众号自己的图片存储呢?微信是有素材接口的。

我去研究了业内做得最成熟的同类工具,有的方案是:

  • 用户配置 AppID + AppSecret。
  • 因为微信 API 要求调用方 IP 在白名单里,而用户家庭宽带 IP 是动态的,所以还得自建一台代理服务器(推荐用 Cloudflare Workers 部署)。
  • 代理服务器的固定 IP 加进白名单,一劳永逸。

技术上没问题,但对普通自媒体用户来说,部署 Cloudflare Workers 这一步就够劝退了。

我盯着这个方案看了一会儿,突然意识到一件事:我的插件不一样,它就跑在微信公众号的编辑页里

用户已经登录了编辑器,浏览器里已经带着登录凭证。我不需要 AppID、不需要 AppSecret、不需要白名单、不需要代理——这些编辑器自己都有了。

我只需要复用编辑器内部的图片上传接口。

但这条路也不是没坑。

第一个坑:编辑器内部用的上传接口,参数和官方 API 不一样。它要的不是 access_token,而是 ticketticket_idsvr_time——这些藏在页面的全局变量 wx.data 里。

而 Chrome 扩展的 content script 运行在隔离世界,读不到页面的 JS 全局变量。我得用 chrome.scripting.executeScript 注入到 main world 才能拿到。

第二个坑更深。我照着搜来的资料写好了接口参数,上传也成功了(返回 ret: 0),但返回里只有一个 content: "302794845"——一个素材 ID,不是图片 URL。

预览里当然显示不出图。

我盯着这个 ID 看了半天,才反应过来:我用的参数 scene=5 是上传到素材库的,返回的就是素材 ID。而正文图片需要的是 scene=8,才会返回 cdn_url

抓了一下编辑器自己上传图片时的真实请求,一对比,参数差了六七个。补齐之后,终于返回了带 cdn_url 的完整结果。

最后这套方案的配置要求是:

零配置。用户登录公众号编辑器就能用。

不分认证类型(个人订阅号也行),不需要任何密钥,不需要代理。

这件事让我再次确认一个产品判断:

工具要贴合用户的真实条件,而不是要求用户来迁就工具的技术栈。

用户没有 OSS,那就别让他配 OSS。用户已经在编辑器里了,那就把这条现成的路走到底。


三、代码高亮:和 sanitize 打了一架

技术类公众号文章里,代码块是刚需。

插件早期的代码块只是「灰底纯文本」,没有语法颜色。说不上难看,但和专业的代码编辑器一比,显得粗糙不少。

我想给它加上语法高亮。

最直接的路是抄作业——业内成熟的方案是 highlight.js,很多编辑器用的也是它。装上、调一下 renderer,应该就完了。

结果第一天就撞墙了。

highlight.js 高亮后的代码是这样的:

<spanclass="hljs-keyword">const</span> x = <spanclass="hljs-number">1</span>;

它用的是 CSS 类名(hljs-keywordhljs-number),靠外部样式表上色。

但微信公众号不支持外部 CSS,也不支持 class 选择器——所有样式必须是内联的 style

更要命的是,我自己的插件里有一道 sanitizeHtml,会把所有 class 属性过滤掉(出于安全考虑)。

所以高亮的颜色全丢了,span 变成了无样式的透明标签。

解法是把 class 转成内联 style:维护一张配色表,hljs-keyword → color:#d73a49,渲染时把每个 span 的 class 替换成对应的内联色值。

这一步做完,代码块终于有了语法颜色。

但紧接着又冒出第二个问题,而且藏得很深。

后来做背景图功能时,用户上传的背景图死活不显示。我查了半天,发现还是 sanitizeHtml 干的——它有一条规则,把所有含 url( 的样式值当 CSS 注入风险,一刀切过滤掉。

这条规则本意是防 url(javascript:...) 这类攻击,但 background-image: url(https://mmbiz...) 也被它一起杀了。

解法是让规则更聪明一点:对 background 系属性放行 url(),但提取出 URL 用安全校验过滤,只允许 http(s) 和 data:image,拦掉 javascript:

这两次撞墙让我对「安全过滤」有了新的认识:

安全规则不是越严越好,而是要严在正确的维度上。

一刀切最省事,但会误杀合法功能;精准过滤才费力,但能兼顾安全和可用。工具型产品里,这种「看不见的过滤」一旦设错,用户根本不知道为什么功能不生效,只会觉得「这工具坏了」。


四、写回编辑器:为什么 DOM 操作注定不稳

插件一直有个「写回编辑器」的功能——把排好版的内容直接写进微信编辑器。

早期实现很直接:找到编辑器的 DOM 元素,设 innerHTML,触发 input 事件。

本地测试没问题,但用户反馈一个诡异的现象:

写回之后,编辑器里看着是对的,但保存发布时,内容又变回旧的。

我去研究了一下,才明白问题出在哪。

微信公众号编辑器不是一张静态的纸。它内部有自己的数据模型(类似 ProseMirror),DOM 只是它的视图层。

直接改 DOM,视图确实变了,但编辑器内部的状态没同步。等它下次从内部状态重渲染,或者提交发布时读的是内部状态——你写进去的内容就「消失」了。

这是所有「直接操作富文本编辑器 DOM」方案的通病:视图和数据不一致。

我去翻微信的开放文档,意外发现一个之前没注意的东西:

微信官方提供了一个编辑器 JSAPI(window.__MP_Editor_JSAPI__),专门给第三方插件用。

里面有 mp_editor_get_content(读全文)和 mp_editor_set_content(写全文)。用官方接口写,编辑器内部状态会正确同步。

这才是「正确的路」——微信自己提供的稳定通道,不怕 DOM 改版,不怕状态不同步。

但用它的代价是:这个 JSAPI 是页面全局变量,content script 隔离世界读不到,得通过 executeScript 的 world: 'MAIN' 注入调用,还得用 Promise 把它的异步回调包装起来(因为 executeScript 默认只等同步返回)。

做完这套桥接后,写回终于稳了。我还保留了 DOM 方式作为兜底——万一某个旧版编辑器没挂载 JSAPI,至少不会直接报错。

这次经历让我定下一条原则:

能用官方接口,就别逆向 DOM。

逆向 DOM 看似灵活,实际上是在和编辑器的内部实现赛跑——它改一次,你修一次。官方接口是它承诺给你的契约,靠契约比靠猜测稳得多。


五、HTML 和 Markdown 之间那道缝

工作台左侧是 Markdown 编辑区,中间预览的是富文本 HTML,写回编辑器的也是 HTML。

这意味着内容要在 Markdown 和 HTML 之间来回转换。

而这俩格式,根本不是对等的。

Markdown 是个「精简集」,它表达不了 HTML 的很多东西——嵌套的 section、精细的内联样式、特定的 class、复杂的表格结构。

所以「HTML 转 Markdown 再转回 HTML」这个往返,注定是有损的。

这个损耗在用户真实使用时,表现为一连串具体的 bug:

表格整块丢失。 turndown(HTML→Markdown 的库)默认不支持表格转换。一段好好的表格,转完只剩散落的文字。我手动加了四条规则,把 <table> 转成标准 Markdown 表格,列数用 DOM 实际计算,才算救回来。

列表每一项之间多空行。 微信编辑器的列表项结构是 <li><section><p>文字</p></section></li>,li 里嵌套了 p。turndown 把 li 里的 p 当段落,输出双换行,列表项之间就出现了空行。而且这个空行还会传染——读进来是松散列表,写回去 marked 又给每项套 <li><p>,编辑器再渲染一层间距,空行越变越多。最后是两头一起修才堵住:读取时让 li 内的 p 紧凑化,写回时剥掉 <li><p> 的 p 包裹。

整段内容被包进代码块。 这是最离谱的一个。最初读进来的 Markdown 整个被一对 ``` 包住了。查了半天,发现是 turndown 的两条自定义规则在作怪——它们本意是「识别代码片段」,但判定太宽,把微信富文本里大量带样式的叶子 section 误判成了代码。移掉这两条规则,问题才消失。

修这一串问题的过程,让我对「中间格式」这件事有了更清醒的认识:

中间格式不是免费的。每多一层转换,就多一层损耗。

工作台用 Markdown 编辑,是因为它对人类友好、好改、好 diff。但只要牵涉到和富文本往返,就要接受「无法 100% 还原」这个事实。

后来我做了一个重要取舍:读取编辑器内容时,不再把 HTML 转成 Markdown 再重新渲染,而是直接保留原始 HTML 作为预览内容。Markdown 只作为左侧的文字参考。

这样一来,「查看和微调」场景下样式零损耗;只有用户主动编辑 Markdown 时,才会切换成「按当前模板重新渲染」——那是明牌的重新设计,不是静默的有损转换。

承认中间格式的局限,比假装它能完美往返,要诚实得多。


六、体验的细节:让每一次反馈都被看见

上线之后,我还花了不少力气在「不那么起眼但影响体感」的细节上。

全局 Toast 提示。 之前操作结果只显示在工作台左上角的一行小字里,用户经常注意不到。后来加了顶部居中的 Toast,任何操作(写回、上传、复制、导出、排版)完成都会弹出,2 秒自动消失。反馈变得确定、即时。

样式卡片的手风琴模式。 右侧的样式微调有七八张折叠卡片(标题、引用、代码块、图片、列表、链接、背景……)。之前它们各自独立,用户展开三四张后,右侧乱成一锅粥。改成手风琴——打开一张自动收起其他,任意时刻最多一张展开。清爽多了。

背景设置。 加了背景颜色和背景图片上传,图片默认循环铺满。这件事顺便逼着我修了 sanitize 对 url() 的误杀(前面代码高亮那节提过),算是意外收获。

导出 PDF。 用浏览器原生打印能力做的,矢量文字、可选、清晰。并强制保留背景色(打印默认不印背景,要加 print-color-adjust: exact)。

整体内边距滑杆。 文章外层的留白可以滑动调节,四边联动。

这些单独拎出来都不算大功能,但合在一起,让工具从「能用」变成了「用着舒服」。

我一直相信一条:

专业感不来自功能数量,而来自每个细节都经得起推敲。

用户说不出哪里好,但就是觉得顺手——这才是工具该有的样子。


七、模板:从「能用」到「够用且有性格」

模板是排版工具的门面。

早期我对模板的理解是「多就是好」,于是堆了不少。但堆着堆着发现一个问题:

很多模板看起来差不多,换个名字就是新的一套。

用户面对十几张看起来差不多的卡片,反而不知道选哪个。

后来我做了一次收敛:模板卡改成双列紧凑布局,只显示色点 + 名称,描述放 hover 提示。列表不再占满屏幕。

同时新增了十套色系明确区分的模板:

  • 极简墨黑:纯黑白灰,无彩色装饰,适合严肃表达。
  • 薰衣草之梦:柔和紫色,圆角块标题,适合心灵、女性向。
  • 莫兰迪雾:低饱和灰绿,高级灰调,适合艺术、生活。
  • 赭石暖阳:赤陶红,暖土色,适合旅行、人文。
  • 薄荷冰:冰青薄荷,清凉通透,适合夏季、健康。

其中这五套和原有的蓝、绿、橙、深色系拉开了明显差距。

做这五套时我给自己定了个规矩:

每套模板必须有「性格」,而不是「换了个主色」。

所以它们不只是变色——H2 的呈现方式也不同(极简用细左边框、薰衣草用圆角色块、莫兰迪用克制灰线、赭石用暖色块、薄荷用清冰块)。配色、形态、气质三者一致,模板才有辨识度。


写在最后

上线之后的这段时间,这个插件改的东西,比上线前还多。

不是因为之前没做好,而是因为用户真正用起来之后,才会把那些藏在角落里的问题一个个翻出来

AI 改了我的字、长文被截断、图片传不上去、代码块没颜色、写回编辑器内容丢失、表格整块消失、列表多出一堆空行——每一个都是真实场景里撞出来的,没有一个是坐在桌前能预先想清楚的。

这让我越来越认同一种做工具的态度:

工具是活的。它得跟着用户的真实工作流一起长。

不是坐在原地等用户来夸,而是主动去到用户最别扭的那个环节,把别扭一点点磨平。

如果你想试试这个插件,可以从默认示例文章开始,切几套模板,调调字号、代码块和背景,再写回编辑器看看效果。

如果你排某种类型的文章时遇到不顺,或者觉得某个环节还能更好,欢迎告诉我。

它还会继续长。

把机械的排版交给工具,把真正的表达留给人——这件事,值得一直做下去。