先说结论:接口返回的字符串里带 HTML 标签,前端插值后显示成尖括号,是因为默认走的是文本插入,浏览器把标签当成普通字符。想让它按样式渲染,就得显式走 HTML 插入,并且插入前必须做一次消毒。

这个现象几乎每个刚做富文本展示的人都会遇到:后端把编辑器内容存成一段带标签的字符串,前端用双花括号或 textContent 塞进页面,页面上出现的是 <p>你好</p> 这样一整串原文。后端说数据没问题,前端说渲染有问题,其实两边都没错,错在插入方式。
本文先复现这个现象,再给出三条可用路径与各自的代价,重点讲清楚什么时候该渲染、什么时候不该渲染,最后给一份消毒与验证清单。示例用原生 JavaScript 演示,框架里的对应写法也会一并标出。
先看结论
- 显示成尖括号不是 bug,是文本插入的正常结果。想渲染就得换插入方式。
- 三条路径:保持文本插入(展示代码、用户输入原文)、innerHTML 类渲染(富文本内容)、解析后按白名单重建(不可信来源)。
- 只要内容不是自己系统产出的,渲染前必须消毒,否则等于给别人在你的页面上执行脚本的机会。
标签与文本节点的关系理不清时,可以翻 HTML 教程 把元素与节点的对应关系过一遍。
一、最小场景与预期行为
把任务定死:接口返回一个字段 content,值是 <p>欢迎 <strong>张三</strong></p>,页面上有两个位置要展示它——一个按样式渲染,一个把标签原文显示出来。
输入是这个字符串。预期行为是:渲染位显示加粗的"欢迎 张三",原文位显示 <p>欢迎 <strong>张三</strong></p> 这串字符本身。
const content = '<p>欢迎 <strong>张三</strong></p>';
const boxRender = document.querySelector('#render');
const boxRaw = document.querySelector('#raw');
boxRender.innerHTML = content; // 按样式渲染
boxRaw.textContent = content; // 显示标签原文
这段做的是:同一个字符串,用 innerHTML 写入时浏览器会解析 HTML 标签并构建元素节点,用 textContent 写入时浏览器只创建一个文本节点,尖括号保持字面量。
判断标准就一句话:你要的是元素还是字符。要元素用 innerHTML,要字符用 textContent。
二、最稳的做法:先判断该不该渲染
真正容易出事的不是"怎么渲染",而是"该不该渲染"。按内容来源分三类:
- 自己系统的富文本编辑器产出:可以渲染,但仍建议消毒,防止编辑器被绕过。
- 第三方接口、爬虫、用户提交:必须先消毒再渲染,或者干脆不渲染。
- 要展示的代码片段:绝不渲染,用 textContent 配合代码块样式。
下面这段是不消毒直接渲染会发生的典型事故:
const bad = '<img src=x onerror="alert(document.cookie)">';
box.innerHTML = bad; // 图片加载失败,onerror 里的脚本被执行
这段做的是构造一个加载必然失败的图片标签,浏览器触发 onerror 后执行其中的脚本。真实攻击不会弹窗,而是把会话凭证发到攻击者的服务器。
这里的信息增量是:危险不只来自 script 标签。onerror、onload、onclick 这类内联事件属性,以及 javascript: 协议的地址,同样可执行脚本。只过滤 script 标签是不够的。
三、三种方法的实现与取舍
| 方法 | 适用场景 | 代价 |
|---|---|---|
| textContent | 展示代码、用户输入原文 | 标签不生效,样式需要自己加 |
| innerHTML 直接渲染 | 完全可信的静态内容 | 有 XSS 风险,绝不用于外部内容 |
| 消毒后再渲染 | 富文本、第三方内容 | 需要引入消毒步骤与白名单 |
3.1 方法一:保持文本插入
box.textContent = content;
这段做的是把整串内容当纯文本写入,尖括号被转义成实体字符后正常显示。它的价值不只是"显示原文",更在于它是唯一没有脚本执行风险的插入方式。
配合 CSS 的 white-space: pre-wrap 可以保留换行,配合等宽字体就成了代码块。展示日志、代码片段、用户填的备注,都应该走这条路。
3.2 方法二:innerHTML 渲染
box.innerHTML = content;
这段做的是让浏览器解析字符串并构建 DOM 节点。框架里的等价写法是 Vue 的 v-html、React 的 dangerouslySetInnerHTML——名字里的 dangerous 就是提醒。
用之前先回答两个问题:内容是不是自己系统产出的?产出环节有没有可能被绕过?两个都答"是/不会"才可以用。
3.3 方法三:消毒后再渲染
自己写白名单过滤的可行性很低,属性、协议、编码变形的组合太多。工程上的做法是用专门的消毒库:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'li', 'br'],
ALLOWED_ATTR: ['href', 'title']
});
box.innerHTML = clean;
这段做的是:先用消毒库按白名单清洗标签与属性,再把清洗结果写入页面。ALLOWED_TAGS 之外的标签被移除,ALLOWED_ATTR 之外的属性(包括所有 on* 事件属性)被剥掉。
预期结果(未在本机执行,依据 DOMPurify 的白名单行为)是 <script> 与内联事件属性被清除,p、strong 等标签保留。
如果不想引入依赖,退而求其次的做法是用 DOMParser 解析后遍历节点,手动移除不在白名单里的标签与所有 on* 属性:
const doc = new DOMParser().parseFromString(content, 'text/html');
doc.querySelectorAll('*').forEach((el) => {
[...el.attributes].forEach((a) => {
if (a.name.startsWith('on') || a.value.startsWith('javascript:')) {
el.removeAttribute(a.name);
}
});
if (!['P', 'STRONG', 'EM', 'A', 'BR'].includes(el.tagName)) {
el.replaceWith(...el.childNodes);
}
});
box.innerHTML = doc.body.innerHTML;
这段做的是:先用 DOMParser 把字符串解析成一棵离屏的 DOM 树,遍历每个元素,剥掉事件属性与 javascript: 协议,再把不在白名单里的元素替换成它的子节点(只脱壳、不丢文字)。注意 DOMParser 解析出的文档不会执行脚本,这是它比直接 innerHTML 安全的原因。

