有一批自动填单的任务,每一单都卡在同一个地方:校验报「问题描述未同步到提交源」,于是只存草稿不提交。
但坐席打开页面看,描述框里内容好好的,一个字不少。
这个 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 字 ✅ 写进去了 |
| 不派事件,等 300ms | 99 字 ✅ 留住了 |
派 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 从来没空过,是读法把它读没了。
正确的做法是写一个和「写入」严格互逆的读函数:
/** HTML 转义,防止正文里的 < 和 & 破坏结构 */export function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');}
/** 纯文本 → 富文本 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(/ /g, ' ') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') // ⚠ 必须最后:先解它会让 &lt; 二次解码成 < .replace(//g, '') // 零宽空格,富文本编辑器常插 .trim();}两个细节:
<br>必须在剥标签之前处理,否则换行信息就永久丢了。&必须最后解码。先解它的话,&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 stateawait 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,手动跑一遍,看真实的值),胜过对着日志推断三轮。