用原生 Web Components 构建可复用的详情组件
作者:MDN / Mozilla Contributors。本文翻译整理 Using templates and slots,并合并 Using custom elements 与 Using shadow DOM 中与组件注册、生命周期及封装直接相关的说明。核对日期:2026年10月8日。
假设页面要反复展示一个 HTML 元素的名称、简介和属性清单。把整套 HTML 复制多次,内容和结构很快就会纠缠在一起。原生 Web Components 提供了另一种组织方法:用 template 保存结构,用自定义元素创建实例,再用 slot 把调用方提供的内容放到指定位置。下面的详情组件不依赖框架,也不依赖扩展内置元素、手工插槽分配或作用域自定义元素注册表。

普通模板保存内容,但不会自行显示
在同一页面反复使用相同标记结构时,可以先把它放入 <template>。普通模板及其中内容不会立即渲染,但 JavaScript 可以取得其 content,这个属性返回一个 DocumentFragment。
<template id="custom-paragraph">
<p>我的段落</p>
</template>
const template = document.getElementById("custom-paragraph");
document.body.appendChild(template.content);
上面的写法把片段中的节点移到页面里;它适合解释模板内容如何出现,却不是多实例复用的正确做法。文档片段被追加后,其子节点会转移到目标容器。因此,创建每个组件时应复制模板,而不是反复移动同一个片段。
用自定义元素装入影子树
自主自定义元素继承 HTMLElement。名称必须符合自定义元素命名规则,包含连字符,例如 my-paragraph。先定义类,再通过 customElements.define() 注册,浏览器就可以在解析或升级相应标签时创建实例。
customElements.define(
"my-paragraph",
class extends HTMLElement {
constructor() {
super();
const template = document.getElementById("custom-paragraph");
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(document.importNode(template.content, true));
}
},
);
document.importNode(fragment, true) 深复制模板片段。把副本插入影子根后,每个实例都有自己的内部节点。普通 DOM 中的模板本身不创造样式作用域;真正使内部样式限定在组件内部的,是副本被放进 Shadow DOM 这一动作。
<template id="custom-paragraph">
<style>
p { color: white; background: #666; padding: 5px; }
</style>
<p>我的段落</p>
</template>
<my-paragraph></my-paragraph>
以上两个模板是同一个示例的不同阶段,不应在一个实际页面中同时保留重复的 id。模板应先于组件注册和实例构造可用;可把模板置于页面前部,并使用外部 defer 脚本或模块脚本注册组件。
用 slot 填入每个实例的内容
只有固定段落的组件还不够灵活。在模板中放入一个插槽,并给它名字:
<p><slot name="my-text">默认文本</slot></p>
调用方在组件标签内部声明节点,并令 slot 属性与插槽的 name 相同。匹配的节点会参与该插槽的显示组合:
<my-paragraph>
<span slot="my-text">这个实例使用自己的文本。</span>
</my-paragraph>
插槽可接收元素或文本等可分配节点;内容不必只有一句话。MDN 还用带 slot="my-text" 的列表说明这点。实际组件设计仍要选择语义合适的容器:若允许列表等块级内容,编辑建议用 div 包裹插槽,而不要把任意结构都当成普通段落。
没有分配内容时,插槽内部的默认文本会作为回退内容显示。这里说的是已支持并建立 Shadow DOM 的组件;不能把回退文本理解为所有不支持 Web Components 的浏览器都会完整渲染组件。应用若需要旧环境降级,应单独设计可见的 light DOM 或替代内容。
同一影子根中插槽名字应保持唯一。如果有两个同名插槽,所有匹配节点都分配给第一个。调用方的 slot 属性则可以重复,多个匹配元素可以进入同一个插槽。
name 和 slot 的默认值都是空字符串。未写 slot 属性的内容会分配给未命名插槽,即默认插槽:
<template id="custom-paragraph">
<p>
<slot name="my-text">默认文本</slot>
<slot></slot>
</p>
</template>
<my-paragraph>
<span slot="my-text">进入具名插槽。</span>
<span>进入默认插槽。</span>
<span>也进入默认插槽。</span>
</my-paragraph>
完整详情组件:三个插槽与原生展开结构
下面把名称、简介、属性三个具名插槽放进 details。浏览器的原生 summary 提供展开入口。示例沿用 MDN 的组件结构,中文化默认文案,并简化样式以便阅读;这是明确标注的整理版,而不是对原文代码逐字符复制。
<template id="element-details-template">
<style>
:host { display: block; }
details { font-family: system-ui, sans-serif; }
.name { font-weight: bold; color: #217ac0; font-size: 120%; }
h4 { margin: 10px 0 0; }
h4 span {
background: #217ac0; color: white; padding: 2px 6px;
border: 1px solid #cee9f9; border-radius: 4px;
}
.attributes { margin-left: 22px; font-size: 90%; }
.attributes p { margin-left: 16px; font-style: italic; }
</style>
<details>
<summary>
<span>
<code class="name"><<slot name="element-name">未提供名称</slot>></code>
<span class="desc"><slot name="description">未提供说明</slot></span>
</span>
</summary>
<div class="attributes">
<h4><span>属性</span></h4>
<slot name="attributes"><p>无</p></slot>
</div>
</details>
<hr>
</template>
模板有三个职责:定义通用 DOM 结构;声明仅用于内部节点的样式;为每个插槽提供缺省内容。插槽本身并不要求必须写在模板中,直接创建影子树时也能加入 slot。使用模板通常更清晰,也能容纳例如 td 这类在普通 div 容器中不合适的片段结构。
接着注册元素。下面在原文构造器模式上增加了模板存在性检查;读取的是页面中已经建立的模板,没有在构造器中读取该实例尚未解析完的属性或子节点。
const detailsTemplate = document.getElementById("element-details-template");
if (!(detailsTemplate instanceof HTMLTemplateElement)) {
throw new Error("element-details-template 必须先于组件注册存在");
}
class ElementDetails extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(document.importNode(detailsTemplate.content, true));
}
}
customElements.define("element-details", ElementDetails);
一个页面只能把同一名称注册一次。应用应让注册模块只加载一次,并避免与其他组件库的名称冲突;不要把捕获并忽略重复注册异常当作解决名称冲突的方法。此例不需要在 connectedCallback() 中反复创建影子根,实例断开后再连接也不会重复初始化。
现在创建两个实例。第一个给出属性清单,第二个只给出名称和说明:
<element-details>
<span slot="element-name">slot</span>
<span slot="description">组件中的内容占位位置,可由调用方提供标记。</span>
<dl slot="attributes">
<dt>name</dt>
<dd>插槽名称,用于匹配调用方节点的 slot 属性。</dd>
</dl>
</element-details>
<element-details>
<span slot="element-name">template</span>
<span slot="description">保存初始不渲染、之后可实例化的客户端内容。</span>
</element-details>
按原文示例的组合规则,第一个实例展开后应显示 name 定义,第二个应使用属性插槽里的“无”。这里描述的是代码应产生的行为,本次未在浏览器执行示例,也没有制作伪造运行截图。
为什么属性清单的样式要写在外面
调用方提供的 dl、dt、dd 仍属于 light DOM。插槽分配改变其在组合树中的显示位置,不会把这些节点真正迁移进影子树。MDN 因而把属性清单样式加到宿主页:
element-details dl { margin-left: 6px; }
element-details dt {
color: #217ac0;
font-family: Consolas, "Liberation Mono", monospace;
font-size: 110%; font-weight: bold;
}
element-details dd { margin-left: 16px; }
此处为减少对页面其他清单的影响,在原文选择器前加了 element-details。影子树里的 .attributes p 可以匹配它自己的回退段落,却不能像普通后代选择器一样直接穿透插槽匹配外部清单的全部内部节点。可继承属性和 CSS 自定义属性仍可能从宿主影响内部,因此封装并不意味着完全不受外界样式影响。
生命周期与 Shadow DOM 的边界
自定义元素在构造器中应先调用 super()。对于依赖宿主属性、子节点、事件或外部资源的初始化,应按生命周期安排工作:connectedCallback() 在连接到文档时调用;disconnectedCallback() 用于断开时清理;adoptedCallback() 响应转移到另一文档;需要观察的属性列入 observedAttributes,再由 attributeChangedCallback(name, oldValue, newValue) 响应变化。
连接回调可能多次发生。扩展这个组件加入事件监听、计时器或请求时,应采用一次性初始化标记或配对注册/移除,避免重新连接造成重复监听和泄漏。MDN 还介绍了配合 Element.moveBefore() 的 connectedMoveCallback()、自定义状态和作用域注册表;这些是额外能力,本文的详情组件不依赖它们,使用前须单独查兼容性。
Shadow DOM 将内部树附着在宿主元素上。普通的 document.querySelectorAll() 不会自动遍历影子树内部,但 mode: "open" 允许页面通过 host.shadowRoot 访问它。mode: "closed" 令这个属性返回 null,表达封装意图,却不是强安全机制。把不可信 HTML 放进模板或插槽仍可能引入 XSS;不要把用户字符串拼接给 innerHTML,纯文本应使用 textContent,需要富文本时应采用经过审核的清洗方案。
影子树样式既可用模板中的 style,也可构造 CSSStyleSheet 后赋给 adoptedStyleSheets,让多个影子根共享样式对象。外部 link 样式表也可用于影子树,但加载不是渲染阻塞保障,可能出现样式到达前的短暂未装饰内容。本文选择模板内样式,使例子更直接。
补充指南的静态审查提示:MDN 方块示例把 size、color 属性直接拼入 style.textContent。若这些属性可由不可信输入控制,可能注入额外 CSS;扩展此类实现时应校验数值范围和颜色格式。本详情组件没有采用这种拼接方式。插槽节点的 lang、dir 仍按其原DOM父节点继承;CSS的部分继承则依据扁平化树,不能把节点归属与全部样式继承混为一谈。
手工分配与声明式影子树:了解即可
具名分配是默认机制,也是大多数场景的首选。原文另介绍手工分配:创建影子根时用 slotAssignment: "manual",再用 HTMLSlotElement.assign() 指定节点;例如按电影的 data-genre 属性过滤内容,不必维护重复的 slot 属性。声明式影子树则可在模板上设置 shadowrootmode,支持手工分配的环境还可使用 shadowrootslotassignment。普通模板的惰性内容与这类声明式创建影子树的模板有不同解析语义,不能混为一谈。
这些接口与普通具名插槽的兼容范围不完全相同。本文实现只依赖自主自定义元素、模板、开放影子根、具名插槽和原生 details;上线前仍应在目标浏览器检查组件升级、键盘展开、屏幕阅读器语义、缺省插槽和重复连接行为。本次只有静态代码审查,没有把这些检查宣称为已完成的浏览器测试。
署名、许可与整理差异
来源与许可:正文主要依据 Using templates and slots,并结合 Using custom elements 与 Using shadow DOM 中与本文直接相关的组件注册、生命周期和封装说明。三篇文档由 Mozilla Contributors 编写,按 CC BY-SA 2.5 或后续版本授权。本文译文与基于原文的改编内容也按 CC BY-SA 2.5 或后续版本共享。转载时请保留原作者、各篇标题和来源链接、许可链接及改动说明。本稿为独立译编,并非 MDN 或 Mozilla 官方发布或背书。
改动包括中文翻译、合并相关指南、简化 CSS、加入模板存在性检查与宿主清单选择器限定,并补充生命周期、安全和兼容性边界。示例代码以文本形式展示;本次未运行代码。
配图为未完纪原创技术示意图(1440×800),非 MDN 插图或浏览器测试截图。该图仅与本文一并使用,不单独适用 MDN 文档许可;详见随稿署名文件 ATTRIBUTION.txt。许可说明见随稿许可说明;完整法律文本见Creative Commons 官方全文。











暂无评论内容