有一批自动填单的任务,每一单都卡在同一个地方:校验报「问题描述未同步到提交源」,于是只存草稿不提交。

但坐席打开页面看,描述框里内容好好的,一个字不少。

这个 bug 查了四轮,前三轮的结论全是错的。最后定案时才发现:宿主页面那个富文本字段,背后不是一份数据,是三份。

第一层:显示层不是提交源

很多基于 iframe 的富文本编辑器(TinyMCE 一类)是这个结构:

┌─ 可见的编辑区:一个 <iframe>,内容在它的 body 里 ──┐
│ 坐席看到的、能打字的,是这个 │
└────────────────────────────────────────────────┘
┌─ 隐藏的 <textarea> ───────────────────────────┐
│ 表单提交时真正读的,是这个 │
└────────────────────────────────────────────────┘

往 iframe 的 body 里写 HTML,屏幕上立刻就对了——所以肉眼验证永远通过。但隐藏的那个 textarea 只有在编辑器自己认为「该同步了」的时候才会被回写。你程序化地塞进去的内容,它不一定知道。

所以第一条规矩:写完要主动同步到提交源,校验也必须读提交源。读 iframe 里有什么,等于什么都没校验。

陷阱一:我们自己派的事件,把刚写进去的内容清空了

知道要写 textarea 之后,很自然会这么写:

setNativeValue(textarea, html); // 绕过 React 的值追踪器写进去
textarea.dispatchEvent(new Event('input', { bubbles: true })); // 告诉 React 值变了
textarea.dispatchEvent(new Event('change', { bubbles: true }));

派事件这一步是标准做法——操控 React 受控组件时,不派事件 React 就不认。

现场实测的结果是这样:

步骤textarea.value
native setter 写入99 字 ✅ 写进去了
不派事件,等 300ms99 字 ✅ 留住了
派 input 事件"" ❌ 当场被清空

这个富文本是 React 受控组件:收到 input 之后,它拿自己 model 里的值重新 render 这个 textarea。而它 model 里是空的——我们是直接写 DOM 进去的,没经过它。

于是那段「写 textarea → 派事件 → 回读 → 还是空 → 再写」的重试轮询,每一轮都被自己派的事件清掉,2.5 秒耗尽判失败。

修法是删掉那两行 dispatchEvent。但边界要划清楚,这里有两个必须保留事件的地方:

  • iframe body 上的事件要保留——那是写给编辑器看的,告诉它显示层变了。被清空的只有 textarea 这一路。
  • 纯 textarea 字段(没有 iframe 那层)的事件要保留——实测这种字段派事件不会被清空,React 反而需要这个事件才知道值变了。

只有「iframe + 隐藏 textarea」这种双源结构才有这个问题。 一刀切地删掉所有 dispatchEvent 会坏掉另一批字段。

这一步的安全性不能靠推断

「不派事件」有个很吓人的顾虑:万一宿主提交时读的是 React state 而不是 DOM,那我们就会提交一张空工单。

这个顾虑没法靠读代码排除——宿主没有源码。所以做法是:用「不派事件」写好一单,让人真的手工提交一次,然后去查那单的内容。

结果是描述完整。证实提交读的是 DOM 里的 textarea.value,方案成立。

涉及「可能产出错误数据」的改动,实证一次的成本远低于推断错一次。

陷阱二:内容一直都在,是读法把它读没了

同一轮里还查出一个更隐蔽的:校验函数报「问题描述为空」,但 textarea 里明明有 85 个字。

问题出在剥标签的那行正则:

const text = html.replace(/<[^>]+>/g, ''); // ← 看起来人畜无害

提交源里存的 HTML 长这样:

<p>客户名称:X<br />客户编码:Y<br />联系方式:Z<br />问题描述:正文</p>

裸 <[^>]+> 把 <br /> 也剥成了空字符串,四行模板被拍平成一整行:

客户名称:X客户编码:Y联系方式:Z问题描述:正文

而解析模板的函数是逐行匹配的(行锚 ^\s*问题描述[::])。拿到拍平的单行,它切不出「问题描述」段,于是判空。

textarea 从来没空过,是读法把它读没了。

正确的做法是写一个和「写入」严格互逆的读函数:

src/shared/rich-text.ts
/** HTML 转义,防止正文里的 < 和 & 破坏结构 */
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** 纯文本 → 富文本 HTML:单个 <p> 内用 <br> 分行 */
export function textToRichHtml(text: string): string {
const lines = text.split('\n');
if (!lines.length || (lines.length === 1 && !lines[0])) return '<p><br></p>';
return `<p>${lines.map(escapeHtml).join('<br>')}</p>`;
}
/** 富文本 HTML → 纯文本:上面那个函数的逆运算 */
export function richHtmlToText(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n') // ★ 分行标记先还原成换行,再剥标签
.replace(/<\/p\s*>/gi, '\n') // 逐行 <p> 的老数据也能正确还原
.replace(/<[^>]+>/g, '') // 其余标签一律剥掉
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&') // ⚠ 必须最后:先解它会让 &amp;lt; 二次解码成 <
.replace(/​/g, '') // 零宽空格,富文本编辑器常插
.trim();
}

