1. 粘贴为什么是编辑器的老大难:从用户视角理解解析的起点
1.1 一次真实的使用现场
之前有位朋友在用我的 TinyEditor 做网页摘录,他的习惯是:在浏览器里打开一篇长文,Ctrl+A 全选,复制,切回编辑器,直接粘贴。然后来找我吐槽,说复制过来的内容里,有些段落的空行消失了,有些图片变成了带链接的文字,字号也忽大忽小。我打开编辑器试了一下,发现他说的情况全都存在。
这个问题的核心,就在粘贴外部文章的文本解析这一步。外部文章从哪里来,决定了剪贴板里装的是什么;剪贴板里装了什么,又决定了解析器要做什么。很多编辑器新手第一次处理粘贴时,只写了几行取文本的代码,以为拿到字符串就万事大吉。实际上,浏览器给你的是一个「混合礼包」——纯文本、HTML、富文本格式、图片文件可能同时存在。你选错了读取顺序,后面做得再精细也白搭。
1.2 剪贴板里到底装了什么
我们通常所说的「复制一段网页内容」,在操作系统层面做的事情远比想象中复杂。复制动作发生时,来源程序会把内容广播给系统剪贴板,大多数主流的、带格式的复制,都会同时写入以下几种 MIME 类型:
text/plain:剥离样式的纯文本,用于不支持的场景或无障碍访问。text/html:带标签和行内样式的 HTML 片段,保留了网页侧的大部分视觉结构。text/rtf:富文本格式,主要在 Office 类文档和邮件客户端之间传递时出现。Files:图片、文件等二进制资源,在复制网页中的图片或截图时会携带。
浏览器在paste事件里把这些数据挂在clipboardData对象上,问题在于:**不同浏览器对各类数据的填充程度不一样。**有的会同时给你 HTML 和纯文本,有的只给纯文本,个别旧版本在粘贴操作里甚至拒绝提供 HTML 数据。这意味着解析器必须学会「看菜下饭」。
1.3 TinyEditor 的整体设计取向
我做的这个 TinyEditor 是个轻量级富文本编辑器,没有用常规的contenteditable那一套直接改富文本,而是维护了一份内部文档模型,视图层根据模型渲染。前面几篇已经处理了核心编辑区的骨架搭建、光标与选区管理、事件系统和撤销栈,这一篇专门讲:外部内容进入编辑器的第一道工序——把剪贴板数据转成内部能理解的结构化文档树。
整体策略定的是「优先读 HTML,回退读纯文本」。为什么不是只读 HTML?因为 HTML 里充满各种外部网页自带的私人记号,直接塞进来会让编辑器变成「样式垃圾场」。为什么不是只读纯文本?因为纯文本方案会丢掉段落结构、列表、链接、加粗这些用户复制时本来就想保留的信息。折中下来,读取 HTML,然后做清洗和结构转换,是大多数轻量编辑器在实际使用中用户最容易接受的做法。用户要的是「粘贴之后基本保持原样,但不要乱」。
2. 剪贴板读取与优先级决策:paste 事件里的第一道分岔口
2.1 拿到 clipboardData 的正确姿势
处理粘贴的第一步,是在编辑区域挂上paste事件监听。这里有一个容易犯错的地方:如果你前面做过键盘事件处理,keydown里按 Ctrl+V 时是不需要自己动手的,浏览器会先触发beforeinput,再触发clipboard相关流程,最后轮到paste。在paste里调用preventDefault()可以阻止浏览器默认的粘贴行为——我们拦下来,自己解析、自己插入。
读取剪贴板数据的核心代码如下:
editor.addEventListener('paste', (e) => { e.preventDefault(); const cd = e.clipboardData; if (!cd) return; const typeArray = Array.from(cd.types || []); const htmlData = cd.getData('text/html'); const plainData = cd.getData('text/plain'); const fileItems = Array.from(cd.files || []); // 决策入口 if (fileItems.length > 0 && isImageFiles(fileItems)) { handleImagePaste(fileItems); return; } if (htmlData && htmlData.trim().length > 0) { processHTMLPaste(htmlData); return; } insertPlainText(plainData); });cd.types在不同浏览器里可能是数组,也可能是类数组,统一用Array.from转一下可以避免后续遍历出错。getData('text/html')返回的是完整的 HTML 字符串,里面带着<html>头、<body>标签,甚至还有<style>块。别慌,后面清洗阶段会一起处理。
有一个细节值得单独说:空格和换行在 HTML 字符串里的表现。外部网页复制来的 HTML,片段通常以<html>或<!--StartFragment-->开头,里面段落之间会有大量空白字符,这些是格式化产物,不是用户看到的排版。解析时要把这些当噪声,而不是当内容,否则渲染出来的文本会多一堆莫名其妙的空行。
2.2 决策逻辑:什么时候走富文本,什么时候走纯文本
我实际跑过的规则是这样的,分为三层判断:
第一层看文件。用户复制了一张截图或一张网图,cd.files里会有图片对象,此时走图片插入分支。注意不要一上来就判断files,因为复制网页文本时,某些浏览器也会把网页里引用的本地图片带上,导致files非空。我的做法是先检查文本数据是否存在且有内容:
function isImageFiles(files) { return files.length > 0 && files.every(f => f.type.startsWith('image/')); }第二层看 HTML 是否「值得走富文本」。有些场景下浏览器虽然提供了 HTML 数据,但内容其实很空洞,比如就一个<br>或者一个什么都不包的空段落,这时候走富文本反而会把空结构插进文档。我用一个简单判断:getData('text/html')去掉标签后的纯文本长度小于 1 个字符,就放弃富文本分支。
第三层才是纯文本兜底。当 HTML 数据缺失或不可用时,用getData('text/plain')转换,按普通文本整体插入,每个换行符直接映射成内部新行。
只有一个浏览器行为需要特别留意:个别浏览器在编辑区失去焦点时,paste事件的clipboardData可能会没有 HTML 数据。这个没有很好的规避办法,只能靠兜底逻辑保证粘贴不死、内容不丢——哪怕最终只落到纯文本分支,也比粘贴失败强。
2.3 图片粘贴的特殊分支
这一篇主题是文本解析,但 paste 过程里图片几乎总是混着来,不处理会导致用户复制图文混排的文章时,图片全部静默丢失。图片分支的逻辑并不复杂:
- 从
cd.files里取出图片文件。 - 检查图片大小,超出阈值的大图先压缩再入库,防止内存暴涨。
- 通过
URL.createObjectURL生成临时地址做预览,后续上传或转 base64 后替换。
实测中唯一让我头疼的是 gif 动图,转 base64 后体积能到几 MB,压缩又把动画丢了。最后在 TinyEditor 里做的取舍是:动图走本地 object URL,不做 base64 内嵌,用户保存时再由后台统一处理。这类问题在文本解析里不会出现,但作为粘贴链路的一环,建议顺手一起设计掉。
3. 标签白名单与样式过滤:把外部 HTML 清洗干净的方案
3.1 为什么要洗:安全和统一
外部网页的 HTML 完全不可信。除了安全因素——恶意脚本、onerror属性、javascript:协议链接——还有一个更日常的理由:网页作者和编辑器作者对「排版」的理解根本不一样。网页侧会用一堆自定义 CSS 类、绝对定位、浮动布局来实现视觉效果,这些信息对编辑器内部文档模型毫无意义,甚至会反过来污染渲染。
安全和统一,这两件事必须在一个清洗函数里一起解决。只过滤脚本不清理类名,编辑器里会残留大片无意义的<span class="xxx">;只清理样式不拦协议,一条javascript:alert(1)的链接就可能让编辑器变成一个脚本执行器。所以清洗函数的核心工作是两件:剔除不允许的标签和属性,保留允许的标签并降级为内部可识别的语义标记。
3.2 白名单设计
我给 TinyEditor 定的标签白名单如下,原则是「只留文档结构和行内语义,不要布局」:
| 分类 | 允许标签 | 理由 |
|---|---|---|
| 块级结构 | P、DIV、H1-H6、BLOCKQUOTE | 边界清晰,适合映射成内部块节点 |
| 列表 | UL、OL、LI | 展开为内部嵌套列表节点 |
| 表格 | TABLE、THEAD、TBODY、TR、TH、TD | 表格比较特殊,后续单独走递归 |
| 行内语义 | STRONG、B、EM、I、A、CODE | 保留加粗、斜体、链接、代码样式 |
| 文本容器 | SPAN | 允许存在,但清空类名和大部分样式 |
| 换行/段落 | BR、HR | BR当软换行,HR当分隔线 |
不在白名单里的标签,比如SCRIPT、STYLE、IFRAME、OBJECT、EMBED、META、LINK、FORM、BUTTON、INPUT,直接移除。注意这里有个决策点:移除还是保留其中的文字内容。SCRIPT和STYLE里的文本不能保留,因为那是代码不是文章;但SPAN、DIV如果被移除,里面的文字必须保留并且向上合并,否则用户复制的内容会丢字。这个「去壳留馅」的逻辑是清洗最容易出 bug 的地方,也是清洗是否专业的试金石。
属性白名单更严格:
- 所有标签:删除
class、id、style、>function sanitizeHTML(htmlString) { const doc = new DOMParser().parseFromString(htmlString, 'text/html'); const allowedTags = new Set(['P', 'BR', 'STRONG', 'B', 'EM', 'I', 'A', 'CODE', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TH', 'TD', 'IMG', 'SPAN', 'DIV', 'HR']); function cleanNode(node) { const children = Array.from(node.childNodes); children.forEach((child) => { if (child.nodeType === Node.ELEMENT_NODE) { const tag = child.tagName; if (!allowedTags.has(tag)) { const parent = child.parentNode; while (child.firstChild) { parent.insertBefore(child.firstChild, child); } parent.removeChild(child); } else { cleanAttributes(child); cleanNode(child); } } else if (child.nodeType === Node.TEXT_NODE) { // 文本节点不做额外处理,但后续需要折叠空白 } }); } cleanNode(doc.body); return doc.body.innerHTML; }DOMParser解析出来的就是标准 DOM 树,递归函数直接操作节点。白名单之外的标签不是简单删除,而是把子节点上抛到父节点位置,这样才能保证文字不丢。3.4 样式哪些留哪些去
样式过滤是我反复调了几轮的地方。第一次我图省事,把所有
style属性全清了,结果用户从在线文档复制来的重点标注全部消失。后来我做了个折中方案:提取少量「语义性视觉属性」,其他全丢。语义性视觉属性主要是这几类:
font-weight大于 500 或等于bold,识别为加粗。font-style: italic,识别为斜体。text-decoration: underline,识别为下划线。background-color或color,可以转成内部标记的背景色/前景色,但只允许保留固定枚举值或合法颜色格式,避免暗藏脚本。
至于
margin、padding、float、position、width、height这些布局属性,一律不要。外部网页里最常见的居中布局用text-align: center,这个我会保留,因为它在语义上等价于「这一段是居中的」,内部模型完全能表达。4. 从清理后的 DOM 到内部文档树:段落拆分、换行归并与深层嵌套
4.1 内部文档树的约定
清洗完成之后,我们手上是一个干净但仍然是标准 HTML 的字符串。下一步是转成 TinyEditor 自己的内部文档树。TinyEditor 的文档树采用以下约定:
- 根节点是一组块级节点,每个块节点对应一个段落、列表、表格、引用块或代码块。
- 块节点内部是行内节点序列,行内节点包含文本、加粗标记、链接、行内代码、图片。
- 所有文本都挂在行内节点里,不允许文本节点直接挂在块节点外,方便渲染层统一处理。
这个约定跟大多数人理解里的 DOM 不同:DOM 是嵌套树,TinyEditor 内部文档模型是「块数组 + 行内数组」的两层结构。外部 HTML 的层级关系需要被拍平重构。
4.2 块级识别的处理顺序
转换的算法不复杂,但处理顺序很讲究。我推荐的做法是:先按块级标签切分,再递归处理块内的行内内容。
第一步遍历
body的子节点,把连续的文本节点和行内元素累积成一个候选块,遇到块级标签就结束当前候选块并开启新块。代码骨架如下:function domToBlocks(root) { const blocks = []; let currentInline = []; const flushInline = () => { if (currentInline.length === 0) return; blocks.push({ type: 'paragraph', children: buildInlineTree(currentInline) }); currentInline = []; }; root.childNodes.forEach((node) => { if (isBlockNode(node)) { flushInline(); blocks.push(transformBlock(node)); } else { currentInline.push(node); } }); flushInline(); return blocks; }第二步处理块内的行内节点,这里有一个常见坑:多个连续行内标签需要合并,但遇到
BR又是换行边界。我的做法是用BR作为分割点,把累积的文本拆成不同的行。buildInlineTree会遍历当前累积的节点,遇到STRONG就记录加粗状态,遇到A就记录链接信息,遇到CODE就标记行内代码,最后生成一行行内节点数组。4.3 遇到的三个典型坑
这几个坑是我在实际项目中跑了很多遍才发现的,值得单独拿出来讲。
坑一:连续块级空结构产生的空段落。外部网页里经常出现
<p><br></p>夹在段落中间,转出来会得到一个空块,渲染后表现为多出一个整行空白。我的过滤器在转块时加了一个规则:块内纯文本为空且不含图片的块直接丢弃。注意是「丢弃空块」而不是「丢弃空行」,因为<br>在段落内制造的是软换行,内部模型可以用行内数组里的换行标志表达,不会真的需要空块。坑二:表格嵌套和结尾 marquee。表格是唯一允许递归嵌套多个块层级的结构,因为表格行里有单元格,单元格里可能还有列表、段落甚至表格。转换时我单独写了
transformBlock的分类分支,对TABLE做深度递归,并用一个栈保存父表格上下文。表格处理最怕退出递归时弄错层级,我在每层递归结束都调用一次flushInline,确保单元格内的内容不会串到下一个单元格里。坑三:
PRE(代码块)的空白保留。普通文本外部 HTML 里的连续空格应该折叠成一个,但代码块里绝对不能折叠。代码块的缩进就是语义的一部分。处理方式是给PRE单独开一个分支,它转换出的块类型是codeBlock,内部文本直接原样保留,不做空白折叠和换行转化。4.4 转出内部树的代码框架
最终转出的内部树会是这样一种结构:
{ type: 'doc', children: [ { type: 'paragraph', children: [ { type: 'text', text: '这是一段' }, { type: 'bold', children: [{ type: 'text', text: '加粗文本' }] }, { type: 'text', text: ',尾部还有一个' }, { type: 'link', href: 'https://example.com', children: [{ type: 'text', text: '链接' }] } ] }, { type: 'heading', level: 2, children: [{ type: 'text', text: '小标题' }] }, { type: 'bulletList', children: [{ type: 'listItem', children: [{ type: 'text', text: '列表项第一项' }] }] } ] }整棵树的转换在几百行的处理函数里完成。一旦得到这个结构,剩下的就只是渲染层的事了:TinyEditor 的更新机制会根据新的文档树生成视图,并且把所有非内部节点全部过滤掉。
5. 不同来源的差异化适配:Office 类文档、图文平台、普通网页三种场景
5.1 风格各异的「脏 HTML」特征
清洗函数有了,不同来源的 HTML 特征仍然五花八门。我收集了一批真实用户提供的粘贴样例,把来源分类整理了一下,这里有跨越多年、没有具体品牌的对比:
来源类型 典型特征 主要问题 处理策略 文字处理软件导出 大量 class="MsoNormal"、命名空间前缀、内嵌样式表标签冗余严重、样式表污染、书签标记一堆 清空所有类名,删除 <o:p>等扩展标签图文平台内容 大量行内样式、 img带懒加载专用属性图片源不在 src而在自定义属性中、段落强制 inline-block图片读取自定义属性兜底,清掉布局样式 普通网页正文 脚本、跟踪代码、随机类名、内联事件 安全风险高、无用节点多 严格白名单过滤,脚本直接移除 富文本邮件客户端 表格排版布局、 bgcolor、cellpadding整封邮件是一个大表格,层级深 表格递归,但外层布局表格识别为块并简化 5.2 最简单的规则:剥离样式、保语义、重映射
针对不同来源,我做了一套统一的「语义重映射」规则,比写一堆特定来源分支更好维护。
规则一:视觉样式优先转语义标签。外部 HTML 里的
<b>转成内部bold,<i>转成italic,<u>转成underline。注意有些来源只会用<span style="font-weight: bold">表达加粗,清洗阶段已经提取出 bold 语义了,这个阶段的入口是一致的。规则二:标题降级映射。外部文章复制过来时,
H1通常保留为内部heading级别1,但很多用户是从正文中间复制的,标题层级可能跳跃——比如直接从一个H3开始。我这边统一在粘贴入口把最高级标题往下压一级,避免一个粘贴片段出现孤零零的H1,导致文档大纲断层。规则三:图片源兜底。部分图文平台复制出来的图片,真正的地址在
>if (window.__DEBUG_PASTE__) { console.log('[paste] raw html:', htmlData.slice(0, 2000)); console.log('[paste] clean html:', sanitized.slice(0, 2000)); console.log('[paste] blocks:', JSON.stringify(blocks, null, 2)); }这段日志只会在调试开关打开时输出,不会打扰普通用户。而 JSON 输出内部树结构,比在 DOM 树里到处打断点直观得多。调试表格嵌套问题时,我甚至会直接输出整棵树的缩进文本,快速定位是哪一层递归出了问题。
6.4 我的几个经验教训
最后一节说几个折腾已久的教训,都是花了不少时间去确认的。
别只信浏览器默认粘贴。早期的 QuickTest 我直接调用
document.execCommand('paste'),完全绕过了自定义解析器,结果粘贴内容带着各种外部脚本原样进入编辑器。后来才明白,execCommand是浏览器层面的行为,根本不经过我们先前提到的数据清洗,所以拦截paste事件并执行自己的解析逻辑是绕不开的。清洗要快,但别贪快。我最初用正则全局替换来删标签,速度确实快,但遇到嵌套标签和引号内特殊字符时经常误杀内容。换成 DOM 递归后效率虽然差点,但正确率大幅提升。实测 2 万字的 HTML 在主流笔记本上解析耗时稳定在 50ms 以内,完全可接受。
别忽略空白的语义。有一个 bug 困扰了我很久:用户从带缩进的代码块复制到编辑器,缩进全没了。排查下来发现是我的空白折叠逻辑作用在了代码块上——普通段落折叠空格没错,代码块不能折叠。从那以后我把代码块的空白保留策略单独做了一个开关。
过一个阶段再回看解析代码。粘贴解析很容易「越改越复杂」,因为每次遇到一个怪异来源就往过滤规则里加一条。过一两个月再回看,你会发现很多规则可以合并,甚至删掉。我现在的习惯是半年左右做一次解析器瘦身,把冗余规则清掉,这段代码的健康度比堆功能时好得多。