做了几年网页富文本编辑器,我到现在还记着第一次有用户抱怨“从Word粘贴过来之后整个页面全乱了”的场景。那会儿我用的是最粗暴的方式:直接把event.clipboardData.getData('text/html')拿到的内容塞进编辑器里,结果就是满屏的class="MsoNormal"、<o:p>空节点、mso-*私有样式、条件注释,还有十八层内联嵌套。后来我专门研究过一轮“富文本编辑器、Word、粘贴、自定义过滤规则”这组关键词,发现大部分资料只讲了“用现成库清洗一下”,但对Word生成的那堆HTML到底长什么样、为什么要这么设计过滤规则、规则写在代码里怎么组织,讲得不够透。
这篇文章想把整个思路从头到尾摊开讲。核心解决的是在线文档、后台CMS、知识库这类系统里的共性问题:用户从Word复制内容粘贴到网页编辑框,如何通过自定义过滤规则,把Office私有语义转换成编辑器能展示的干净HTML。适合正在做富文本编辑器、想优化粘贴体验的前端同学,也适合负责低代码平台或工单系统、被Word粘贴搞得焦头烂额的开发者参考。我会给出一套可以直接落地的规则设计思路、完整代码骨架,以及我踩过的一些坑。
1. 为什么Word复制出来的HTML这么难搞
1.1 Word往剪贴板里塞了一整套“Office世界”
很多人以为从Word复制内容,剪贴板里只有一段纯文本和一段干净的HTML。实际上Word复制时会向剪贴板写入多种格式,除了纯文本text/plain,还有text/html、text/rtf,甚至OLE对象。对网页编辑器来说,我们拿到的text/html是Word自己生成的,不是浏览器解析出来的,它把Office内部的一套文档模型整个暴露出来了。
一个典型的Word复制HTML长这样:
<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"> <head> <style> @page WordSection1 { size: 595.3pt 841.9pt; margin: 72.0pt 72.0pt 72.0pt 72.0pt; } p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0cm; ... } </style> </head> <body> <p class="MsoNormal" style="text-indent:21.0pt; mso-char-indent-count:2.0;"> <span style="font-family:宋体; mso-ascii-font-family:Calibri;">这是一段内容</span> <o:p></o:p> </p> <!--[if supportLists]--> <p class="MsoListParagraph">...</p> <![endif]--> </body> </html>特征很明显:大量xmlns命名空间声明、MsoNormal这类业务类名、mso-*私有样式、<o:p>这种Office专属哨兵标签、条件注释,还有<style>里的@page规则。浏览器默认粘贴时并不认识这套语义,它只知道把这些节点塞进contenteditable,类名没有任何CSS定义,私有样式被部分保留但没意义,于是用户看到的就是一团糟。
1.2 剪贴板数据里不止有HTML
处理前必须知道我们能拿到什么。标准paste事件里,event.clipboardData可以读取剪贴板内容,关键方法有两个:
event.clipboardData.getData('text/html'); event.clipboardData.getData('text/plain');如果你调试过,会发现同一份内容,从Word复制时text/html段非常长,带一堆命名空间和类名;从浏览器网页里复制时text/html相对干净;从纯文本编辑器复制时text/html通常是空字符串。这个差异本身就可以作为过滤流程的开关:如果HTML里能匹配到xmlns:o=、MsoNormal、<!--[if gte mso 9]>这类特征,就走Word专用清洗流程;否则只做普通净化。RTF格式text/rtf也能读到,但大多数网页编辑器用不到,它主要是给Windows原生应用之间交换用的。
1.3 第一步永远是拦截,而不是事后补救
正确的流程是在粘贴发生前就拦截,处理完再插入,绝不能先让编辑器把原始内容吃进去再想着清理。回归代码:
editorEl.addEventListener('paste', function (event) { const clipboardData = event.clipboardData || window.clipboardData; const html = clipboardData && clipboardData.getData('text/html'); if (html) { const hasWordMark = /xmlns:o=|MsoNormal|Microsoft(Word|Office)|<!--\[if .*mso/gi.test(html); event.preventDefault(); let cleaned; if (hasWordMark) { cleaned = cleanWordHtml(html, wordRules); } else { cleaned = sanitizeNormalHtml(html); // 普通HTML净化 } insertHtmlAtCursor(cleaned); } });preventDefault()相当于把编辑器默认粘贴行为关掉。这一步不做,后面所有自定义规则都是给别人的孩子洗澡,白费力气。
2. 自定义过滤规则应该怎么设计
2.1 先定基调:白名单为主,黑名单兜底
设计过滤规则第一个绕不开的问题是:到底是“列出我允许的”,还是“列出我要删的”。我的经验是白名单为主、黑名单兜底。原因很简单,Office的私有标记太多了,黑名单根本列不全。今天你认识<o:p>,明天可能冒出mso-ansi-font-size、mso-line-height-rule、<v:roundrect>。白名单的特点是“我只要我知道的、能渲染的”,其余一律拆掉,输出永远是可控的。
黑名单用来处理那些不需要经过白名单审批、明确要整棵删除的东西,比如<script>、<iframe>、<style>里的内容、<head>标签。白名单和黑名单混用,逻辑上更干净。
2.2 规则对象的结构
为了让过滤逻辑不散落在函数里到处if,我习惯把它收敛成一个可配置的规则对象。一个典型配置长这样:
const wordRules = { allowedTags: [ 'P', 'DIV', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'A', 'IMG', 'UL', 'OL', 'LI', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'CODE', 'SPAN', 'SUB', 'SUP', 'HR' ], deniedTags: ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'STYLE', 'XML'], allowedAttrs: ['href', 'src', 'alt', 'title', 'target', 'colspan', 'rowspan', 'width'], styleWhitelist: [ 'font-family', 'font-size', 'font-weight', 'font-style', 'color', 'background-color', 'text-align', 'text-indent', 'line-height', 'margin', 'padding', 'vertical-align', 'border', 'border-collapse', 'border-spacing', 'text-decoration', 'list-style-type', 'float', 'max-width' ], stripEmptyTags: ['P', 'SPAN', 'DIV'], image: { allowDataURI: true, allowHttp: true }, table: { normalizeWidth: true, maxRows: 100, maxCols: 20 } };把规则抽成对象有两个好处。第一,测试好写,给不同项目传不同配置就能跑用例。第二,不同业务场景差异很大:知识库允许多级标题和表格,聊天窗口可能只需要粗体斜体,规则可配置就不用改引擎代码。规则引擎是通用的,业务差异都在配置里。
2.3 解析HTML用DOM,别用正则硬解
必须强调一点:不要试图用正则完整解析HTML。HTML结构有嵌套、有容错、有缺失闭合,正则处理树形结构是经典的反模式。正确做法是先把HTML字符串转成DOM树,再通过递归遍历做结构化过滤。
浏览器里可以这样解析:
function parseHtml(html) { const template = document.createElement('template'); template.innerHTML = html; return template.content; }<template>元素不会触发图片加载、脚本执行这类副作用,比临时iframe方便很多。拿到DocumentFragment后,从根节点开始递归,每一个元素节点都过一遍过滤器。
正则也不是完全没用,它适合做“预处理”:删除条件注释、剔除<style>/<head>这类大块内容,因为它们结构简单、边界清晰,用正则快速摘掉能减少后续DOM遍历的开销。但真正精细的标签、属性、样式过滤,必须基于DOM。
3. 动手实现过滤引擎
3.1 预处理:先剥掉Word的外包装
进入DOM遍历之前,先用正则做一轮预处理,把明显不想要的块删掉。
function preprocessWordHtml(html) { // 去掉条件注释,如 <!--[if gte mso 9]>...<![endif]--> html = html.replace(/<!--\[if[\s\S]*?<!\[endif\]-->/gi, ''); // 去掉 XML 命名空间声明块 html = html.replace(/<xml>[\s\S]*?<\/xml>/gi, ''); // 去掉整个 head,里面通常只有样式和 meta html = html.replace(/<head[\s\S]*?<\/head>/gi, ''); // 去掉内联样式表 html = html.replace(/<style[\s\S]*?<\/style>/gi, ''); return html; }为什么先做这一步?因为Word生成的HTML里,<style>包含@page、.MsoNormal、.MsoListParagraph等大量规则,这些对编辑器没有用,还会误导后续判断。条件注释里往往藏着列表编号和公式的结构,如果不删,等到DOM遍历时会变成奇怪的文本节点。预处理做得好,后面树遍历的压力直接减半。
3.2 标签级过滤:保留、拆解还是删除
DOM遍历的核心是对每个节点做三选一决策:
- 保留:节点标签在白名单里,继续处理属性和子节点。
- 拆解(unwrap):标签不在白名单,但内容希望保留,比如
<u>、<font>。拆解就是丢掉标签,把子节点提升到父级。 - 删除(remove):标签在黑名单或明确无意义,比如
<o:p>、<script>、<iframe>,连同子树整个删掉。
代码实现看起来像这样:
function filterTag(node, rules) { const tag = node.nodeName.toLowerCase(); if (rules.deniedTags.includes(tag)) return 'remove'; if (rules.allowedTags.includes(tag)) return 'keep'; return 'unwrap'; }拆解要注意点:node.replaceWith(...node.childNodes)时,childNodes是动态集合,直接展开会出问题。稳妥做法是先把子节点转成数组:
function unwrapNode(node) { const children = Array.from(node.childNodes); node.replaceWith(...children); }<div style="text-align: center;"><p>文字</p></div>这类块级标签,如果白名单里有<p>没有<div>,直接拆<div>会导致center样式丢失。所以拆解时如果节点本身有值得保留的样式,要先合并到子节点或转移给父节点。实战里我一般建议把div、p、span都放进白名单,靠样式层做清理,不要轻易拆块级标签,否则排版丢得很厉害。
3.3 属性与样式过滤:从源头上销毁Office私有样式
标签保留后,下一步过滤属性。原则上只保留白名单里的属性,并且对href、src这类带协议的值做二次校验:
function sanitizeAttrs(node, rules) { const attrs = Array.from(node.attributes); for (const attr of attrs) { const name = attr.name.toLowerCase(); if (!rules.allowedAttrs.includes(name)) { node.removeAttribute(attr.name); continue; } if (name === 'href' && /^javascript:/i.test(attr.value.trim())) { node.removeAttribute('href'); } if (name === 'src' && !/^(https?:|data:image\/)/i.test(attr.value.trim())) { node.removeAttribute('src'); } } }样式过滤是Word清洗的重头戏。Word的内联style经常长这样:
<span style="font-family:宋体; mso-ascii-font-family:Calibri; mso-hansi-font-family:Calibri; font-size:14.0pt; mso-bidi-font-size:11.0pt; color:black; mso-themecolor:text1;">我们需要从中间把mso-*丢掉,只留下编辑器支持的属性。最可靠的办法不是正则拆字符串,而是利用浏览器的CSS解析能力:
function sanitizeStyle(styleStr, styleWhitelist) { if (!styleStr) return ''; const probe = document.createElement('span'); probe.setAttribute('style', styleStr); const decl = probe.style; const kept = []; for (let i = 0; i < decl.length; i++) { const prop = decl[i]; if (styleWhitelist.includes(prop)) { kept.push(prop + ': ' + decl.getPropertyValue(prop)); } } return kept.join('; '); }decl.length是浏览器解析后的CSS属性数量,decl[i]拿到的是属性名。这个方案天然跳过mso-*这类非法或不在白名单里的属性,比正则可控得多。Color、font-family这些写成什么格式、哪个属性名是标准形式,都由浏览器帮我们归一化。
需要注意字体大小单位的问题。Word常用pt作为单位,网页里更合适的是px,否则同一文档里不同字号会出现渲染不一致。换算关系是1pt = 4/3 px,即乘1.3333。例如14.0pt换算成18.67px。这一步在样式白名单里单独处理:
function normalizeFontSize(value) { const match = value.match(/^([\d.]+)pt$/i); if (match) { return (parseFloat(match[1]) * 4 / 3).toFixed(2) + 'px'; } return value; }3.4 Word专属节点清理:见了就删,别心软
遍历过程中,凡是nodeName里带冒号的,基本都是Office命名空间下的私有节点,比如<o:p>、<v:imagedata>、<w:sdt>。这类节点网页端渲染不出来,处理策略就是“整棵拆掉”或“整棵删除”。我偏向直接删,因为o:p基本是个空哨兵,v:shapetype留着也没用:
function shouldRemoveOfficeNode(node) { return node.nodeName.indexOf(':') !== -1; }如果你要用DOMParser解析,HTML里的o:p在DOM里nodeName会是O:P,冒号判断依然有效。顺手把SVG考虑进去:如果编辑器不支持SVG,svg相关节点最好也删掉,避免粘贴进奇怪的矢量标记。
删完Office节点后,还要清理空段落。Word段落里十有八九藏着<o:p> </o:p>,一旦o:p删除,留下的就是只有空白符的空<p>,粘贴后看起来是一整页换行。我建议对p、span、div做空内容清理:
function isElementEmpty(node) { return node.nodeType === Node.ELEMENT_NODE && !node.hasChildNodes() || (node.childNodes.length === 1 && node.firstChild.nodeType === Node.TEXT_NODE && !node.textContent.trim()); }清理时注意不要误删那些靠padding或height撑起视觉占位的元素,但Word粘贴场景下这种元素很少,可以放心处理。
3.5 表格、图片、链接:每个都要单独“上刑”
表格是最容易翻车的。Word表格有大量固定单元格宽度,width单位通常是pt,直接保留会让表格在窄屏上溢出。我的做法是归一化:把table的宽度转成百分比或直接去掉宽度限制,设置max-width: 100%,让布局自适应容器。
function normalizeTable(node) { if (node.nodeName === 'TABLE') { node.style.maxWidth = '100%'; if (node.style.width) { node.removeAttribute('width'); } node.style.borderCollapse = 'collapse'; } if (node.nodeName === 'TD' || node.nodeName === 'TH') { if (node.style.width && node.style.width.includes('pt')) { node.style.width = 'auto'; } } }合并单元格的colspan、rowspan必须在白名单属性里,删掉它们表格结构就变了。border样式可以允许,但建议只允许1px solid #ccc这类简化写法,否则Word的复杂边框样式会把表格渲染得跟补丁布一样。
图片的坑在网上特别多。Word里图片在HTML中通常有两种形态:一种是<img>带file://路径,另一种是<v:imagedata>搭配OLE对象。file://路径网页端无法访问,直接在过滤时删掉src;v:imagedata前面Office命名空间清理时已经顺带删了。
如果希望图片能用,需要在paste事件里读取剪贴板文件:
const files = Array.from(clipboardData.files || []); const imageFile = files.find(function (f) { return f.type.startsWith('image/'); });拿到图片文件后,可以转成Base64暂时插入,或者走自己项目的上传接口。这是编辑器产品里图片粘贴的常用方案:先转成Base64保底,用户保存内容时再主动上传替换。
链接的href一定要做协议校验,白名单只放http:、https:、mailto:,其他一律清掉。Word里自动生成的超链接还会带mso-bookmark这类属性,统一在属性白名单里过滤掉即可。target="_blank"这种在后台系统里可能有安全要求,加不加rel="noopener"取决于编辑器自己的输出策略。
3.6 数学公式和OLE对象:隐蔽的大坑
Word里的公式到了网页端基本不能直接用。网页端常见的Word公式产物有两种:一种是m:oMath数学标记,另一种是OLE对象如<o:OLEObject>或<v:shape>包着公式编辑器内容。如果编辑器不支持渲染这些,结果就是粘贴后看到一堆空白或乱码,用户还找不到原因。
处理策略根据产品需求分三种。第一,编辑器支持公式展示(比如集成了MathJax),建议把公式部分交给专门的解析器,把oMath转成MathML或LaTeX;第二,业务允许用户以图片形式保存公式,可以把OLE对象对应的位图识别出来插入图片;第三,什么都不做,直接删掉公式节点,至少保证内容不脏。这里面的“word公式转latex”“mathtype”“公式图片转word”这些热词,说明很多用户真实需求是把Word里的公式无损搬到线上编辑器。可惜这不是过滤规则能独立解决的,需要专门工具链配合,所以在过滤引擎里我建议只做兜底:识别到公式节点则删除,并给用户提示“当前内容包含公式粘贴,建议使用公式导入工具”,避免无声的数据丢失。
4. 完整实现骨架
4.1 一个可以直接落地的cleanWordHtml
把前面几节的逻辑组装起来,就是一个可用的清洗函数。这段代码的结构是按“预处理 -> DOM遍历 -> 属性样式过滤 -> 表格图片归一化 -> 空节点清理”的顺序组织的:
function cleanWordHtml(html, rules) { html = preprocessWordHtml(html); const root = parseHtml(html); (function walk(node) { if (node.nodeType !== Node.ELEMENT_NODE) return; // 带冒号的Office节点直接删 if (shouldRemoveOfficeNode(node)) { node.remove(); return; } // 标签级过滤 const action = filterTag(node, rules); if (action === 'remove') { node.remove(); return; } if (action === 'unwrap') { unwrapNode(node); return; } // 属性与样式过滤 sanitizeAttrs(node, rules); if (node.hasAttribute('style')) { const style = sanitizeStyle(node.getAttribute('style'), rules.styleWhitelist); if (style) node.setAttribute('style', style); else node.removeAttribute('style'); } // 特殊标签处理 if (node.nodeName === 'TABLE' || node.nodeName === 'TD' || node.nodeName === 'TH') { normalizeTable(node); } if (node.nodeName === 'IMG') { sanitizeImage(node, rules.image); } if (node.nodeName === 'A') { node.setAttribute('rel', 'noopener noreferrer'); } // 递归子节点 Array.from(node.childNodes).forEach(walk); })(root); // 删除空段落 root.querySelectorAll('p, span, div').forEach(function (el) { if (isElementEmpty(el)) el.remove(); }); return root; // 调用方拿到处理后的 DocumentFragment }这段代码没有处理每个细节的边界情况,但完整覆盖了主干流程。实际项目里,建议在函数返回前最后再过一遍DOMPurify(或者服务端再清洗一次),防止XSS。之前我们讨论过“自定义规则负责还原语义,DOMPurify负责安全兜底”,这是我认为最稳的组合。
4.2 接入不同编辑器的方式
如果是原生contenteditable,插入清理后的内容有几种办法。老派但兼容性极好的做法是:
document.execCommand('insertHTML', false, fragment);虽然execCommand已经标记废弃,但在ContentEditable场景下目前仍然能用。新项目里更好的方案是拿到选中区域,用Range.insertNode:
function insertHtmlAtCursor(fragment) { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(fragment); }如果是Quill编辑器,直接在clipboard.dangerouslyPasteHTML里传入清洗后的HTML即可;TinyMCE可以在paste_preprocess回调里修改event.content。每个编辑器都有自己的粘贴接入点,但核心思路不变:拦截默认行为,执行自定义过滤,再以编辑器允许的方式注入。
4.3 用测试数据锁住行为
清洗规则很容易“修一个bug引出三个bug”,所以我建议直接把典型输入输出固化成测试用例。比如:
| 输入片段 | 期望输出 | 断言点 |
|---|---|---|
<p class="MsoNormal"><o:p></o:p></p> | 空 | 空段落被删除 |
<span style="mso-ansi-font-size:14.0pt; color:red;">文本</span> | <span style="color: red;">文本</span> | mso样式被过滤 |
<table width="623.6pt"><tr><td width="200">x</td></tr></table> | table宽度自适应 | 表格宽度被归一化 |
用Jest这类测试框架,把真实Word复制出来的HTML存成fixture文件,每次改规则后跑一遍快照,能省下大量的手工回归时间。
5. 常见问题与排查技巧实录
以下是我在实际项目里遇到频率最高的问题,整理成一个速查表。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 粘贴后只剩纯文本,样式全丢 | text/html没读到,或过滤时把 span 样式全删了 | 检查getData('text/html')返回值;确认 styleWhitelist 是否包含常用样式属性 |
| 表格溢出容器,页面被撑破 | Word表格固定宽度pt未被转换 | 对TABLE强制max-width:100%,去掉固定width |
| 图片不显示,只有红叉 | 图片src是file://,网页无法访问 | 从clipboardData.files读取图片文件,转Base64或上传后插入 |
| 粘贴进来一堆空白行 | o:p删掉后遗留空段落 | 清理空p/span/div节点 |
| 编号列表变成纯文本段落 | Word列表用了mso-list样式而非<ol> | 识别text-indent和list-style组合转换,或建议用户粘贴时选“只保留文本” |
| 从Excel粘贴表格过大 | Excel会生成超大table,行数上千 | 限制最大行列数,超过则提示改用纯文本粘贴 |
Safari里读不到text/html | 旧版本Safari对clipboardData支持不完整 | 检测不到HTML时回退到text/plain,或升级适配 |
排查这类问题有个实用技巧:在paste事件里打一个断点,把clipboardData.getData('text/html')完整复制出来,存到本地当测试样本。多存几份不同类型的Word文档(含表格、图片、公式、多级列表),清洗规则改一次就跑一遍这批样本,很多隐藏问题都能提前暴露。
另一个经验是规则执行顺序比规则本身更容易被忽略。我的固定顺序是:预处理删注释和style -> 命名空间节点直接删 -> 标签白名单过滤 -> 属性过滤 -> 样式过滤 -> 表格和图片专项处理 -> 空段落清理 -> DOMPurify兜底。顺序一旦乱了,比如先清理空段落再处理表格,很容易把表格里的空单元格误删。
6. 自研规则 vs 现成库,到底怎么选
6.1 四类方案的快速对比
| 方案 | 定位 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| DOMPurify | XSS净化 | 安全可靠,API简单 | 不做语义还原,Word样式还是脏 | 任何方案的安全兜底 |
| sanitize-html | HTML清洗 | 配置丰富,支持白名单/黑名单 | 依赖配置深度,Word特殊标签要自己定义 | Node端清洗,或给清洗流程打底 |
| js-xss | HTML过滤 | 轻量,可自定义规则 | 对Office私有结构和样式处理弱 | 简单评论、留言板 |
| 自研规则引擎 | 语义还原 | 能精确处理Word结构、列表归一化、表格转换 | 维护成本高,需要充分测试 | 在线文档、复杂表单场景 |
DOMPurify这类库解决的是“安全”问题,但把Word HTML直接交给它,结果是XSS没了,排版也基本没了,它不会理解mso-char-indent-count是首行缩进的意思。反过来,自研规则解决的是“还原”问题,允许我把一个Word的拼音指南结构翻译成编辑器支持的简单标签。做在线文档这类强排版的产品,自研是绕不开的。
6.2 我推荐的自研+兜底组合
我不会完全自研一套安全过滤器,安全领域专业的事交给专业库。实际落地是三层结构:
- 自定义规则引擎,处理Word->浏览器HTML的语义转换;
- DOMPurify对输出结果做白名单XSS清洗;
- 服务端再存一份净化后的HTML或纯文本备份。
这三层缺一层都有隐患。只做第一层,恶意用户贴一个<script>标签,可能被过滤规则漏掉;只做第二层,Word结构还原会失败。组合起来,过滤规则在“语义”层面工作,DOMPurify在“安全”层面兜底,各管各的,边界清晰。
6.3 规则配置在项目里怎么演进
规则配置不要硬编码在组件里,建议独立成模块,给不同编辑器实例传不同规则。我见过一个项目同时存在“论坛编辑器”“知识库编辑器”“后台公告编辑器”三种场景,粘贴需求完全不同。论坛只需要简单排版,知识库要允许多级标题和表格,公告后台则要严格控制样式。同一套引擎,三份不同配置,维护起来反而比三份复制粘贴的代码轻松。
演进时注意版本化。规则会随着产品需求变化,比如某天决定图片不再允许Base64存储,回到image.allowDataURI: false,历史内容是不受影响的(它是存库时的快照),但新粘贴的内容会按新规则生成。规则配置本身要有版本号,方便回溯是哪次改动引起了行为变化。
最后说一个实操体会
做这个功能踩过最大的坑,不是正则写不好,也不是规则不够全,而是总想着“完美还原Word”。后来我想通了一个原则:过滤规则的目标不是把Word原封不动搬进网页,而是在编辑器的语义范围内,尽量保留用户可读的内容。凡是解析不出来的,宁可丢掉,也不要把一堆不可见残留塞给用户。根据我的经验,Word粘贴里90%的“奇怪Bug”都出在“想保留的东西太多”。规则设计得再精细,也比不上在paste拦截前先问一句:这个编辑器到底允许哪些内容?把答案写进白名单,一切问题都从根上解决了。再分享一个小技巧:开发时打开console,把粘贴前后的HTML打印出来对比着看,日积月累就是一套非常有价值的样本库,下次再有人反馈“粘贴有问题”,你拿出样本库跑一遍,定位速度快得多。