两个细节:

  • <br> 必须在剥标签之前处理,否则换行信息就永久丢了。
  • &amp; 必须最后解码。先解它的话,&amp;lt; 会被二次解码成 <——这是一个会在别人的输入里等着你的坑。

顺带:为什么是 <br> 不是逐行 <p>

最初的写法是 text.split('\n').map(l => '<p>' + l + '</p>'),把每一行包成独立段落。

用户报「描述里多了空行」——因为 <p> 是段落,编辑器给它段间距,四行模板渲染出来每行之间都空一行。body.innerText 读出来也确实是双 \n。

改成单个 <p> 内用 <br> 分行后,视觉无空行、innerText 是单 \n。读端两种写法都能正确还原(上面那个函数两种都处理了)。

还有一条:判据必须同源

同步校验原本比的是「期望内容的最后 30 个字符」,这会从正文中间硬切,切出来的片段毫无语义:

"58\n联系方式:\n问题描述:…"

改成两边都解析成结构化的段再比语义单元,并且和提交前的校验走同一个函数。

判据不同源,你就会碰上「一个说成功、一个说失败」的死结——而且两边都言之凿凿。

第三个源:存草稿读的是 React state

上面两个修完,提交正常了。然后用户报了一个新现象:

同一单,提交完全正常;但如果不提交、关掉页面再打开草稿,只剩前三行,问题描述没了。

定位手法:别猜,直接去读宿主存草稿的地方

草稿存在 localStorage 里,键名有规律,值是一个 JSON。直接读出来看:

丢描述的那几单,草稿里 content 字段的问题描述段本来就是空的。

→ 这不是「重开时被什么东西盖掉了」,是关页那一刻就没存进去。

这一步很关键。前面几轮全在查「谁把它盖掉了」,方向从一开始就是错的。去读最终产物,比沿着链路往回推快得多。

逐项实测:什么能进 state

既然是关页时写的,那就一项项试,每试一种写法就关一次页,然后读 localStorage:

写法草稿里有没有
body.innerHTML = html❌
editor.fire('change')❌
textarea native setter + input 事件❌ 且 React 当场把 textarea 改回旧值(陷阱一复现)
setContent() + save() + triggerSave()❌
在编辑器里真实按键❌
调组件自己的 React onChange(html)✅

结论:宿主只在关页那一刻写一次草稿,写的是 React state——不读 DOM,不读隐藏 textarea。

而我们为了守住陷阱一的契约(写完 textarea 绝不派事件),只改了 DOM 和 textarea,那段内容从来没进过 React state。提交正常、草稿必丢,两个现象是同一个原因。

(同一个描述框里的前三行不丢,是因为它们走的是另一条路径:改文本节点 + 派 input,能进 state。同一个字段里两条写入路径命运不同——这也是这个 bug 难查的原因之一。)

修法:增量补一步,不动既有写法

// 写完 DOM 和 textarea 之后,增量补一步:把同一份 HTML 推进 React state
await pushToReactState(fieldEl, html); // fire-and-forget,失败只 warn 不中断

DOM / textarea 的既有写法一行未动,提交契约完全不受影响。三个源各写各的,互不干扰。

⚠ 这一步必须在主世界执行

第一版我把「找组件的 onChange」写在内容脚本里,实测每一单都打「未找到 React onChange」——等于没修。

因为 React fiber 挂在 DOM 元素的 __reactInternalInstance$… 属性上,那是页面 JS 对象,扩展的隔离世界读不到。在 DevTools 里试能成功,是因为 DevTools 默认跑在主世界。

这个坑我在另一篇里单独写了。这里只记结论:凡是碰 fiber、碰编辑器实例的,一律先想桥。

三个源,一张表

源是什么谁读它怎么写进去
显示层编辑器 iframe 的 body人眼body.innerHTML = html,在 iframe 上派事件(告诉编辑器显示层变了)
提交源隐藏的 <textarea>表单提交native setter 直写,写完不派任何事件
组件状态React state关页存草稿调组件自己的 onChange(html),只能在主世界

三个源、三种写法,其中两条的事件策略正好相反。

碰到这类「别人家的富文本」,值得先花十分钟把这张表填出来——比修三轮便宜。

四轮作废假设,和一条更贵的教训

留个档,这四个结论全是错的:

当时的判断为什么错
是某个后台轮询写进了重建后的 iframe,冲掉内容那个 bug 真实存在(另修了),但与本条无关——日志显示它全程被一道闸挡着,根本没参与
改走表单实例的 setFieldsValue 直填前提被证伪:隔离世界能拿到的那个表单实例根本不是目标表单
是后续步骤回写时冲掉了已同步的内容被假日志误导——那句「同步确认: true」是旧代码早退的假阳性,它根本没写就报成功
有残留的编辑器实例在抢写现场实测否定:只有一个实例、未销毁、目标节点在 DOM 里

最贵的一条在第三行。

当时日志里有一句 textarea实读: "",我围着它查了三轮。那句日志本身是错的——它用的是陷阱二里那个坏正则,把有内容的 textarea 读成了空。

用户两次说「明明填写成功了」「我觉得是校验的时候出的问题」,两次都是对的,而我两次都被自己的日志说服了。

日志也是代码写的。代码有 bug 的时候,日志同样会撒谎。

现场实测一次(打开 DevTools,手动跑一遍,看真实的值),胜过对着日志推断三轮。