用 Custom Elements API 写你的组件

Markdown 插件对简单交互组件来说太重。Custom Elements 能直接提供交互能力,同时减少开发时的摩擦。

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

我写了一个 hexo renderer plugin,让 Hexo 可以用 markdown exit[1] 把 Markdown 转成 HTML。所以继续扩展 parser,看起来是最自然的路。

但这个方案有 2 个问题:

  1. 扩展语法
  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 元素,比如 HTMLParagraphElementHTMLUListElement

大多数时候,你需要的是 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 里面,和外部隔离
  • :host selector 用来给组件根元素设置样式
  • 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。

Tip

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, TabsGlobal CSS内容会变化,需要外部样式
Theme SwitcherShadow DOMUI 固定,封装有价值
CarouselShadow DOM自成一体,内部 CSS 复杂
Text-Image SectionGlobal CSS排版应该和页面保持一致

你当然可以选择 React、Vue,或者某个 web component library。但对一个静态博客来说,custom elements 提供了一些框架给不了的东西:

  • 开发时没有构建步骤:改 JS,刷新页面
  • 没有框架开销:一切交给浏览器处理
  • 到处都能用:把 script tag 放进任意 HTML 页面即可

浏览器原生 API 的能力比多数开发者想象得更强。Custom elements 和合适场景下的 Shadow DOM 结合起来,不需要依赖也能覆盖大多数交互组件需求。

延伸阅读


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