HTML 模板与 Web Components:原生组件化方案
看完这篇你就能从零写一个不用任何框架、浏览器原生支持的 `<user-card>` 组件,并且知道 template、Shadow DOM、自定义元素、slot 这四个零件各自负责什么。
第一步:先认清四个零件
Web Components 不是一门新语言,是四个已有 API 的组合,缺一个都不完整:
- 自定义元素(`customElements.define`):把 `<user-card>` 这种带短横线的标签注册成真标签。
- Shadow DOM:给组件一个独立 DOM 树,外部样式进不来,内部样式出不去。
- `<template>`:写结构但不渲染,需要时克隆一份,避免在 JS 里拼字符串。
- `<slot>`:内容分发,让使用者往组件里塞自己的 HTML。
注意:自定义元素名字必须包含短横线(`user-card` 合法,`usercard` 会直接抛 `SyntaxError`),且不能和已有标签重名。
第二步:用 template 写结构
把模板放在 HTML 里,而不是写在 JS 字符串中,编辑器能高亮、能格式化:
<template id="user-card-tpl">
<style>
:host { display:block; border:1px solid #ddd; border-radius:8px; padding:12px; }
:host([hidden]) { display:none; }
.name { font-weight:700; color: var(--card-name-color, #222); }
</style>
<div class="name"></div>
<slot name="desc">暂无简介</slot>
</template>
`:host` 指组件自身,`var(--card-name-color, #222)` 是留给外部改样式的接口。
第三步:注册自定义元素并挂 Shadow DOM
class UserCard extends HTMLElement {
static get observedAttributes() { return ['name']; }
constructor() {
super();
const tpl = document.getElementById('user-card-tpl');
this.attachShadow({ mode: 'open' })
.appendChild(tpl.content.cloneNode(true)); // 必须 clone,否则只能插一次
this._nameEl = this.shadowRoot.querySelector('.name');
}
connectedCallback() { this._render(); }
attributeChangedCallback() { this._render(); }
_render() {
// 用 textContent,不要用 innerHTML 拼用户数据
this._nameEl.textContent = this.getAttribute('name') ?? '';
}
}
customElements.define('user-card', UserCard);
注意:`tpl.content` 是文档片段,直接 append 会被“搬走”,第二次就空了,所以每次都要 `cloneNode(true)`。
注意:初始属性存在时,`attributeChangedCallback` 会在 `constructor` 之后、`connectedCallback` 之前触发,所以 `_render()` 里访问的元素必须在 constructor 阶段就缓存好。
第四步:用 slot 让使用者塞内容
<user-card name="阿乐">
<span slot="desc">Clara BBS 作者</span>
</user-card>
模板里的 `<slot name="desc">` 会接收这个 `slot="desc"` 的元素;如果使用者没传,就显示“暂无简介”这个默认内容。
第五步:对外通信——事件和样式两条路
组件往外发消息用自定义事件:
this.dispatchEvent(new CustomEvent('greet', {
detail: { name: this.getAttribute('name') },
bubbles: true,
composed: true // 关键:不加这个,事件穿不出 Shadow DOM
}));
注意:`composed: true` 是跨 Shadow 边界冒泡的必要条件,忘了它,外面 `document.addEventListener('greet', ...)` 永远收不到。
样式想留可改的口子,就用 `part` + `::part()`:
<!-- 模板内 -->
<div class="name" part="name"></div>
/* 组件外部 */
user-card::part(name) { letter-spacing: 1px; }
注意:外部普通的 `user-card .name { }` 是无效的,选择器进不了 Shadow DOM。改样式只有两条正规路径:CSS 自定义属性、`::part()`。
第六步:检查兼容与常见错误
- 现代 Chrome / Edge / Firefox / Safari 均已原生支持,IE 不支持。
- 同名重复 `define` 会抛 `NotSupportedError`;注册后再改原型也不生效,务必在类写完整后再注册。
- 组件未 upgrade 前,自定义标签里的内容会先按普通元素显示一下(FOUC)。给 `:host { display:block }` 配合 CSS 里的 `user-card:not(:defined) { visibility:hidden }` 可以规避。
小结
- 四个零件分工明确:`<template>` 存结构、Shadow DOM 做隔离、自定义元素做注册、`<slot>` 做分发。
- 组件名必须带短横线;模板必须 `cloneNode`;事件跨边界必须 `composed: true`。
- 样式隔离是双向的,对外只留 CSS 自定义属性和 `::part()` 两个口子。
- 给用户数据赋值一律用 `textContent`,别用 `innerHTML` 拼字符串。
- 生命周期按 `constructor → attributeChangedCallback → connectedCallback` 的顺序走,缓存 DOM 引用要在 constructor 里做。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





