后端返回的 HTML 标签怎么正常显示(附 3 种方法)

编程狮(w3cschool.cn) 2026-10-21 07:08:09 浏览数 (20)
反馈

先说结论:接口返回的字符串里带 HTML 标签,前端插值后显示成尖括号,是因为默认走的是文本插入,浏览器把标签当成普通字符。想让它按样式渲染,就得显式走 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。

二、最稳的做法:先判断该不该渲染

真正容易出事的不是"怎么渲染",而是"该不该渲染"。按内容来源分三类:

  1. 自己系统的富文本编辑器产出:可以渲染,但仍建议消毒,防止编辑器被绕过。
  2. 第三方接口、爬虫、用户提交:必须先消毒再渲染,或者干脆不渲染。
  3. 要展示的代码片段:绝不渲染,用 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 安全的原因。

该不该把后端返回的 HTML 渲染出来?

四、常见失败与验证清单

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,然后在渲染容器外层单独写一套富文本样式,不要依赖原站点的类名体系。

延伸学习

想把富文本渲染这块补完整,可以按这个顺序来:

  1. 读一篇相关笔记 HTML 与 CSS 实现轮播图,把标签、样式与脚本的分工看清;
  2. 想系统补语法,Javascript Guide 教程 覆盖了从基础到进阶;
  3. 想确认元素与节点的对应关系,翻 Html5 教程 里的结构章节。

0 人点赞