深入解析 dangerouslySetInnerHTML 的使用风险与安全实践
又踩坑了,dangerouslySetInnerHTML 到底怎么用才安全?
上周改一个老项目,后端直接返回带 HTML 的富文本内容,我第一反应就是用 dangerouslySetInnerHTML。结果 QA 一测,说 XSS 漏洞告警了。唉,这玩意儿名字里就带着“危险”两个字,真不是白叫的。但不用它,又没法渲染富文本——总不能让用户看到一堆 <p> 标签吧?
于是我把手头能用的方案都试了一遍,今天就来唠唠:在 React 里处理动态 HTML,到底哪个方案更靠谱、更省心、更少踩坑。
谁更灵活?谁更省事?
先说结论:如果内容完全可控(比如 CMS 后台你亲自写的),dangerouslySetInnerHTML 直接上,快且简单。但只要内容来自用户输入、第三方接口,或者你不确定来源,那就别偷懒,必须加过滤或换方案。
我试过三种主流做法:
- 原生
dangerouslySetInnerHTML(裸奔) - 配合 DOMPurify 做 XSS 过滤
- 彻底不用 innerHTML,改用 react-html-parser 或类似库
核心代码就这几行
先看最原始的写法,也是最容易出事的:
// 危险!千万别直接这么用
function RawContent({ htmlString }) {
return <div dangerouslySetInnerHTML={{ __html: htmlString }} />;
}
这段代码跑起来没问题,但如果 htmlString 是 <img src=x onerror=alert(1)>,那恭喜你,XSS 成功。我第一次上线就这么干过,被安全团队追着问了三天。
后来学乖了,加个 DOMPurify:
import DOMPurify from 'dompurify';
function SafeContent({ htmlString }) {
const cleanHTML = DOMPurify.sanitize(htmlString);
return <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />;
}
DOMPurify 会自动过滤掉 script、onerror、javascript: 等危险内容,亲测有效。而且它支持自定义配置,比如允许某些标签(如 iframe)或属性(如 class)。我现在的项目基本都这么用,除非有特殊需求。
第三种方案,完全避开 innerHTML,用解析器转成 React 元素:
import parse from 'html-react-parser';
function ParsedContent({ htmlString }) {
return <div>{parse(htmlString)}</div>;
}
这个方案看起来最“React”,但实际用起来有点别扭。比如样式丢失、嵌套复杂时性能差,而且有些 HTML 结构它处理不了(比如自闭合标签不规范)。我试过一次,最后还是切回了 DOMPurify + dangerouslySetInnerHTML。
踩坑提醒:这三点一定注意
第一,DOMPurify 不是万能的。它默认只保留安全的标签和属性,如果你的富文本需要保留 style 或 class,得手动配置。比如:
const cleanHTML = DOMPurify.sanitize(htmlString, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'style']
});
但注意,一旦允许 style,就可能被注入 style="background: url(javascript:alert(1))" 这种,所以最好再加一层 CSS 过滤,或者干脆禁用 style。
第二,react-html-parser 在 SSR 下可能出问题。因为它内部用了 DOM API(比如 document.createElement),在 Node.js 环境里会报错。虽然有 workaround,但多一事不如少一事。我有个项目从 CSR 改 SSR,就因为这个被迫重构。
第三,别以为“内容来自后端就安全”。我们有个接口是从第三方抓取文章,后端没做清洗,结果某天返回的内容里藏了 <svg onload=...>,前端直接中招。所以无论数据源在哪,只要不是你 100% 控制的,一律当“脏数据”处理。
我的选型逻辑
现在我基本按这个流程走:
- 如果内容是我自己写的静态 HTML(比如帮助文档、公告),直接
dangerouslySetInnerHTML,不加过滤,图个快。 - 如果内容来自用户或不可信源,必加 DOMPurify,配置好允许的标签和属性。
- 如果项目对 SEO 要求高,或者需要深度控制渲染结构(比如把所有链接换成
<Link>组件),才会考虑 html-react-parser,但会提前压测性能。
说实话,90% 的场景 DOMPurify + dangerouslySetInnerHTML 就够了。它快、稳定、兼容性好,而且社区维护活跃。我宁愿多花 5 分钟配过滤规则,也不想折腾那些“纯 React”的解析方案。
另外,别忘了在构建时加个 ESLint 规则,禁止裸用 dangerouslySetInnerHTML。比如用 eslint-plugin-react 的 no-danger 规则,强制要求注释说明原因。这样至少能避免新人踩同样的坑。
性能对比:差距比我想象的大
我简单 benchmark 了一下(1000 个含 HTML 的列表项):
- 裸用 dangerouslySetInnerHTML:最快,30ms
- DOMPurify + dangerouslySetInnerHTML:约 80ms,可接受
- html-react-parser:300ms+,卡顿明显
DOMPurify 的开销主要在解析和过滤,但它用的是原生 DOM 操作,比 JS 递归解析快得多。而 html-react-parser 要把 HTML 转成 AST 再生成 React 元素,中间层太多,性能自然掉下来。所以除非你真需要 React 元素级别的控制,否则别为了“优雅”牺牲体验。
最后的小建议
如果你的项目用 Next.js,记得在 getStaticProps 或 getServerSideProps 里就做 HTML 清洗,别等到客户端。这样既能减轻前端负担,又能避免 SSR/CSR 内容不一致的问题。比如:
// pages/article/[id].js
import DOMPurify from 'dompurify';
export async function getStaticProps({ params }) {
const res = await fetch(https://jztheme.com/api/articles/${params.id});
const data = await res.json();
data.content = DOMPurify.sanitize(data.content, { /* config */ });
return { props: { article: data } };
}
这样前端直接渲染干净的 HTML,连客户端过滤都省了。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用 Web Worker 做异步清洗,或者结合 Content Security Policy(CSP)做双重防护,欢迎评论区交流。毕竟安全这事,永远没有“做完”,只有“做得更好”。

暂无评论