用 Custom Elements API 写你的组件
当我想给博客做一个手风琴组件时,第一反应是写一个 markdown-it 插件。
我写了一个 hexo renderer plugin,让 Hexo 可以用 markdown exit[1] 把 Markdown 转成 HTML。所以继续扩展 parser,看起来是最自然的路。
但这个方案有 2 个问题:
- 扩展语法
- 迭代很慢
这些额外工作会直接消磨掉尝试设计的乐趣。
Custom Elements(Web Components) 是更好的答案。它们是浏览器原生能力,可以在任何 HTML 环境里工作,开发时也不需要构建步骤。
什么是 Custom Elements?
Custom elements 让你定义带有行为的自定义 HTML 标签。用 customElements.define() 注册一次,这个元素就能在任何地方使用。
浏览器会解析你的自定义标签,而你的 JavaScript class 提供具体行为。
如何定义一个 Custom Element
Custom elements 有两种:
- Autonomous custom elements:独立元素,直接继承
HTMLElement。 - Customized built-in elements:扩展已有 HTML 元素,比如
HTMLParagraphElement或HTMLUListElement。
大多数时候,你需要的是 autonomous custom elements。模式是这样:class MyAccordion extends HTMLElement { constructor() { super(); // 必须先调用 super() } connectedCallback() { // 元素被添加到 DOM 时运行 this.innerHTML = `<div class="accordion">${this.innerHTML}</div>`; }}customElements.define('my-accordion', MyAccordion);
注意,元素名(my-accordion)必须以小写字母开头,包含一个连字符,并满足规范里 valid name 定义列出的其他规则。
生命周期回调
| Callback | 什么时候运行 |
|---|---|
constructor() | 元素创建时。先调用 super() |
connectedCallback() | 加入 DOM 时 |
disconnectedCallback() | 从 DOM 移除时 |
attributeChangedCallback(name, oldValue, newValue) | 属性变化时。需要 static observedAttributes |
adoptedCallback() | 被移动到新 document 时 |
对于简单组件,你会用 connectedCallback 做初始化,用 disconnectedCallback 做清理,比如移除事件监听器、取消 timer。
现在你已经理解了这个模式,真正的问题来了:样式应该放在哪里?
用不用 Shadow DOM
这个选择取决于组件里装的是什么。
Shadow DOM
对于内部结构固定、自成一体的组件,比如 carousel 或 card stack,Shadow DOM 能提供真正的封装。
使用 Shadow DOM 时:
- CSS 完全隔离:全局样式不会破坏 carousel,
.showcase-card也不会影响其他元素。 - CSS 变量可以穿透:
var(--red)和var(--font-serif)仍然可用,所以外部依然可以做主题定制。
下面是我博客里 device-carousel.js 的一个简化例子:class DeviceCarousel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); // 关键:创建 Shadow DOM } connectedCallback() { this.render(); } render() { const style = ` :host { display: block; --card-width: 280px; } .track { display: flex; animation: scroll var(--duration, 40s) linear infinite; } .card { width: var(--card-width); background: var(--bg, #1e1e2e); border-radius: 12px; } @keyframes scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } `; const devices = this.getDevices(); const cards = [...devices, ...devices] // 复制一份,实现无缝滚动 .map(d => `<div class="card">${d.name}</div>`).join(''); this.shadowRoot.innerHTML = ` <style>${style}</style> <div class="track">${cards}</div> <slot></slot> `; } getDevices() { // 从 slot 获取自定义内容,或者使用默认数据 const slot = this.querySelector('device-card'); if (slot) { return [{ name: slot.getAttribute('name'), image: slot.getAttribute('image') }]; } return [{ name: 'Default Device', image: '/default.png' }]; }}customElements.define('device-carousel', DeviceCarousel);
关键点:
this.attachShadow({ mode: 'open' })创建 Shadow DOM- 所有样式都放在 Shadow DOM 里面,和外部隔离
:hostselector 用来给组件根元素设置样式- CSS 变量(
--card-width、--duration)允许外部定制
用 Slot 插入外部内容
<slot> 是 Shadow DOM 最强大的功能。它让外部内容穿过 Shadow DOM 的边界,进入组件内部:// 在组件内部定义 slotthis.shadowRoot.innerHTML = ` <div class="header"><slot name="header">Default Title</slot></div> <div class="body"><slot></slot></div>`;<!-- 用法: --><my-component> <span slot="header">Custom Title</span> <p>This content goes into the default slot</p></my-component>
两种 Slot 类型:
| 类型 | 组件用法 | 使用方写法 | 用途 |
|---|---|---|---|
| Named slot | <slot name="header"> | <div slot="header"> | 指定位置 |
| Default slot | <slot> | 直接放子元素 | 接收所有内容 |
在 device-carousel 里,<slot></slot> 让用户可以直接在 HTML 里写自定义 <device-card> 元素,覆盖 JavaScript 生成的 card。
slot="..." 属性告诉浏览器内容应该放在哪里。没有 slot 属性的元素会进入默认的未命名 slot。
Shadow DOM 组件的实时例子
非 Shadow DOM(全局 CSS)
手风琴、tabs,以及其他“容器”组件会包含任意内容:代码块、列表、图片。这些内容应该继承站点本身的排版和间距。所以你不需要为这些组件重复写一套样式。
下面是 text-image-section.js 的真实例子,也就是我博客里的图文组件:let styleSheetInjected = false;class TextImageSection extends HTMLElement { connectedCallback() { this.injectStyles(); this.render(); } injectStyles() { if (styleSheetInjected) return; // 防止重复注入 const style = ` text-image-section { display: block; } .ti-text { line-height: 1.8; } .ti-image img { border-radius: 8px; } `; const styleEl = document.createElement('style'); styleEl.textContent = style; document.head.appendChild(styleEl); styleSheetInjected = true; }}customElements.define('text-image-section', TextImageSection);
和 Shadow DOM 相比,关键差异是:
- 没有
attachShadow():内容直接留在 light DOM 里 - 样式注入到
document.head:用 module 级别的 flag 避免重复注入 - 用户内容(文本、图片)继承站点样式:段落、链接、列表都会使用博客自己的排版
代价是:全局样式可能意外影响组件,所以要使用更具体的 class name,比如 .ti-text,尽量减少冲突。
非 Shadow DOM 组件的示例
Hermann Karl Hesse(1877 年 7 月 2 日 - 1962 年 8 月 9 日)是一位德裔瑞士诗人和小说家,1946 年诺贝尔文学奖得主。他对东方宗教、灵性和哲学传统的兴趣,以及他与荣格分析的关联,共同塑造了他的文学作品。他最著名的小说包括 Demian、Steppenwolf、Siddhartha、Narcissus and Goldmund 和 The Glass Bead Game。这些作品都在探索个体对真实自我、自我认识和灵性的追寻。
Hesse 在生前就已经是德语国家广受阅读的作者,但他更持久的国际声誉是在去世几年后才到来的。20 世纪 60 年代中期,他的作品在美国、欧洲和其他地区的二战后读者中变得非常流行。
一个决策框架
组件是否包含应该匹配页面其他部分的 用户内容?用全局 CSS。它是不是一个内部结构固定的 自包含 widget?用 Shadow DOM。
决策矩阵
| 组件类型 | 样式策略 | 原因 |
|---|---|---|
| Accordion, Tabs | Global CSS | 内容会变化,需要外部样式 |
| Theme Switcher | Shadow DOM | UI 固定,封装有价值 |
| Carousel | Shadow DOM | 自成一体,内部 CSS 复杂 |
| Text-Image Section | Global CSS | 排版应该和页面保持一致 |
你当然可以选择 React、Vue,或者某个 web component library。但对一个静态博客来说,custom elements 提供了一些框架给不了的东西:
- 开发时没有构建步骤:改 JS,刷新页面
- 没有框架开销:一切交给浏览器处理
- 到处都能用:把 script tag 放进任意 HTML 页面即可
浏览器原生 API 的能力比多数开发者想象得更强。Custom elements 和合适场景下的 Shadow DOM 结合起来,不需要依赖也能覆盖大多数交互组件需求。
延伸阅读
- Building optimistic UI in Rails (and learn custom elements):实践导向的介绍
- MDN: Using custom elements:参考文档
- MDN: Using shadow DOM:关于封装的深入说明
Markdown-exit 是一个现代的、基于 TypeScript 的 Markdown parser 和 renderer。它在 markdown-it 的基础上扩展了异步渲染、插件支持和完整的 TypeScript 类型安全。 ↩︎