四、常见失败与验证清单
4.1 标签被转义了两次
后端存的时候转义过一次,前端渲染时又转义一次,页面上出现 <p> 这样的字样。修复办法是确认转义只发生在输出环节,存储环节保持原始 HTML。
4.2 渲染后样式全丢了
富文本里的 class 被白名单剥掉了,或者页面用了 CSS Modules 导致类名对不上。修复办法是在白名单里保留 class,并在渲染容器外层写一套针对富文本的样式,而不是依赖原站点的类名。
4.3 图片加载不出来
消毒时 ALLOWED_ATTR 没放开 src,或者站点配了 CSP 限制了图片来源。修复办法是把 src 加进属性白名单,并确认 CSP 的 img-src 包含图片域名。
4.4 验证清单
- 检查:确认内容来源,自己产出还是第三方
- 运行:先用 textContent 输出一次,确认原始数据确实带标签
- 运行:再切到消毒加 innerHTML,对比两次结果
- 预期:标签生效、脚本类内容被清除、控制台无报错
- 失败:页面出现尖括号原文 → 仍走的是文本插入
- 修复:确认写入的是 innerHTML 且赋值发生在数据返回之后
- 验证:用一段含 onerror 的测试串打一遍,确认脚本没有执行
上面这些步骤做的是确认数据来源、跑通两种插入方式、用攻击样例验证。预期结果(未在本机执行)是攻击串里的脚本不执行;若执行了,说明消毒步骤没生效或被绕过。

总结
后端返回的 HTML 标签显示成尖括号,根因是插入方式走了文本插入。要渲染就换 innerHTML 类方式,但内容不可信时必须先消毒,白名单之外一律清除。
三条要点带走:
- 先判断该不该渲染,再决定怎么渲染,顺序不能反;
- 危险不只来自 script 标签,on* 事件属性和 javascript: 协议同样能执行脚本;
- 消毒优先用成熟库,自己写正则过滤几乎一定会被绕过。
下一步建议在项目里约定一条规则:所有富文本渲染必须经过同一个 sanitize 函数,禁止在业务代码里直接写 innerHTML。节点操作细节记不清时,翻 JavaScript 教程 最快。
常见问题
Q:为什么后端说数据没问题,页面还是显示尖括号?
A:数据确实没问题,是插入方式的问题。用 textContent 或模板插值写入时,浏览器只创建文本节点,标签不会被解析。
Q:渲染后端返回的 HTML 标签一定会有安全风险吗?
A:取决于内容来源。完全由自己系统产出且不可被外部改写的,风险可控;第三方或用户提交的内容必须先消毒。
Q:只过滤 script 标签够不够?
A:不够。img 的 onerror、a 标签的 javascript: 协议、svg 里的事件属性都能执行脚本,必须用标签加属性的完整白名单。
Q:Vue 和 React 里对应的写法是什么?
A:Vue 用 v-html,React 用 dangerouslySetInnerHTML。两者的语义都等同于 innerHTML,一样需要先消毒。
Q:消毒之后样式全没了怎么处理?
A:白名单里放开 class,然后在渲染容器外层单独写一套富文本样式,不要依赖原站点的类名体系。
延伸学习
想把富文本渲染这块补完整,可以按这个顺序来:
- 读一篇相关笔记 HTML 与 CSS 实现轮播图,把标签、样式与脚本的分工看清;
- 想系统补语法,Javascript Guide 教程 覆盖了从基础到进阶;
- 想确认元素与节点的对应关系,翻 Html5 教程 里的结构章节。

TRAE-AI编程



