每次有运营或者产品同事跑过来跟我说“这个富文本又出问题了”,十有八九都是跟“标签”有关。做后端和前端的同学应该都有体会:项目刚接富文本编辑器的那几个月,基本是在跟 HTML 标签 斗智斗勇——存进去的内容莫名其妙带了一堆样式,导出 PDF 时排版稀烂,粘贴过来的内容带上一堆奇怪节点,更别提图片加载失败、链接点击跳转异常这些问题。
这里说的“标签”,不是浏览器里那个 tabs 标签页,也不是微信小程序的 <标签页组件>,而是富文本编辑器存储和渲染时的 HTML 标签体系。富文本编辑器本身做的事情很简单:把用户输入的富文本内容转成 HTML,或者说转成一组结构化的标签。可是一旦这套标签进入后端、再被不同端(Web 端、App、小程序、甚至邮件)渲染,问题就开始排队出现了。
这篇文章我打算把“富文本编辑器里的标签”这件事按我这些年实际踩坑的顺序拆开讲:从编辑器到底输出什么标签、为什么不能直接信任这些标签,到怎么清洗、怎么二次加工、怎么在前端安全渲染,再到图片、链接、PDF 下载这些高频疑难场景的应对。适合正在做 CMS 后台、BBS、工单系统、笔记应用,或者任何打算在项目里接入富文本编辑器的人,尤其是刚接手相关内容、正在排查各种诡异 bug 的同学。
1. 先弄清楚编辑器输出的“标签”到底是什么来路
1.1 同样是编辑框,各家生成的 HTML 完全不一样
不要以为富文本编辑器就是把文字变“富”而已。现在的编辑器底层大概分两类:一类基于 contenteditable,另一类是基于视图模型 + 自定义渲染。
基于 contenteditable 的老牌编辑器,用户输入什么浏览器行为就产生什么 DOM,比较接近你在网页上直接看到的内容。这类编辑器生成的标签风格比较“原始”,比如加粗可能直接用 <b>,也可能用 <strong>,颜色可能用 <font color="">,也可能是一堆内联 style="color: rgb(...)"。而后来出现的 Quill、TipTap、Slate 这些模型驱动的编辑器,会把内容转成自己的 Delta 或 JSON 结构,渲染时再定制生成标签。
同样是“粘贴一段来自网页的文字”,在老的编辑器里可能直接带进来 <div>、<span>、<a>,还有一堆内联样式;在某些编辑器里会被规范成 <p> 和 <strong> 这种干净结构。这不只是观感问题,后端拿到这些标签后是否安全、是否好渲染、是否能通过小程序端的富文本组件解析,都受影响。
我在对接后台系统时遇到过一个很常见的情况:编辑在后台用老编辑器写了一篇文章,里面用了 <table> 来实现排版,保存没问题,但用户端是 App 内嵌的 WebView,或者某些富文本原生组件,表格解析差,直接变成一堆乱码。这种现象背后是标签模型的差异,靠“换个前端组件”补不回来,必须从标签源头处理。
1.2 一个高频误区:标签只是“展示格式”吗
很多产品经理会觉得,用户加粗、变红、插个图,最终存的就是“带格式的文字”。但富文本存下来的标签其实是“可执行内容”。尤其当你用页面渲染富文本内容时,如果直接 v-html 或 dangerouslySetInnerHTML,这些标签不只是排版,它们还携带脚本、事件、资源加载。
例如用户粘贴来源不明的文本时,有可能带进带事件属性的标签,或者用 <iframe> 引用外部页面,又或者用 <meta>、<link> 这类标签去试图影响页面。在 Web 前端做“富文本展示”时,这种标签一旦被浏览器执行,轻则样式错乱,重则出现弹窗、跳转、页面被篡改等问题。这不是危言耸听,后台审核系统里最常被钻的空子就是“富文本内容里的标签没清洗”。
这里有个观念需要先纠正:富文本编辑器的标签输出,不是“存储格式”的终点,而是“安全过滤”的起点。编辑器负责给用户创作体验,后端负责把标签审一遍,前端渲染时再按业务规则加工一遍。任何时候都不建议直接把编辑器生成的 HTML 当成“安全可信数据”存库,更不建议把库里原样吐出的 HTML 直接渲染给终端用户。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标签清洗怎么做:黑名单思路一定会漏
2.1 为什么只过滤 script 不靠谱
很多业务系统一开始处理用户提交的富文本,采用的是“黑名单”思维:把 <script>、<iframe>、onclick 这类危险标签和属性删掉,剩下的放行。听起来合理,但它有非常现实的问题:HTML 语言的组合方式太多,标签的属性组合、嵌套方式、编码方式都可以绕开简单过滤。
比如你以为删掉了 <script>,但用户粘贴的内容里可能有 <img src=x onerror=...>,这类标签在图片加载失败时会触发事件逻辑;你以为过滤了 javascript: 开头的内容,但超链接的 href 可能写成大小写混合或编码形式,照样被浏览器解析执行。黑名单维护成本很高,你今天堵住一个写法,明天可能冒出新的组合。
核心思路应当换成白名单:只放行你明确允许渲染的标签和属性,其他全部剥离。这样就算哪天新增了一个危险的标签写法,只要它不在白名单里,一样会被干掉。
2.2 一套实用的白名单清洗配置
目前业界比较标准的做法是使用 DOMPurify,它本身就是一个基于白名单的 HTML 标签清洗库。你不用自己手写正则去匹配标签(正则解析 HTML 本身就是坑)。配置时可以按项目需要,声明哪些标签和属性需要放行。
javascript复制import DOMPurify from 'dompurify';
const allowedTags = [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'sub', 'sup',
'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'a', 'img', 'span', 'div', 'table', 'thead', 'tbody', 'tfoot',
'tr', 'th', 'td', 'hr', 'figure', 'figcaption'
];
const allowedAttrs = [
'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height',
'class', 'colspan', 'rowspan', 'data-id', 'data-type'
];
function cleanHtml(dirtyHtml) {
return DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|data):/i,
});
}
上面这份配置里有两个细节值得说。第一,我只给了 a 标签的 href、target、rel,没有放行 style 属性;如果你希望保留文字颜色、字体大小这些基础排版,可以用允许的 span 配合 CSS 类名方案,而不是直接放行“任意内联样式”,否则清洗等于没洗,用户随手粘贴的外站内容里那一大坨 style 会把你整个页面样式全部带偏。第二,ALLOWED_URI_REGEXP 这块限制了链接和图片资源的协议,防止 javascript: 这类伪协议出现在 href 里。
2.3 粘贴自 Word / 外站内容时的标签灾难
富文本内容里最脏的标签来源,不是用户故意攻击,而是他们从 Word、WPS 或别人的网站直接粘贴内容。这类粘贴内容经常带着微软办公软件特有的 <o:p> 节点、<!--[if gte mso 9]> 条件注释、大量内联 style、甚至内嵌的 v:shape、w:... 命名空间标签。如果不清理,页面会被莫名撑出各种缩进和边框。
处理这种情况可以采用“两步走”:第一步用编辑器自身的粘贴拦截逻辑,在用户粘贴进去时先尝试转成纯文本或者净化后的 HTML;第二步入库前仍要做一次服务端清洗。不能只信任前端。后端起过滤作用更重要,因为前后端校验都可能被绕过,必须由服务端做最终把关。
这里补充一个我常用的思路:白名单过滤不是把内容“删到看不出原样”,而是先解析成 DOM 树,保留可允许的标签,对不允许的标签做降级处理。例如 <font color="red"> 标签可以转成 <span class="text-red"> 这类由自己定义的标记,块级标签如果不在允许范围内,根据内容类型判断是替换成 div 还是 p,防止一段内容被强行捂在一个没有语义的节点里。
3. 高频场景实战:图片标签、A 标签、危险资源标签
3.1 图片标签加载失败、点击放大、防盗链的连环坑
富文本里最常打交道的就是 <img> 标签。先说加载失败的情况。编辑器里用户上传的图片地址,等过几个月可能就失效了,后端返回 404 时,前端图片会显示一个小裂图,很难看。搜索引擎里有人搜“img标签图片加载失败的”处理,说明这个问题很普遍。
从标签处理角度,有两个关键点:
- 不要写
src="xxx" onerror="..."这种行内事件。且不说内联事件破坏了安全白名单策略,如果后端清洗时把onerror属性剥掉了,等于没写。 - 前端的兜底建议用监听容器内所有
img的 error 事件去做统一处理,或者使用全局事件捕获。
javascript复制document.querySelector('.rich-text-content').addEventListener('error', function (e) {
const target = e.target;
if (target.tagName === 'IMG') {
target.src = 'https://example.com/placeholder.png';
target.classList.add('img-error');
}
}, true);
如果要支持“点击图片跳出图层预览大图”,同样不建议把 onclick 绑进标签属性里。更稳妥的方法是在渲染后,给富文本容器做事件委托,统一处理图片点击。事件委托只需要监听容器本身,判断当前点击的节点是不是 img 或 a 元素即可,这样无论内容后续怎么变,预览逻辑都能用。
处理图片防盗链时,如果你的图片站开启了 referrer 校验,图片标签的 src 即使正确也可能加载失败。常见方法是给富文本容器内图片统一加上 referrerpolicy="no-referrer" 属性,但这又涉及属性白名单——如果后端清洗时没有放行这个属性,就不会生效。所以要在清洗规则里为 img 标签预设好合法属性:src, alt, title, data-src 等,不要把 referrerpolicy 忘记了,很多团队在这里踩坑。
3.2 a 标签下载 PDF:iOS 上为什么会变成预览
有同事问过我:“后台富文本里放了个文件下载链接,PC 上点击能下载,iPhone 上点开却在浏览器里预览 PDF,怎么办?”这背后的区别在 <a> 标签的 download 属性上。
桌面浏览器对同源文件的 download 属性通常比较尊重,点击就会触发下载。但 iOS Safari 对 download 属性的支持有限,尤其是 PDF 这类浏览器自己能预览的文件格式,往往会优先在页面里打开预览。这是浏览器产品的既有行为,不完全是标签写错的锅。
如果你在产品上确实希望实现“不管什么设备,都强制下载”而不是预览,比较常见的方案是不直接让浏览器跳转,而是用 JavaScript 请求文件内容,转成 Blob 后通过 URL.createObjectURL 创建临时下载链接再触发下载。对 PDF 来说,还需要设置 MIME 为 application/octet-stream,避免浏览器识别成 PDF。
javascript复制async function downloadFile(url, filename) {
const res = await fetch(url, { mode: 'cors' });
const blob = await res.blob();
const objectUrl = URL.createObjectURL(new Blob([blob], { type: 'application/octet-stream' }));
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename || 'file.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
但在实际项目中,我不会把这段逻辑用内联方式塞进富文本里的每个 a 标签,而是在整个列表或详情页里做事件委托。找到富文本容器内点击的标签,判断是否是指定类型的下载链接,是的话就拦截默认跳转,改走下载逻辑。这样后端的标签清洗规则可以保持白名单纯净,下载行为是业务层添加的,而不是从用户内容里直接读取的。
如果产品允许“预览”和“下载”两种行为,可以在富文本里约定 data-type="preview" 或 data-type="download" 这类自定义属性,然后由渲染端去决定行为。自定义属性也需要同步加进清洗白名单里,否则存的时候好好的,编辑再保存一次,data-type 属性被洗掉了,功能就悄悄失效。
3.3 iframe、meta、link 这类资源型标签怎么处理
很多富文本编辑器会在“插入视频”功能里生成 <iframe>,比如插入哔哩哔哩视频或腾讯视频。iframe 标签本身并不适合直接放在用户可编辑内容里,因为它可以指向任意 URL,加载任意页面,如果宽高设置不合理还会把布局撑爆。
对于有视频插入需求的场景,我会采用“占位 + 白名单单独放行”的方式:后端清洗规则默认不放行 iframe,只允许站内维护的视频域名地址,然后在前端渲染时看到这类 iframe 再做响应式处理。
javascript复制const ALLOWED_VIDEO_HOSTS = [
'player.bilibili.com',
'v.qq.com',
'www.youtube.com'
];
function sanitizeIframeSrc(src) {
try {
const url = new URL(src);
return ALLOWED_VIDEO_HOSTS.includes(url.hostname);
} catch (e) {
return false;
}
}
同时,标签清洗时如果解析到 <meta>、<link>、<base>、<form>、<input> 这类标签,我建议直接在白名单以外统统剥离。这些标签在富文本编辑场景里几乎不会主动使用,基本都是外部内容带进来的,保留下来只会带来干扰。尤其 <base> 会影响页面根路径,一旦某个页面设置了错误的 base 路径,页面上的所有相对链接都会错乱,排查起来相当浪费时间。
4. 渲染侧标签处理:Vue3/React 里的安全渲染与样式管理
4.1 v-html 渲染富文本时,事件绑定怎么做才不怕误伤
前端框架渲染富文本,最简单的确是用 v-html 或 dangerouslySetInnerHTML。但你要清楚,渲染出来的这些标签是“静态字符串解析出来的”,直接用 v-html 不会触发框架的事件绑定。很多人第一反应是想给富文本里的按钮或标签“绑定 onclick”,这不是框架的干活方式,正确的做法是由容器做统一事件代理。
比如 Vue3 里可以这样:
vue复制<template>
<div
class="rich-text-content"
v-html="cleanContent"
@click="handleContentClick"
></div>
</template>
<script setup>
function handleContentClick(e) {
const link = e.target.closest('a[data-type="download"]');
if (link) {
e.preventDefault();
downloadFile(link.href, link.dataset.filename);
return;
}
const img = e.target.closest('img');
if (img) {
openImagePreview(img.src);
}
}
</script>
事件委托的好处是:HTML 标签本身不变,业务行为全部在渲染侧通过类名或 data 属性识别。这也解释了为什么前面在清洗白名单里要保留 data-* 属性,因为标签上如果没有任何标记,渲染层很难知道“这张图点了要放大”还是“这段文字只是装饰”。
不建议在富文本标签里用内联 onclick 做复杂业务,你测试时可能没问题,但一旦内容经过后端存储、转义、展示多个环节,内联事件会被转掉或者被浏览器安全策略拦掉,到时候要排查的问题就更多了。
4.2 全局样式冲突:富文本里的标签为什么会长得“不像后台的样子”
富文本里的标签渲染时,其实是嵌套在你项目的大环境里的。很多项目的全局 CSS 对 p、ul、table 这类标签做了重置,比如 p { margin: 0; }、img { max-width: 100%; },这在多数情况下没问题。但如果你的项目用了某个 UI 框架的 reset,富文本内容里的标题、段落、表格很可能被全局样式误伤。
这类问题的常见表现是:后台编辑时看到的效果是好好的,发布到前台就丑了很多,字号不对、列表没缩进、表格边框消失。排查思路不是去富文本内容里加一堆内联 style,而是在富文本容器类名下写一套独立的样式规范。
比较推荐的做法是给富文本容器一个专属类名,比如 .rich-text-content,然后在这个作用域下定义一套基础排版规则:
css复制.rich-text-content p {
margin: 0 0 1em 0;
line-height: 1.8;
font-size: 16px;
}
.rich-text-content img {
max-width: 100%;
height: auto;
}
.rich-text-content table {
width: 100%;
border-collapse: collapse;
}
.rich-text-content td,
.rich-text-content th {
border: 1px solid #ddd;
padding: 8px;
}
如果项目里必须修改富文本内部内容的样式,但用的是 scoped 样式或 CSS Modules,记得给容器加上 :deep() 或 :global 才能命中编辑器生成的标签,否则样式根本不会进去,容易又排查半天。
5. 富文本编辑器选型时如何权衡标签能力和扩展需求
5.1 核心原则:先想清楚你的内容里究竟允许多少标签
搜索引擎热词里有一些“能实现 echarts 展示的富文本编辑器”,说明不少人想把图表、复杂组件塞进富文本里。这背后其实是需求边界问题:如果用户只是写文章、记录笔记、发公告,那富文本里允许的标签应当以文本样式为主,而不是把前端组件能力塞进去。
富文本编辑器允许的标签越多,清洗、渲染、多端适配的复杂度就越高。你在编辑器页面拖一个图表进去看着简单,等到内容需要在小程序里渲染时,图表标签怎么解析?到 PDF 导出时用什么组件渲染?这些都是在标签层面积累的债。
我个人建议:文档内容要“富”,但标签范围要克制。基础排版标签如标题、段落、列表、引用、表格是富文本的核心,放行它们没有问题。图表、音视频、附件这类能力,更适合用“区块占位”的方式处理——编辑器里显示一个可拖拽改动的占位块,保存时存一个 JSON 结构或者单独的表,而不必硬塞进 HTML 标签字符串里。
5.2 如果必须插入自定义组件:占位标签应如何设计
有些产品确实希望在富文本里插入业务组件,比如商品卡片、投票、地图、图表等。直接把 <div id="chart1"> 这种裸标签放进 HTML 再让前端去挂载图表,往往不可靠,因为富文本内容可能被复制、清洗、二次编辑,DOM 节点与初始化脚本之间的关系很容易断。而且文档字符串也不适合承载业务对象 ID 这种状态。
可以设计一个私有属性节点方案:新增标签时在后端清洗规则里放行 div[data-widget="chart"],并把图表所需的数据 ID 放在 data-* 属性里,前面例子里的 data-id 就是为了这种场景预留的。渲染侧拿到内容后,先渲染 HTML,再扫描容器里带 data-widget 的节点做组件挂载。
javascript复制const widgetNodes = document.querySelectorAll('.rich-text-content [data-widget]');
widgetNodes.forEach((node) => {
const widgetType = node.dataset.widget;
if (widgetType === 'echart') {
const chart = echarts.init(node);
chart.setOption(getWidgetOption(node.dataset.id));
}
});
这种方案保证了编辑器存储的仍是可清洗的 HTML 标签,业务组件信息只是附在标签属性里。内容不管贴到哪个端,解析标签时至少不会报错;遇到不支持交互式渲染的端,还能降级显示占位说明文本。
6. 富文本标签处理常见问题速查与经验盘点
6.1 典型问题对照表
下面这些是“富文本编辑器 + 标签”场景中我很高频遇到的问题,我做成了速查表,方便排查时快速定位:
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 用户粘贴内容后页面布局被冲乱 | 外部 HTML 带入了大量内联样式和冷门标签 | 粘贴时预处理,入参时再白名单清洗 |
| 后台编辑正常,前台样式错乱 | 前台没有全局加载对应的富文本标签样式 | 为富文本容器建立专属样式作用域 |
| 图片外链偶尔打不开 | 图片来源站启用了防盗链,或需要 referrer 策略 | 清洗时放行 img 的 referrerpolicy 属性,或在容器层统一设置 |
| 点击 PDF 链接变成了预览而不是下载 | iOS Safari 对 download 属性支持有限 | 用 Blob + application/octet-stream 方式做下载,或事件委托处理 |
| 偶尔弹出广告或出现奇怪跳转 | 富文本 HTML 未经过有效的白名单清洗,混入危险标签/属性 | 后端接入 DOMPurify 白名单清洗,不允许的标签一律剥离 |
| 后台编辑时看到的视频,发布后就见不到了 | 编辑器生成的 iframe 被清洗掉了 | 如果需要保留视频,单独放行可信域名 iframe,并做响应式适配 |
| 排查内容问题时改动无效果 | 浏览器缓存了旧的静态资源 | 开发时 F12 打开 Network,勾选 Disable cache,再复测 |
| 小程序端富文本显示不全 | 部分富文本标签小程序组件不支持 | 要么限制编辑器输出标签,要么在小程序端扩展现有组件解析规则 |
| 用户保存后再打开编辑,格式“变少”了 | 保存时后端清洗把某些标签或属性剥掉了 | 检查清洗白名单和编辑器粘贴内容是否需要提前转换 |
| Vue 渲染富文本后点击内部元素不触发事件 | v-html 渲染的内容不能直接用框架事件指令 | 在容器上做事件委托,事件抛给目标元素处理 |
6.2 真实工作中最容易被忽略的 3 个细节
第一个细节:清洗后的标签必须保持一致,不能前端清洗一套、后端清洗一套。前端可以为了交互体验过滤脏内容,但后端也要做同样的规则,否则就会出现“编辑器里看起来没问题、接口返回的数据也干净,但旧数据拿出来却复现了问题”这种灵异 bug。根因往往是线上还有一条老接口没有做清洗,历史数据一直是脏的。
第二个细节:富文本标签中涉及到的 “class” 也要有白名单。举个例子,富文本内容提交到页面后,需要支持“文字红橙黄绿青蓝紫”七种颜色,用 span class="red" 这种方案时,如果清洗规则把 class 属性杀了,颜色也就没了。这时要么把自定义类名加进白名单,要么用自定义标签转换逻辑把它们替换成 style="color: red"。我在做内容发布系统时统一约定为 class 方案,因为这样如果前端样式有调整,改一份 CSS 就够了,不需要重算所有历史内容。
第三个细节:排查富文本问题时,把本地环境改成“禁用缓存”状态。富文本编辑器资源通常包含一系列 JS 文件,静态资源被浏览器缓存后,你以为是代码改动没生效,折腾半天结果是缓存问题。浏览器 DevTools 的 Network 面板里有 Disable cache 选项,调试时直接勾上,省很多时间。这看起来和设备标签没什么关系,但排查富文本编辑问题的频率非常高,属于前端人的止痛药。
7. 富文本标签处理后续可以怎么扩展
如果你已经用白名单清洗控制住了安全边界,下一步可以考虑给内容标签增加版本化处理。富文本编辑器的标签结构会随着编辑器版本升级而变化,今天用的编辑器升级了,可能新增了 mark 标签,或者把加粗从 <b> 改成了 <strong>。旧内容如果允许系统自动升级,最好增加“历史内容重清洗”的任务,而不是让旧内容一直带着旧标签结构运行。
另外,搜索场景也需要关注标签语义。富文本内容入库之后,如果搜索时是直接对 HTML 内容做模糊匹配,怎么保证搜索结果不把一堆标签字符串匹配出来?这个问题的处理往往不是在前端渲染层,而是在存储层单独抽一份“纯文本内容”列,专门用于搜索、摘要、检测等非展示场景。这个纯文本列可以在内容保存清洗时同步生成,把允许的标签依次解析成文本,加上分隔符之后存下来。
标签处理不会因为功能上线就结束,内容越存越多,清洗规则会慢慢演进,新需求还会不断挑战现有的标签体系。保持清洗规则、渲染规则、前后端白名单的一致和可维护,是富文本功能长期稳定的关键。这套东西一旦成型跑顺,后面再加新功能基本就是加标签和加样式的活,不会再是让人头疼的坑。
