如何在自定义元素中安全、规范地获取和处理内部内容(如语法高亮组件)

自定义 Web Component(如 )应优先通过 分发内容并监听 slotchange 事件来响应动态内容,而非直接读取 this.innerHTML;后者易导致 XSS 风险、样式隔离失效、Shadow DOM 渲染冲突及构建工具(如 Vite)解析异常。
自定义 web component(如 ``)应优先通过 `` 分发内容并监听 `slotchange` 事件来响应动态内容,而非直接读取 `this.innerhtml`;后者易导致 xss 风险、样式隔离失效、shadow dom 渲染冲突及构建工具(如 vite)解析异常。
在构建语法高亮类自定义元素(例如 <fs-highlight data-language="html">...</fs-highlight>)时,直接访问 this.innerHTML 并非推荐做法,尽管它在某些简单场景下“看似可用”,但存在多个根本性缺陷:
❌ 为什么 this.innerHTML 是危险且不规范的?
-
破坏 Shadow DOM 封装性:若组件已启用 Shadow DOM(推荐方式),
this.innerHTML 读取的是 light DOM 中的原始 HTML 字符串,但该内容不会自动渲染到 shadowRoot 内,导致内容不可见、样式无法作用、事件无法绑定。
-
XSS 风险高:
innerHTML 原样拼接用户输入后直接插入(如 this.shadowRoot.innerHTML = ...),极易触发跨站脚本攻击。即使使用 hljs.highlight() 处理,其返回的 .value 仍是未转义的 HTML 字符串,需配合 TrustedHTML 或 unsafeHTML(仅限 Lit)等安全机制。
-
构建工具兼容性差:Vite、Rollup 等工具在预构建阶段会解析 HTML 模板中的
<script></script>、<style></style> 标签——若 light DOM 内含未转义的 <script></script> 或嵌套标签(如 <div><code>...
),将导致编译失败或运行时解析异常(如 StackBlitz 中报错)。
无法响应内容变更:innerHTML 是一次性快照,当父级动态修改子节点(如 React 更新、JS 插入新文本)时,组件不会自动感知,必须手动监听 slotchange 或 MutationObserver,而 innerHTML 完全绕过了标准响应机制。
✅ 正确方案:基于 <slot></slot> 的声明式内容分发
Web Components 规范原生支持内容分发(Content Distribution),核心是 <slot></slot> 元素与 slotchange 事件:
class FsHighlight extends HTMLElement {
private shadow: ShadowRoot;
private codeEl: HTMLElement;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
// 创建 slot 容器,显式接收 light DOM 内容
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: 'JetBrains Mono', monospace; }
pre { margin: 0; padding: 1rem; background: #2d2d2d; color: #f8f8f2; }
code { white-space: pre; }
</style><pre class="brush:php;toolbar:false;"><code><slot></slot></code>
`;
this.codeEl = this.shadow.querySelector('code')!;
}
// 监听 slot 内容变化(包括首次挂载)
connectedCallback() {
const slot = this.shadow.querySelector('slot')!;
slot.addEventListener('slotchange', () => this.highlight());
this.highlight(); // 初始高亮
}
private highlight() {
const slotAssignments = this.shadow
.querySelector('slot')!
.assignedNodes({ flatten: true });
// 提取纯文本内容(避免执行 script / style)
let rawText = '';
for (const node of slotAssignments) {
if (node.nodeType === Node.TEXT_NODE) {
rawText += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE) {
rawText += (node as HTMLElement).textContent || '';
}
}
const language = this.getAttribute('data-language') || 'auto';
const result = hljs.highlightAuto(rawText.trim(), {
language: language !== 'auto' ? language : undefined
});
// 安全注入:使用 innerHTML + sanitize(或 Lit 的 unsafeHTML)
this.codeEl.innerHTML = result.value;
}
}
customElements.define('fs-highlight', FsHighlight);
✅ 优势显著:
- ✅ 语义清晰:
<slot></slot> 明确表达“此处接收外部内容”,符合 Web Components 设计哲学;
- ✅ 响应式更新:
slotchange 自动捕获所有 DOM 变更(React/Vue 动态渲染、JS 修改);
- ✅ 样式隔离:CSS 作用于 shadowRoot,不污染全局,也不被外部 CSS 覆盖;
- ✅ 构建友好:Vite 不解析
<slot></slot> 内容,完全规避编译错误;
- ✅ 无障碍支持:
<slot></slot> 天然支持屏幕阅读器语义流。
⚠️ 补充注意事项
-
禁止在
constructor 中操作 DOM:this.innerHTML 或 this.shadowRoot 在 constructor 中可能为 null,必须在 connectedCallback 中初始化;
-
语言检测慎用
auto:highlightAuto 可能误判,建议显式声明 data-language="javascript" 并校验有效性;
-
样式需内联或通过
<link> 加载:@highlightjs 的 CSS 必须通过 <link rel="stylesheet"> 注入 shadowRoot,或提取为字符串内联(避免外部样式污染);
-
服务端渲染(SSR)兼容:若需 SSR 支持,应在服务端同步执行高亮逻辑,并将结果作为静态 HTML 注入,客户端只做 hydration。
综上,<slot></slot> 是 Web Components 中处理传入内容的唯一标准、安全、可维护的方式。放弃 this.innerHTML,拥抱 Slot API,才能构建真正健壮、跨框架、可长期演进的自定义元素。