﻿---
title: "用 Custom Elements API 写你的组件"
excerpt: Markdown 插件对简单交互组件来说太重。Custom Elements 能直接提供交互能力，同时减少开发时的摩擦。
date: 2026-02-20
tags:
  - FrontEnd
  - HTML
lang: zh-CN
updated: 2026-06-16 13:00:23
i18n:
  en: /en/custom_elements
  translation: 2
---

当我想给博客做一个手风琴组件时，第一反应是写一个 markdown-it 插件。

我写了一个 [hexo renderer plugin](https://github.com/Efterklang/hexo-renderer-markdown-exit)，让 Hexo 可以用 **markdown exit**[^1] 把 Markdown 转成 HTML。所以继续扩展 parser，看起来是最自然的路。

<script type="module" src="/js/components/text-image-section.js"></script>
<text-image-section image="https://assets.vluv.space/problems_with_md_renderering_for_ui_components.avif" alt="两个问题" width="550px">

但这个方案有 2 个问题：

1. **扩展语法**
2. **迭代很慢**

这些额外工作会直接消磨掉尝试设计的乐趣。

</text-image-section>

**Custom Elements（Web Components）** 是更好的答案。它们是浏览器原生能力，可以在任何 HTML 环境里工作，开发时也不需要构建步骤。

## 什么是 Custom Elements？

Custom elements 让你定义带有行为的自定义 HTML 标签。用 `{js} customElements.define()` 注册一次，这个元素就能在任何地方使用。

浏览器会解析你的自定义标签，而你的 JavaScript class 提供具体行为。

### 如何定义一个 Custom Element

Custom elements 有两种：

- **Autonomous custom elements**：独立元素，直接继承 `HTMLElement`。
- **Customized built-in elements**：扩展已有 HTML 元素，比如 `HTMLParagraphElement` 或 `HTMLUListElement`。

大多数时候，你需要的是 autonomous custom elements。模式是这样：

```js
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 定义](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name)列出的其他规则。

#### 生命周期回调

| Callback                                                  | 什么时候运行                                 |
| --------------------------------------------------------- | -------------------------------------------- |
| `{js} constructor()`                                      | 元素创建时。先调用 `super()`                 |
| `{js} connectedCallback()`                                | 加入 DOM 时                                  |
| `{js} disconnectedCallback()`                             | 从 DOM 移除时                                |
| `{js} attributeChangedCallback(name, oldValue, newValue)` | 属性变化时。需要 `static observedAttributes` |
| `{js} adoptedCallback()`                                  | 被移动到新 document 时                       |

对于简单组件，你会用 `connectedCallback` 做初始化，用 `disconnectedCallback` 做清理，比如移除事件监听器、取消 timer。

现在你已经理解了这个模式，真正的问题来了：**样式应该放在哪里？**

## 用不用 Shadow DOM

这个选择取决于组件里装的是什么。

### Shadow DOM

对于内部结构固定、自成一体的组件，比如 carousel 或 card stack，Shadow DOM 能提供真正的封装。

使用 Shadow DOM 时：

- **CSS 完全隔离**：全局样式不会破坏 carousel，`.showcase-card` 也不会影响其他元素。
- **CSS 变量可以穿透**：`{css} var(--red)` 和 `{css} var(--font-serif)` 仍然可用，所以外部依然可以做主题定制。

下面是我博客里 `{sh} device-carousel.js` 的一个简化例子：

```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 里面，和外部隔离
- `:host` selector 用来给组件根元素设置样式
- CSS 变量（`--card-width`、`--duration`）允许外部定制

### 用 Slot 插入外部内容

`<slot>` 是 Shadow DOM 最强大的功能。它让外部内容穿过 Shadow DOM 的边界，进入组件内部：

```js
// 在组件内部定义 slot
this.shadowRoot.innerHTML = `
  <div class="header"><slot name="header">Default Title</slot></div>
  <div class="body"><slot></slot></div>
`;
```

```html
<!-- 用法： -->
<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。

> [!tip]
> `slot="..."` 属性告诉浏览器内容应该放在哪里。没有 `slot` 属性的元素会进入默认的未命名 slot。

### Shadow DOM 组件的实时例子

<script type="module" src="/js/components/device-carousel.js"></script>

<device-carousel></device-carousel>

---

### 非 Shadow DOM（全局 CSS）

手风琴、tabs，以及其他“容器”组件会包含任意内容：代码块、列表、图片。这些内容应该继承站点本身的排版和间距。所以你不需要为这些组件重复写一套样式。

下面是 `{sh} text-image-section.js` 的真实例子，也就是我博客里的图文组件：

```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 里
- **样式注入到 `{js} document.head`**：用 module 级别的 flag 避免重复注入
- **用户内容（文本、图片）继承站点样式**：段落、链接、列表都会使用博客自己的排版

代价是：全局样式可能意外影响组件，所以要使用更具体的 class name，比如 `.ti-text`，尽量减少冲突。

#### 非 Shadow DOM 组件的示例

<text-image-section image="https://www.myinterestingfacts.com/wp-content/uploads/2014/03/Hermann-Hesse-Image.jpg" alt="Hermann Hesse" image-width="200px" font-family="var(--font-serif)">

**Hermann Karl Hesse**（1877 年 7 月 2 日 - 1962 年 8 月 9 日）是一位德裔瑞士诗人和小说家，1946 年诺贝尔文学奖得主。他对东方宗教、灵性和哲学传统的兴趣，以及他与荣格分析的关联，共同塑造了他的文学作品。他最著名的小说包括 Demian、Steppenwolf、Siddhartha、Narcissus and Goldmund 和 The Glass Bead Game。这些作品都在探索个体对真实自我、自我认识和灵性的追寻。

Hesse 在生前就已经是德语国家广受阅读的作者，但他更持久的国际声誉是在去世几年后才到来的。20 世纪 60 年代中期，他的作品在美国、欧洲和其他地区的二战后读者中变得非常流行。

</text-image-section>

## 一个决策框架

> [!tip] 快速规则
> 组件是否包含应该匹配页面其他部分的 *用户内容*？用全局 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)](https://railsdesigner.com/custom-elements/)：实践导向的介绍
- [MDN: Using custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)：参考文档
- [MDN: Using shadow DOM](https://developer.mozilla.org/en-US/docs/Web_API/Web_components/Using_shadow_DOM)：关于封装的深入说明

[^1]: Markdown-exit 是一个现代的、基于 TypeScript 的 Markdown parser 和 renderer。它在 markdown-it 的基础上扩展了异步渲染、插件支持和完整的 TypeScript 类型安全。
