web-components-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Web Components Architecture

Web Components 架构

Core Principles

核心原则

This skill enforces a strict architectural pattern for web components:
  1. Zero DOM Selection: NEVER use
    querySelector
    ,
    querySelectorAll
    , or
    getElementById
  2. Attribute-Driven State: All state flows through HTML attributes
  3. Event Delegation on
    this
    : Use
    this.addEventListener
    and the
    handleEvent
    pattern
  4. No External Dependencies: Use only standard Web Platform APIs
  5. Declarative Shadow DOM: Use
    <template shadowrootmode="open">
    for instant rendering
  6. Progressive Enhancement: Components work (degraded) even if JavaScript fails
  7. Customized Built-ins Over Autonomous: Extend native elements when possible to preserve accessibility
本技能为Web Components强制执行严格的架构模式:
  1. 零DOM选择:绝对禁止使用
    querySelector
    querySelectorAll
    getElementById
  2. 属性驱动状态:所有状态通过HTML属性流转
  3. 基于
    this
    的事件委托
    :使用
    this.addEventListener
    handleEvent
    模式
  4. 无外部依赖:仅使用标准Web平台API
  5. 声明式Shadow DOM:使用
    <template shadowrootmode="open">
    实现即时渲染
  6. 渐进式增强:即使JavaScript失效,组件仍能(降级)工作
  7. 优先定制内置元素而非自主元素:尽可能扩展原生元素以保留可访问性

Relationship with JavaScript Best Practices

与JavaScript最佳实践的关系

This skill defines the architectural pattern for building web components. When implementing components, combine this skill with
javascript-pragmatic-rules
for production-quality code:
This skill provides the WHAT (component architecture):
  • Component structure (extends HTMLElement)
  • Lifecycle callbacks (connectedCallback, disconnectedCallback)
  • State management (attribute-driven)
  • Event patterns (handleEvent, CustomEvent)
  • Shadow DOM and encapsulation
javascript-pragmatic-rules
provides the HOW (implementation quality):
  • Async operation handling (timeouts, cancellation)
  • Resource cleanup patterns
  • Error handling strategies
  • Memory leak prevention
  • Performance optimization
Example: Building an
<async-button>
component:
javascript
// Architecture from web-components-architecture skill
class AsyncButton extends HTMLButtonElement {
  #controller = null; // Private field for cleanup

  connectedCallback() {
    this.addEventListener('click', this);
  }

  // Using handleEvent pattern from web-components-architecture
  async handleEvent(e) {
    if (e.type === 'click') {
      // Rule 2 from javascript-pragmatic-rules: Timeout async operations
      this.#controller = new AbortController();
      const timeoutId = setTimeout(() => this.#controller.abort(), 5_000);

      try {
        const response = await fetch(this.getAttribute('data-url'), {
          signal: this.#controller.signal
        });
        clearTimeout(timeoutId);
        // Handle response...
      } catch (error) {
        // Rule 1 from javascript-pragmatic-rules: Handle rejections
        if (error.name === 'AbortError') {
          console.warn('Request timed out');
        } else {
          throw new Error('Request failed', { cause: error });
        }
      }
    }
  }

  // Rule 4 from javascript-pragmatic-rules: Clean up resources
  disconnectedCallback() {
    this.removeEventListener('click', this);
    if (this.#controller) this.#controller.abort();
  }
}
Key Integration Points:
  • Use this skill's
    connectedCallback
    with
    javascript-pragmatic-rules
    Rule 4 (cleanup)
  • Use this skill's
    handleEvent
    with
    javascript-pragmatic-rules
    Rules 1-2 (async safety)
  • Use this skill's attribute patterns with
    javascript-pragmatic-rules
    Rule 5 (immutability)
See
javascript-pragmatic-rules
skill for comprehensive JavaScript best practices.

本技能定义了构建Web Components的架构模式。在实现组件时,需将本技能与
javascript-pragmatic-rules
结合,以生成生产级质量的代码:
本技能提供WHAT(组件架构):
  • 组件结构(继承HTMLElement)
  • 生命周期回调(connectedCallback、disconnectedCallback)
  • 状态管理(属性驱动)
  • 事件模式(handleEvent、CustomEvent)
  • Shadow DOM与封装
javascript-pragmatic-rules
提供HOW(实现质量):
  • 异步操作处理(超时、取消)
  • 资源清理模式
  • 错误处理策略
  • 内存泄漏预防
  • 性能优化
示例:构建
<async-button>
组件:
javascript
// 来自web-components-architecture技能的架构
class AsyncButton extends HTMLButtonElement {
  #controller = null; // 用于清理的私有字段

  connectedCallback() {
    this.addEventListener('click', this);
  }

  // 使用web-components-architecture技能中的handleEvent模式
  async handleEvent(e) {
    if (e.type === 'click') {
      // javascript-pragmatic-rules规则2:异步操作超时处理
      this.#controller = new AbortController();
      const timeoutId = setTimeout(() => this.#controller.abort(), 5_000);

      try {
        const response = await fetch(this.getAttribute('data-url'), {
          signal: this.#controller.signal
        });
        clearTimeout(timeoutId);
        // 处理响应...
      } catch (error) {
        // javascript-pragmatic-rules规则1:处理拒绝状态
        if (error.name === 'AbortError') {
          console.warn('请求超时');
        } else {
          throw new Error('请求失败', { cause: error });
        }
      }
    }
  }

  // javascript-pragmatic-rules规则4:资源清理
  disconnectedCallback() {
    this.removeEventListener('click', this);
    if (this.#controller) this.#controller.abort();
  }
}
关键集成点:
  • 将本技能的
    connectedCallback
    与javascript-pragmatic-rules规则4(清理)结合使用
  • 将本技能的
    handleEvent
    与javascript-pragmatic-rules规则1-2(异步安全)结合使用
  • 将本技能的属性模式与javascript-pragmatic-rules规则5(不可变性)结合使用
查看
javascript-pragmatic-rules
技能以获取全面的JavaScript最佳实践。

Component Types

组件类型

1. Customized Built-in Elements (PREFERRED)

1. 定制内置元素(优先选择)

Extend native HTML elements to preserve built-in accessibility and behavior.
javascript
class AsyncAction extends HTMLButtonElement {
  connectedCallback() {
    // Principle: Event Delegation on Self
    this.addEventListener('click', this);
  }

  // Principle: HandleEvent pattern avoids binding 'this'
  async handleEvent(e) {
    if (e.type === 'click') {
      // Principle: Attribute-Driven State (Input)
      this.setAttribute('aria-busy', 'true');
      this.disabled = true;

      // Simulate async work
      await new Promise((resolve) => setTimeout(resolve, 1_500));

      this.removeAttribute('aria-busy');
      this.disabled = false;

      // Principle: Events are the ONLY output
      this.dispatchEvent(new CustomEvent('action-complete', {
        bubbles: true,
        detail: { originalEvent: e }
      }));
    }
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }
}

// Register with { extends: 'button' }
customElements.define('async-action', AsyncAction, { extends: 'button' });
Usage in HTML:
html
<button is="async-action" aria-label="Add to cart">
  Add to Cart
</button>
扩展原生HTML元素以保留内置的可访问性和行为。
javascript
class AsyncAction extends HTMLButtonElement {
  connectedCallback() {
    // 原则:在自身上进行事件委托
    this.addEventListener('click', this);
  }

  // 原则:handleEvent模式避免绑定'this'
  async handleEvent(e) {
    if (e.type === 'click') {
      // 原则:属性驱动状态(输入)
      this.setAttribute('aria-busy', 'true');
      this.disabled = true;

      // 模拟异步工作
      await new Promise((resolve) => setTimeout(resolve, 1_500));

      this.removeAttribute('aria-busy');
      this.disabled = false;

      // 原则:事件是唯一的输出方式
      this.dispatchEvent(new CustomEvent('action-complete', {
        bubbles: true,
        detail: { originalEvent: e }
      }));
    }
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }
}

// 注册时指定{ extends: 'button' }
customElements.define('async-action', AsyncAction, { extends: 'button' });
HTML中的用法:
html
<button is="async-action" aria-label="Add to cart">
  加入购物车
</button>

2. Autonomous Custom Elements

2. 自主自定义元素

Use for layout containers and composite components.
javascript
class ProductCard extends HTMLElement {
  // Purely declarative - no logic needed if using DSD
}

customElements.define('product-card', ProductCard);
用于布局容器和复合组件。
javascript
class ProductCard extends HTMLElement {
  // 纯声明式 - 如果使用DSD则无需逻辑
}

customElements.define('product-card', ProductCard);

Declarative Shadow DOM

声明式Shadow DOM

Use
<template shadowrootmode="open">
for instant rendering without JavaScript.
html
<product-card>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        max-width: 300px;
      }

      /* Attribute-Driven Styling */
      button[aria-busy="true"] {
        opacity: 0.7;
        cursor: wait;
      }

      .card {
        background: var(--card-bg, white);
        border: 1px solid #e5e7eb;
        border-radius: 8px;
        padding: 1rem;
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
      }

      .footer {
        margin-top: 1rem;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
    </style>

    <article class="card" part="container">
      <slot name="header"></slot>
      <slot></slot>

      <div class="footer">
        <slot name="price">Free</slot>

        <button is="async-action" part="action-btn">
          Add to Cart
        </button>
      </div>
    </article>
  </template>

  <h3 slot="header">Product Title</h3>
  <p>Product description goes here.</p>
  <strong slot="price">$450.00</strong>
</product-card>
使用
<template shadowrootmode="open">
实现无需JavaScript的即时渲染。
html
<product-card>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        max-width: 300px;
      }

      /* 属性驱动的样式 */
      button[aria-busy="true"] {
        opacity: 0.7;
        cursor: wait;
      }

      .card {
        background: var(--card-bg, white);
        border: 1px solid #e5e7eb;
        border-radius: 8px;
        padding: 1rem;
        display: flex;
        flex-direction: column;
        gap: 0.5rem;
      }

      .footer {
        margin-top: 1rem;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
    </style>

    <article class="card" part="container">
      <slot name="header"></slot>
      <slot></slot>

      <div class="footer">
        <slot name="price">免费</slot>

        <button is="async-action" part="action-btn">
          加入购物车
        </button>
      </div>
    </article>
  </template>

  <h3 slot="header">产品标题</h3>
  <p>产品描述内容。</p>
  <strong slot="price">$450.00</strong>
</product-card>

Attribute-Driven State Pattern

属性驱动状态模式

State flows IN via attributes and OUT via events.
状态通过属性输入,通过事件输出。

Input: Reading Attributes

输入:读取属性

javascript
class DataDisplay extends HTMLElement {
  connectedCallback() {
    // Read initial state from attributes
    const apiUrl = this.getAttribute('data-url');
    const refreshInterval = this.getAttribute('refresh-interval') || 5_000;

    this.setAttribute('status', 'loading');
    this.loadData(apiUrl);
  }

  async loadData(url) {
    try {
      const response = await fetch(url);
      const data = await response.json();

      // State change via attribute
      this.setAttribute('status', 'loaded');
      this.setAttribute('data-count', data.items.length);

      // Output via event
      this.dispatchEvent(new CustomEvent('data-loaded', {
        bubbles: true,
        detail: { data }
      }));
    } catch (error) {
      this.setAttribute('status', 'error');
      this.dispatchEvent(new CustomEvent('data-error', {
        bubbles: true,
        detail: { error: error.message }
      }));
    }
  }

  // Observe attribute changes
  static get observedAttributes() {
    return ['data-url', 'refresh-interval'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;

    if (name === 'data-url' && newValue) {
      this.loadData(newValue);
    }
  }
}
javascript
class DataDisplay extends HTMLElement {
  connectedCallback() {
    // 从属性读取初始状态
    const apiUrl = this.getAttribute('data-url');
    const refreshInterval = this.getAttribute('refresh-interval') || 5_000;

    this.setAttribute('status', 'loading');
    this.loadData(apiUrl);
  }

  async loadData(url) {
    try {
      const response = await fetch(url);
      const data = await response.json();

      // 通过属性变更状态
      this.setAttribute('status', 'loaded');
      this.setAttribute('data-count', data.items.length);

      // 通过事件输出
      this.dispatchEvent(new CustomEvent('data-loaded', {
        bubbles: true,
        detail: { data }
      }));
    } catch (error) {
      this.setAttribute('status', 'error');
      this.dispatchEvent(new CustomEvent('data-error', {
        bubbles: true,
        detail: { error: error.message }
      }));
    }
  }

  // 监听属性变化
  static get observedAttributes() {
    return ['data-url', 'refresh-interval'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;

    if (name === 'data-url' && newValue) {
      this.loadData(newValue);
    }
  }
}

Output: Events Only

输出:仅使用事件

javascript
// CORRECT: Dispatch events for output
this.dispatchEvent(new CustomEvent('value-changed', {
  bubbles: true,
  composed: true, // Cross shadow DOM boundaries
  detail: { value: newValue }
}));

// WRONG: Never modify external DOM
// document.querySelector('.result').textContent = value; // ❌ FORBIDDEN
javascript
// 正确做法:通过事件输出
this.dispatchEvent(new CustomEvent('value-changed', {
  bubbles: true,
  composed: true, // 跨Shadow DOM边界
  detail: { value: newValue }
}));

// 错误做法:绝不要修改外部DOM
// document.querySelector('.result').textContent = value; // ❌ 禁止

HandleEvent Pattern

HandleEvent模式

Use the
handleEvent
interface to avoid
.bind(this)
and maintain clean memory management.
javascript
class MultiEventHandler extends HTMLElement {
  connectedCallback() {
    // Single listener for multiple event types
    this.addEventListener('click', this);
    this.addEventListener('keydown', this);
    this.addEventListener('focus', this);
  }

  handleEvent(e) {
    // Route by event type
    switch(e.type) {
      case 'click':
        this.handleClick(e);
        break;
      case 'keydown':
        this.handleKeydown(e);
        break;
      case 'focus':
        this.handleFocus(e);
        break;
    }
  }

  handleClick(e) {
    this.setAttribute('last-interaction', 'click');
  }

  handleKeydown(e) {
    if (e.key === 'Enter' || e.key === ' ') {
      this.setAttribute('last-interaction', 'keyboard');
    }
  }

  handleFocus(e) {
    this.setAttribute('focused', 'true');
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
    this.removeEventListener('keydown', this);
    this.removeEventListener('focus', this);
  }
}
使用
handleEvent
接口避免
.bind(this)
并保持清晰的内存管理。
javascript
class MultiEventHandler extends HTMLElement {
  connectedCallback() {
    // 为多种事件类型添加单个监听器
    this.addEventListener('click', this);
    this.addEventListener('keydown', this);
    this.addEventListener('focus', this);
  }

  handleEvent(e) {
    // 按事件类型路由
    switch(e.type) {
      case 'click':
        this.handleClick(e);
        break;
      case 'keydown':
        this.handleKeydown(e);
        break;
      case 'focus':
        this.handleFocus(e);
        break;
    }
  }

  handleClick(e) {
    this.setAttribute('last-interaction', 'click');
  }

  handleKeydown(e) {
    if (e.key === 'Enter' || e.key === ' ') {
      this.setAttribute('last-interaction', 'keyboard');
    }
  }

  handleFocus(e) {
    this.setAttribute('focused', 'true');
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
    this.removeEventListener('keydown', this);
    this.removeEventListener('focus', this);
  }
}

Styling API

样式API

CSS Custom Properties (Theming)

CSS自定义属性(主题化)

css
/* Consumer defines theme */
:root {
  --primary-color: #6200ea;
  --bg-color: #ffffff;
  --spacing: 1rem;
}

product-card {
  --card-bg: var(--bg-color);
  --card-padding: var(--spacing);
}
css
/* 使用者定义主题 */
:root {
  --primary-color: #6200ea;
  --bg-color: #ffffff;
  --spacing: 1rem;
}

product-card {
  --card-bg: var(--bg-color);
  --card-padding: var(--spacing);
}

::part() Pseudo-element

::part()伪元素

html
<template shadowrootmode="open">
  <style>
    .internal-element {
      /* internal styles */
    }
  </style>

  <!-- Expose parts for external styling -->
  <div class="internal-element" part="container">
    <button part="action-btn">Click me</button>
  </div>
</template>
css
/* External styling via ::part() */
product-card::part(container) {
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

product-card::part(action-btn) {
  background-color: var(--primary-color);
  color: white;
}
html
<template shadowrootmode="open">
  <style>
    .internal-element {
      /* 内部样式 */
    }
  </style>

  <!-- 暴露部分供外部样式修改 -->
  <div class="internal-element" part="container">
    <button part="action-btn">点击我</button>
  </div>
</template>
css
/* 通过::part()进行外部样式修改 */
product-card::part(container) {
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

product-card::part(action-btn) {
  background-color: var(--primary-color);
  color: white;
}

:host and :host-context

:host和:host-context

css
<style>
  /* Style the host element */
  :host {
    display: block;
    font-family: system-ui, sans-serif;
  }

  /* State-based styling */
  :host([disabled]) {
    opacity: 0.5;
    pointer-events: none;
  }

  /* Context-based styling */
  :host-context(.dark-mode) {
    background: #1a1a1a;
    color: #ffffff;
  }
</style>
css
<style>
  /* 样式化宿主元素 */
  :host {
    display: block;
    font-family: system-ui, sans-serif;
  }

  /* 基于状态的样式 */
  :host([disabled]) {
    opacity: 0.5;
    pointer-events: none;
  }

  /* 基于上下文的样式 */
  :host-context(.dark-mode) {
    background: #1a1a1a;
    color: #ffffff;
  }
</style>

Lifecycle Callbacks

生命周期回调

javascript
class LifecycleExample extends HTMLElement {
  constructor() {
    super();
    // ONLY: Initialize instance variables
    // DO NOT: Access attributes, children, or DOM
  }

  connectedCallback() {
    // Element added to DOM
    // DO: Set up event listeners, start timers, fetch data
    this.addEventListener('click', this);

    const initialValue = this.getAttribute('value');
    if (initialValue) {
      this.initialize(initialValue);
    }
  }

  disconnectedCallback() {
    // Element removed from DOM
    // DO: Clean up event listeners, timers, connections
    this.removeEventListener('click', this);

    if (this.intervalId) {
      clearInterval(this.intervalId);
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Observed attribute changed
    if (oldValue === newValue) return;

    switch(name) {
      case 'value':
        this.updateValue(newValue);
        break;
      case 'disabled':
        this.updateDisabled(newValue !== null);
        break;
    }
  }

  adoptedCallback() {
    // Element moved to new document
    // Rarely needed - usually for iframes
  }

  static get observedAttributes() {
    return ['value', 'disabled', 'theme'];
  }
}
javascript
class LifecycleExample extends HTMLElement {
  constructor() {
    super();
    // 仅在此处:初始化实例变量
    // 禁止:访问属性、子元素或DOM
  }

  connectedCallback() {
    // 元素被添加到DOM中
    // 可以:设置事件监听器、启动定时器、获取数据
    this.addEventListener('click', this);

    const initialValue = this.getAttribute('value');
    if (initialValue) {
      this.initialize(initialValue);
    }
  }

  disconnectedCallback() {
    // 元素从DOM中移除
    // 可以:清理事件监听器、定时器、连接
    this.removeEventListener('click', this);

    if (this.intervalId) {
      clearInterval(this.intervalId);
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 监听的属性发生变化
    if (oldValue === newValue) return;

    switch(name) {
      case 'value':
        this.updateValue(newValue);
        break;
      case 'disabled':
        this.updateDisabled(newValue !== null);
        break;
    }
  }

  adoptedCallback() {
    // 元素被移动到新文档
    // 很少需要 - 通常用于iframe
  }

  static get observedAttributes() {
    return ['value', 'disabled', 'theme'];
  }
}

Form Integration with ElementInternals

使用ElementInternals集成表单

Use ElementInternals API for form-associated custom elements.
javascript
class CustomInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  connectedCallback() {
    this.addEventListener('input', this);
    this.updateValue(this.getAttribute('value') || '');
  }

  handleEvent(e) {
    if (e.type === 'input') {
      const value = e.target.value;
      this.updateValue(value);
    }
  }

  updateValue(value) {
    // Update form value
    this.internals.setFormValue(value);

    // Validate
    if (!value) {
      this.internals.setValidity(
        { valueMissing: true },
        'This field is required'
      );
    } else {
      this.internals.setValidity({});
    }

    // Emit event
    this.dispatchEvent(new CustomEvent('value-changed', {
      bubbles: true,
      detail: { value }
    }));
  }

  // Form lifecycle callbacks
  formResetCallback() {
    this.updateValue('');
  }

  formDisabledCallback(disabled) {
    this.setAttribute('aria-disabled', disabled);
  }
}

customElements.define('custom-input', CustomInput);
使用ElementInternals API实现与表单关联的自定义元素。
javascript
class CustomInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  connectedCallback() {
    this.addEventListener('input', this);
    this.updateValue(this.getAttribute('value') || '');
  }

  handleEvent(e) {
    if (e.type === 'input') {
      const value = e.target.value;
      this.updateValue(value);
    }
  }

  updateValue(value) {
    // 更新表单值
    this.internals.setFormValue(value);

    // 验证
    if (!value) {
      this.internals.setValidity(
        { valueMissing: true },
        '此字段为必填项'
      );
    } else {
      this.internals.setValidity({});
    }

    // 触发事件
    this.dispatchEvent(new CustomEvent('value-changed', {
      bubbles: true,
      detail: { value }
    }));
  }

  // 表单生命周期回调
  formResetCallback() {
    this.updateValue('');
  }

  formDisabledCallback(disabled) {
    this.setAttribute('aria-disabled', disabled);
  }
}

customElements.define('custom-input', CustomInput);

Accessibility Patterns

可访问性模式

ARIA Attributes

ARIA属性

javascript
class AccessibleToggle extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener('click', this);

    // Set initial ARIA state
    if (!this.hasAttribute('aria-pressed')) {
      this.setAttribute('aria-pressed', 'false');
    }
  }

  handleEvent(e) {
    if (e.type === 'click') {
      const isPressed = this.getAttribute('aria-pressed') === 'true';
      this.setAttribute('aria-pressed', String(!isPressed));

      this.dispatchEvent(new CustomEvent('toggle', {
        bubbles: true,
        detail: { pressed: !isPressed }
      }));
    }
  }
}

customElements.define('accessible-toggle', AccessibleToggle, { extends: 'button' });
javascript
class AccessibleToggle extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener('click', this);

    // 设置初始ARIA状态
    if (!this.hasAttribute('aria-pressed')) {
      this.setAttribute('aria-pressed', 'false');
    }
  }

  handleEvent(e) {
    if (e.type === 'click') {
      const isPressed = this.getAttribute('aria-pressed') === 'true';
      this.setAttribute('aria-pressed', String(!isPressed));

      this.dispatchEvent(new CustomEvent('toggle', {
        bubbles: true,
        detail: { pressed: !isPressed }
      }));
    }
  }
}

customElements.define('accessible-toggle', AccessibleToggle, { extends: 'button' });

Keyboard Navigation

键盘导航

javascript
class KeyboardNav extends HTMLElement {
  connectedCallback() {
    if (!this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0');
    }

    this.addEventListener('keydown', this);
  }

  handleEvent(e) {
    if (e.type === 'keydown') {
      switch(e.key) {
        case 'Enter':
        case ' ':
          e.preventDefault();
          this.activate();
          break;
        case 'Escape':
          this.deactivate();
          break;
      }
    }
  }

  activate() {
    this.setAttribute('active', 'true');
    this.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
  }

  deactivate() {
    this.removeAttribute('active');
    this.dispatchEvent(new CustomEvent('deactivated', { bubbles: true }));
  }
}
javascript
class KeyboardNav extends HTMLElement {
  connectedCallback() {
    if (!this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0');
    }

    this.addEventListener('keydown', this);
  }

  handleEvent(e) {
    if (e.type === 'keydown') {
      switch(e.key) {
        case 'Enter':
        case ' ':
          e.preventDefault();
          this.activate();
          break;
        case 'Escape':
          this.deactivate();
          break;
      }
    }
  }

  activate() {
    this.setAttribute('active', 'true');
    this.dispatchEvent(new CustomEvent('activated', { bubbles: true }));
  }

  deactivate() {
    this.removeAttribute('active');
    this.dispatchEvent(new CustomEvent('deactivated', { bubbles: true }));
  }
}

Complete Example: Async Action Button

完整示例:异步操作按钮

javascript
/* async-action.js */
class AsyncAction extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener('click', this);
  }

  async handleEvent(e) {
    if (e.type === 'click') {
      // Prevent double-clicks
      if (this.getAttribute('aria-busy') === 'true') {
        return;
      }

      // Set loading state
      this.setAttribute('aria-busy', 'true');
      this.disabled = true;

      const originalText = this.textContent;
      const loadingText = this.getAttribute('loading-text') || 'Loading...';
      this.textContent = loadingText;

      try {
        // Dispatch event for external handler
        const actionEvent = new CustomEvent('async-action', {
          bubbles: true,
          cancelable: true,
          detail: {
            button: this,
            originalEvent: e
          }
        });

        const shouldContinue = this.dispatchEvent(actionEvent);

        if (shouldContinue) {
          // Simulate async work (in real usage, parent handles this)
          await new Promise((resolve) => setTimeout(resolve, 1_500));

          // Success state
          this.setAttribute('aria-busy', 'false');
          this.removeAttribute('aria-busy');

          this.dispatchEvent(new CustomEvent('action-complete', {
            bubbles: true,
            detail: { success: true }
          }));
        }
      } catch (error) {
        // Error state
        this.setAttribute('aria-busy', 'false');
        this.removeAttribute('aria-busy');

        this.dispatchEvent(new CustomEvent('action-error', {
          bubbles: true,
          detail: { error: error.message }
        }));
      } finally {
        // Reset state
        this.textContent = originalText;
        this.disabled = false;
      }
    }
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }
}

customElements.define('async-action', AsyncAction, { extends: 'button' });
HTML Usage:
html
<button
  is="async-action"
  loading-text="Saving..."
  aria-label="Save changes">
  Save
</button>

<script>
  document.addEventListener('async-action', async (e) => {
    // Handle the action
    const response = await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ data: 'example' })
    });

    if (!response.ok) {
      throw new Error('Save failed');
    }
  });
</script>
javascript
/* async-action.js */
class AsyncAction extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener('click', this);
  }

  async handleEvent(e) {
    if (e.type === 'click') {
      // 防止重复点击
      if (this.getAttribute('aria-busy') === 'true') {
        return;
      }

      // 设置加载状态
      this.setAttribute('aria-busy', 'true');
      this.disabled = true;

      const originalText = this.textContent;
      const loadingText = this.getAttribute('loading-text') || '加载中...';
      this.textContent = loadingText;

      try {
        // 触发事件供外部处理
        const actionEvent = new CustomEvent('async-action', {
          bubbles: true,
          cancelable: true,
          detail: {
            button: this,
            originalEvent: e
          }
        });

        const shouldContinue = this.dispatchEvent(actionEvent);

        if (shouldContinue) {
          // 模拟异步工作(实际使用中,由父元素处理)
          await new Promise((resolve) => setTimeout(resolve, 1_500));

          // 成功状态
          this.setAttribute('aria-busy', 'false');
          this.removeAttribute('aria-busy');

          this.dispatchEvent(new CustomEvent('action-complete', {
            bubbles: true,
            detail: { success: true }
          }));
        }
      } catch (error) {
        // 错误状态
        this.setAttribute('aria-busy', 'false');
        this.removeAttribute('aria-busy');

        this.dispatchEvent(new CustomEvent('action-error', {
          bubbles: true,
          detail: { error: error.message }
        }));
      } finally {
        // 重置状态
        this.textContent = originalText;
        this.disabled = false;
      }
    }
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }
}

customElements.define('async-action', AsyncAction, { extends: 'button' });
HTML用法:
html
<button
  is="async-action"
  loading-text="保存中..."
  aria-label="保存更改">
  保存
</button>

<script>
  document.addEventListener('async-action', async (e) => {
    // 处理操作
    const response = await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ data: '示例' })
    });

    if (!response.ok) {
      throw new Error('保存失败');
    }
  });
</script>

Anti-Patterns (NEVER DO THIS)

反模式(绝对禁止)

❌ DOM Selection

❌ DOM选择

javascript
// WRONG
connectedCallback() {
  const button = this.querySelector('button'); // ❌
  button.addEventListener('click', ...);
}
javascript
// 错误做法
connectedCallback() {
  const button = this.querySelector('button'); // ❌
  button.addEventListener('click', ...);
}

❌ Direct DOM Manipulation of External Elements

❌ 直接操作外部元素的DOM

javascript
// WRONG
handleClick() {
  document.getElementById('result').textContent = 'Done'; // ❌
}
javascript
// 错误做法
handleClick() {
  document.getElementById('result').textContent = '完成'; // ❌
}

❌ innerHTML for Dynamic Content

❌ 使用innerHTML处理动态内容

javascript
// WRONG
updateContent(data) {
  this.innerHTML = `<div>${data}</div>`; // ❌
}
javascript
// 错误做法
updateContent(data) {
  this.innerHTML = `<div>${data}</div>`; // ❌
}

❌ Imperative Shadow DOM Creation

❌ 命令式创建Shadow DOM

javascript
// WRONG (use Declarative Shadow DOM instead)
constructor() {
  super();
  this.attachShadow({ mode: 'open' }); // ❌
  this.shadowRoot.innerHTML = '<div>...</div>'; // ❌
}
javascript
// 错误做法(请使用声明式Shadow DOM替代)
constructor() {
  super();
  this.attachShadow({ mode: 'open' }); // ❌
  this.shadowRoot.innerHTML = '<div>...</div>'; // ❌
}

❌ Global State

❌ 全局状态

javascript
// WRONG
let globalState = {}; // ❌

class MyComponent extends HTMLElement {
  connectedCallback() {
    globalState.value = 'bad'; // ❌
  }
}
javascript
// 错误做法
let globalState = {}; // ❌

class MyComponent extends HTMLElement {
  connectedCallback() {
    globalState.value = 'bad'; // ❌
  }
}

Testing Pattern

测试模式

javascript
// test.html
<!DOCTYPE html>
<html>
<head>
  <script type="module" src="./async-action.js"></script>
</head>
<body>
  <button is="async-action" id="test-btn">Test</button>

  <script type="module">
    const btn = document.getElementById('test-btn');

    // Test attribute-driven state
    btn.addEventListener('action-complete', (e) => {
      console.log('✓ Action completed', e.detail);
    });

    // Programmatically trigger
    btn.click();

    // Verify state
    console.assert(
      btn.getAttribute('aria-busy') === 'true',
      'Should be busy during action'
    );
  </script>
</body>
</html>
javascript
// test.html
<!DOCTYPE html>
<html>
<head>
  <script type="module" src="./async-action.js"></script>
</head>
<body>
  <button is="async-action" id="test-btn">测试</button>

  <script type="module">
    const btn = document.getElementById('test-btn');

    // 测试属性驱动状态
    btn.addEventListener('action-complete', (e) => {
      console.log('✓ 操作完成', e.detail);
    });

    // 以编程方式触发
    btn.click();

    // 验证状态
    console.assert(
      btn.getAttribute('aria-busy') === 'true',
      '操作期间应处于忙碌状态'
    );
  </script>
</body>
</html>

Progressive Enhancement

渐进式增强

Components should degrade gracefully without JavaScript:
html
<!-- Works as regular button if JS fails -->
<button is="async-action" formaction="/submit" formmethod="post">
  Submit Form
</button>

<!-- Works as regular link if JS fails -->
<a is="spa-link" href="/page">
  Navigate
</a>
组件在没有JavaScript的情况下应优雅降级:
html
<!-- 如果JS失效,可作为常规按钮工作 -->
<button is="async-action" formaction="/submit" formmethod="post">
  提交表单
</button>

<!-- 如果JS失效,可作为常规链接工作 -->
<a is="spa-link" href="/page">
  导航
</a>

Integrating with Utopia Fluid Scales

与Utopia Fluid Scales集成

When using this skill alongside Utopia skills (
utopia-fluid-scales
,
utopia-grid-layout
,
utopia-container-queries
), follow these patterns for CSS Custom Properties and shared styles.
当本技能与Utopia技能(
utopia-fluid-scales
utopia-grid-layout
utopia-container-queries
)一起使用时,请遵循以下CSS自定义属性和共享样式模式。

CSS Custom Properties Pierce Shadow Boundaries

CSS自定义属性可穿透Shadow DOM边界

Utopia tokens defined on
:root
automatically work inside shadow DOM via
var()
:
css
/* Inside shadow DOM - these just work */
:host {
  display: block;
  container-type: inline-size; /* Required for cqi units */
}

.card {
  padding: var(--space-m);           /* Utopia spacing */
  font-size: var(--step-0);          /* Utopia typography */
  gap: var(--grid-gutter);           /* Utopia grid */
}

.title {
  font-size: var(--step-2);
  font-weight: 700;
}

.body {
  font-size: var(--step--1);
  color: var(--color-muted, #6b7280);
}
:root
上定义的Utopia令牌可通过
var()
自动在Shadow DOM内生效:
css
/* 在Shadow DOM内 - 这些可直接生效 */
:host {
  display: block;
  container-type: inline-size; /* 使用cqi单位必需 */
}

.card {
  padding: var(--space-m);           /* Utopia间距 */
  font-size: var(--step-0);          /* Utopia排版 */
  gap: var(--grid-gutter);           /* Utopia网格 */
}

.title {
  font-size: var(--step-2);
  font-weight: 700;
}

.body {
  font-size: var(--step--1);
  color: var(--color-muted, #6b7280);
}

Sharing Utility Classes via Constructable Stylesheets

通过可构造样式表共享工具类

Shadow DOM encapsulates styles, so global utility classes (
.u-stack
,
.u-grid
,
.u-cluster
) don't penetrate. Use Constructable Stylesheets with
adoptedStyleSheets
to share utilities without duplication:
javascript
/* js/styles/shared-styles.js */

// Create stylesheets once - shared across all components
export const utopiaGridStyles = new CSSStyleSheet();
utopiaGridStyles.replaceSync(`
  .u-container {
    max-width: var(--grid-max-width, 77.5rem);
    margin-inline: auto;
    padding-inline: var(--grid-gutter, var(--space-s-l));
  }

  .u-grid {
    display: grid;
    gap: var(--grid-gutter, var(--space-s-l));
  }

  .u-grid-auto {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
    gap: var(--grid-gutter, var(--space-s-l));
  }

  .u-stack {
    display: flex;
    flex-direction: column;
    gap: var(--space-s);
  }

  .u-cluster {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-s);
    align-items: center;
  }
`);

export const utopiaTypeStyles = new CSSStyleSheet();
utopiaTypeStyles.replaceSync(`
  .u-step-5 { font-size: var(--step-5); }
  .u-step-4 { font-size: var(--step-4); }
  .u-step-3 { font-size: var(--step-3); }
  .u-step-2 { font-size: var(--step-2); }
  .u-step-1 { font-size: var(--step-1); }
  .u-step-0 { font-size: var(--step-0); }
  .u-step--1 { font-size: var(--step--1); }
  .u-step--2 { font-size: var(--step--2); }
`);

// Helper to create component-specific stylesheets
export function createStyleSheet(css) {
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(css);
  return sheet;
}
Shadow DOM会封装样式,因此全局工具类(
.u-stack
.u-grid
.u-cluster
)无法穿透。使用可构造样式表
adoptedStyleSheets
来共享工具类而无需重复代码:
javascript
/* js/styles/shared-styles.js */

// 一次性创建样式表 - 供所有组件共享
export const utopiaGridStyles = new CSSStyleSheet();
utopiaGridStyles.replaceSync(`
  .u-container {
    max-width: var(--grid-max-width, 77.5rem);
    margin-inline: auto;
    padding-inline: var(--grid-gutter, var(--space-s-l));
  }

  .u-grid {
    display: grid;
    gap: var(--grid-gutter, var(--space-s-l));
  }

  .u-grid-auto {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
    gap: var(--grid-gutter, var(--space-s-l));
  }

  .u-stack {
    display: flex;
    flex-direction: column;
    gap: var(--space-s);
  }

  .u-cluster {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-s);
    align-items: center;
  }
`);

export const utopiaTypeStyles = new CSSStyleSheet();
utopiaTypeStyles.replaceSync(`
  .u-step-5 { font-size: var(--step-5); }
  .u-step-4 { font-size: var(--step-4); }
  .u-step-3 { font-size: var(--step-3); }
  .u-step-2 { font-size: var(--step-2); }
  .u-step-1 { font-size: var(--step-1); }
  .u-step-0 { font-size: var(--step-0); }
  .u-step--1 { font-size: var(--step--1); }
  .u-step--2 { font-size: var(--step--2); }
`);

// 辅助函数:创建组件专属样式表
export function createStyleSheet(css) {
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(css);
  return sheet;
}

Component Using Shared Styles

使用共享样式的组件

Note: When using
adoptedStyleSheets
, imperative shadow DOM creation is acceptable. This is the exception to the "no imperative shadow DOM" rule.
javascript
import { utopiaGridStyles, utopiaTypeStyles, createStyleSheet } from '../styles/shared-styles.js';

const componentStyles = createStyleSheet(`
  :host {
    display: block;
    container-type: inline-size;
  }

  /* Use :host([attr]) for attribute-based styling */
  :host([variant="primary"]) {
    --card-accent: var(--color-primary, #6366f1);
  }

  :host([variant="secondary"]) {
    --card-accent: var(--color-secondary, #8b5cf6);
  }

  .card {
    background: var(--card-bg, white);
    border: 2px solid var(--card-accent, #e5e7eb);
    border-radius: var(--space-xs);
    padding: var(--space-m);
  }

  .title {
    font-size: var(--step-1);
    font-weight: 700;
  }
`);

class FeatureCard extends HTMLElement {
  static get observedAttributes() {
    return ['title', 'variant'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    // Adopt shared + component styles (no duplication!)
    this.shadowRoot.adoptedStyleSheets = [
      utopiaGridStyles,
      utopiaTypeStyles,
      componentStyles
    ];

    // Now utility classes work inside shadow DOM
    this.shadowRoot.innerHTML = `
      <article class="card u-stack" part="container">
        <h3 class="title u-step-1" part="title"></h3>
        <slot></slot>
      </article>
    `;

    this._titleEl = this.shadowRoot.querySelector('.title');
  }

  connectedCallback() {
    this.addEventListener('click', this);
    this.render();
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    if (this.isConnected) this.render();
  }

  handleEvent(e) {
    if (e.type === 'click') {
      this.dispatchEvent(new CustomEvent('card-selected', {
        bubbles: true,
        composed: true,
        detail: { title: this.getAttribute('title') }
      }));
    }
  }

  render() {
    this._titleEl.textContent = this.getAttribute('title') || '';
  }
}

customElements.define('feature-card', FeatureCard);
注意: 使用
adoptedStyleSheets
时,允许使用命令式创建Shadow DOM。这是“禁止命令式Shadow DOM”规则的例外情况。
javascript
import { utopiaGridStyles, utopiaTypeStyles, createStyleSheet } from '../styles/shared-styles.js';

const componentStyles = createStyleSheet(`
  :host {
    display: block;
    container-type: inline-size;
  }

  /* 使用:host([attr])实现基于属性的变体样式 */
  :host([variant="primary"]) {
    --card-accent: var(--color-primary, #6366f1);
  }

  :host([variant="secondary"]) {
    --card-accent: var(--color-secondary, #8b5cf6);
  }

  .card {
    background: var(--card-bg, white);
    border: 2px solid var(--card-accent, #e5e7eb);
    border-radius: var(--space-xs);
    padding: var(--space-m);
  }

  .title {
    font-size: var(--step-1);
    font-weight: 700;
  }
`);

class FeatureCard extends HTMLElement {
  static get observedAttributes() {
    return ['title', 'variant'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    // 采用共享样式+组件样式(无重复!)
    this.shadowRoot.adoptedStyleSheets = [
      utopiaGridStyles,
      utopiaTypeStyles,
      componentStyles
    ];

    // 现在工具类可在Shadow DOM内生效
    this.shadowRoot.innerHTML = `
      <article class="card u-stack" part="container">
        <h3 class="title u-step-1" part="title"></h3>
        <slot></slot>
      </article>
    `;

    this._titleEl = this.shadowRoot.querySelector('.title');
  }

  connectedCallback() {
    this.addEventListener('click', this);
    this.render();
  }

  disconnectedCallback() {
    this.removeEventListener('click', this);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    if (this.isConnected) this.render();
  }

  handleEvent(e) {
    if (e.type === 'click') {
      this.dispatchEvent(new CustomEvent('card-selected', {
        bubbles: true,
        composed: true,
        detail: { title: this.getAttribute('title') }
      }));
    }
  }

  render() {
    this._titleEl.textContent = this.getAttribute('title') || '';
  }
}

customElements.define('feature-card', FeatureCard);

Key Patterns for Utopia Integration

Utopia集成的关键模式

  1. Always set
    container-type: inline-size
    on
    :host
    - Required for
    cqi
    units in Utopia fluid scales
  2. Use
    var()
    for all Utopia tokens
    - They pierce shadow DOM automatically:
    • Typography:
      var(--step-0)
      ,
      var(--step-1)
      ,
      var(--step--1)
    • Spacing:
      var(--space-xs)
      ,
      var(--space-m)
      ,
      var(--space-l)
    • Grid:
      var(--grid-gutter)
      ,
      var(--grid-max-width)
  3. Use
    :host([attribute])
    for variant styling
    - Attribute-driven state maps directly to CSS:
    css
    :host([size="small"]) { --internal-padding: var(--space-xs); }
    :host([size="large"]) { --internal-padding: var(--space-l); }
  4. Create component-scoped custom properties - Map Utopia tokens to semantic names:
    css
    :host {
      --card-padding: var(--space-m);
      --card-gap: var(--space-s);
      --title-size: var(--step-1);
    }
  5. Share CSSStyleSheet objects, not CSS strings -
    adoptedStyleSheets
    references the same object across all components
  1. 始终在
    :host
    上设置
    container-type: inline-size
    - Utopia流体刻度中使用
    cqi
    单位必需
  2. 所有Utopia令牌使用
    var()
    - 它们可自动穿透Shadow DOM:
    • 排版:
      var(--step-0)
      var(--step-1)
      var(--step--1)
    • 间距:
      var(--space-xs)
      var(--space-m)
      var(--space-l)
    • 网格:
      var(--grid-gutter)
      var(--grid-max-width)
  3. 使用
    :host([attribute])
    实现变体样式
    - 属性驱动状态直接映射到CSS:
    css
    :host([size="small"]) { --internal-padding: var(--space-xs); }
    :host([size="large"]) { --internal-padding: var(--space-l); }
  4. 创建组件作用域的自定义属性 - 将Utopia令牌映射为语义化名称:
    css
    :host {
      --card-padding: var(--space-m);
      --card-gap: var(--space-s);
      --title-size: var(--step-1);
    }
  5. 共享CSSStyleSheet对象而非CSS字符串 -
    adoptedStyleSheets
    在所有组件中引用同一个对象

Container Query Checklist

容器查询检查清单

When using Utopia's
cqi
-based fluid scales inside shadow DOM:
  • :host
    has
    container-type: inline-size
  • Parent elements in light DOM have
    container-type: inline-size
    where needed
  • Typography uses
    var(--step-*)
    tokens
  • Spacing uses
    var(--space-*)
    tokens
  • Component is tested at various container widths
在Shadow DOM内使用Utopia基于
cqi
的流体刻度时:
  • :host
    设置了
    container-type: inline-size
  • 轻量DOM中的父元素在需要时设置了
    container-type: inline-size
  • 排版使用
    var(--step-*)
    令牌
  • 间距使用
    var(--space-*)
    令牌
  • 已在不同容器宽度下测试组件

FOUC Prevention (Flash of Unstyled Content)

FOUC预防(未样式内容闪烁)

Custom elements are "undefined" until JavaScript loads and registers them via
customElements.define()
. During this window, browsers render raw content without shadow DOM styles, causing a visible flash (FOUC) and layout shift (CLS).
自定义元素在JavaScript加载并通过
customElements.define()
注册前处于“未定义”状态。在此期间,浏览器会渲染无Shadow DOM样式的原始内容,导致可见的闪烁(FOUC)和布局偏移(CLS)。

The Problem

问题

html
<!-- Before JavaScript loads, this shows raw slot content -->
<site-nav>
  <span slot="brand">My Site</span>
  <a slot="menu" href="/">Home</a>
</site-nav>
Without FOUC prevention:
  1. HTML parses, custom element renders as inline element with raw slot content
  2. JavaScript loads and defines the component
  3. Shadow DOM attaches, styles apply, component "snaps" into place
  4. Users see an ugly flash and layout shift
html
<!-- JavaScript加载前,会显示原始插槽内容 -->
<site-nav>
  <span slot="brand">我的网站</span>
  <a slot="menu" href="/">首页</a>
</site-nav>
未进行FOUC预防时:
  1. HTML解析完成,自定义元素以内联元素形式渲染,显示原始插槽内容
  2. JavaScript加载并定义组件
  3. Shadow DOM附加,样式生效,组件“突然”变为最终样式
  4. 用户会看到丑陋的闪烁和布局偏移

Solution 1: CSS
:not(:defined)
Selector (Required)

解决方案1:CSS
:not(:defined)
选择器(必需)

The
:defined
pseudo-class targets elements that have been registered via
customElements.define()
. Use
:not(:defined)
to hide or style elements before definition:
css
/* In global CSS (e.g., accessibility.css) */

/* Hide custom elements until JavaScript defines them */
site-nav:not(:defined),
nav-link-item:not(:defined) {
  opacity: 0;
}

/* Reserve layout space to prevent CLS */
site-nav:not(:defined) {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky, 100);
  min-height: calc(var(--nav-height, 56px) + var(--space-s) * 2);
  background: var(--theme-surface);
  border-block-end: 1px solid var(--theme-outline);
}

/* Instant visibility once defined */
site-nav:defined {
  opacity: 1;
}
Why
opacity: 0
instead of
display: none
?
  • opacity: 0
    preserves layout space, preventing CLS
  • display: none
    causes layout shift when element appears
  • visibility: hidden
    also works but can cause focus issues
:defined
伪类匹配已通过
customElements.define()
注册的元素。使用
:not(:defined)
在元素定义前隐藏或样式化元素:
css
/* 在全局CSS中(例如accessibility.css) */

/* 隐藏自定义元素直到JavaScript定义它们 */
site-nav:not(:defined),
nav-link-item:not(:defined) {
  opacity: 0;
}

/* 预留布局空间以防止CLS */
site-nav:not(:defined) {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky, 100);
  min-height: calc(var(--nav-height, 56px) + var(--space-s) * 2);
  background: var(--theme-surface);
  border-block-end: 1px solid var(--theme-outline);
}

/* 定义完成后立即显示 */
site-nav:defined {
  opacity: 1;
}
为什么使用
opacity: 0
而非
display: none
  • opacity: 0
    保留布局空间,防止CLS
  • display: none
    会在元素出现时导致布局偏移
  • visibility: hidden
    也可使用,但可能导致焦点问题

Solution 2: Module Preload (Recommended)

解决方案2:模块预加载(推荐)

Preload critical above-the-fold components to reduce time-to-definition:
html
<head>
  <!-- Styles first -->
  <link rel="stylesheet" href="/css/styles/index.css">

  <!-- Preload critical component scripts -->
  <link rel="modulepreload" href="/js/components/site-nav.js">
  <link rel="modulepreload" href="/js/components/nav-link-item.js">
  <link rel="modulepreload" href="/js/components/page-transition.js">

  <!-- Import maps -->
  <script type="importmap">...</script>
</head>
Benefits:
  • Browser starts downloading modules in parallel with HTML parsing
  • Reduces time between HTML render and component definition
  • Works with ES modules (
    type="module"
    scripts)
预加载首屏关键组件以缩短定义时间:
html
<head>
  <!-- 先加载样式 -->
  <link rel="stylesheet" href="/css/styles/index.css">

  <!-- 预加载关键组件脚本 -->
  <link rel="modulepreload" href="/js/components/site-nav.js">
  <link rel="modulepreload" href="/js/components/nav-link-item.js">
  <link rel="modulepreload" href="/js/components/page-transition.js">

  <!-- Import maps -->
  <script type="importmap">...</script>
</head>
优势:
  • 浏览器在解析HTML的同时并行下载模块
  • 缩短HTML渲染与组件定义之间的时间
  • 适用于ES模块(
    type="module"
    脚本)

Solution 3: Declarative Shadow DOM (Best)

解决方案3:声明式Shadow DOM(最佳)

For server-rendered or static sites, use Declarative Shadow DOM for instant styling:
html
<site-nav>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        position: fixed;
        top: 0;
        /* ... all component styles inline ... */
      }
    </style>
    <nav>
      <slot name="brand"></slot>
      <slot name="menu"></slot>
    </nav>
  </template>

  <!-- Light DOM content -->
  <span slot="brand">My Site</span>
  <a slot="menu" href="/">Home</a>
</site-nav>
Benefits:
  • Styles apply immediately during HTML parsing
  • No JavaScript required for initial render
  • Best possible CLS score
  • JavaScript enhances (adds interactivity) but doesn't enable styling
对于服务端渲染或静态站点,使用声明式Shadow DOM实现即时样式:
html
<site-nav>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        position: fixed;
        top: 0;
        /* ... 所有组件样式内联 ... */
      }
    </style>
    <nav>
      <slot name="brand"></slot>
      <slot name="menu"></slot>
    </nav>
  </template>

  <!-- 轻量DOM内容 -->
  <span slot="brand">我的网站</span>
  <a slot="menu" href="/">首页</a>
</site-nav>
优势:
  • 样式在HTML解析期间立即生效
  • 初始渲染无需JavaScript
  • 获得最佳CLS分数
  • JavaScript仅用于增强(添加交互性),而非启用样式

Implementation Checklist

实现检查清单

For every custom element in your application:
  • Add
    :not(:defined)
    CSS rule in global stylesheet
  • Reserve layout space for fixed/positioned elements
  • Add
    modulepreload
    for above-the-fold components
  • Consider Declarative Shadow DOM for critical-path components
  • Test with network throttling to verify no FOUC
  • Measure CLS in Lighthouse/PageSpeed Insights
应用中的每个自定义元素都需:
  • 在全局样式表中添加
    :not(:defined)
    CSS规则
  • 为固定/定位元素预留布局空间
  • 为首屏组件添加
    modulepreload
  • 考虑为关键路径组件使用声明式Shadow DOM
  • 通过网络节流测试以验证无FOUC
  • 在Lighthouse/PageSpeed Insights中测量CLS

Project Pattern

项目模式

This project handles FOUC in
css/styles/accessibility.css
:
css
/* Navigation - reserve space and hide content */
site-nav:not(:defined) {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky, 100);
  min-height: calc(var(--nav-height, 56px) + var(--space-s, 0.75rem) * 2);
  background: var(--theme-surface, #1f1b15);
  border-block-end: 1px solid var(--theme-outline, #5c4d3d);
  opacity: 0;
}

/* Cards and grids - hide until ready */
filterable-grid:not(:defined),
word-card:not(:defined) {
  opacity: 0;
}
And in HTML
<head>
:
html
<!-- Modulepreload critical components to prevent FOUC -->
<link rel="modulepreload" href="/js/components/site-nav.js">
<link rel="modulepreload" href="/js/components/nav-link-item.js">
<link rel="modulepreload" href="/js/components/page-transition.js">
本项目在
css/styles/accessibility.css
中处理FOUC:
css
/* 导航 - 预留空间并隐藏内容 */
site-nav:not(:defined) {
  display: block;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky, 100);
  min-height: calc(var(--nav-height, 56px) + var(--space-s, 0.75rem) * 2);
  background: var(--theme-surface, #1f1b15);
  border-block-end: 1px solid var(--theme-outline, #5c4d3d);
  opacity: 0;
}

/* 卡片和网格 - 准备就绪前隐藏 */
filterable-grid:not(:defined),
word-card:not(:defined) {
  opacity: 0;
}
并在HTML
<head>
中:
html
<!-- 预加载关键组件以防止FOUC -->
<link rel="modulepreload" href="/js/components/site-nav.js">
<link rel="modulepreload" href="/js/components/nav-link-item.js">
<link rel="modulepreload" href="/js/components/page-transition.js">

Anti-Pattern: Generic Wildcard

反模式:通用通配符

Avoid hiding all undefined elements with a wildcard:
css
/* DON'T DO THIS - breaks progressive enhancement */
*:not(:defined) {
  display: none;
}
This breaks elements that should be visible during progressive enhancement (forms, links, etc.). Instead, explicitly list the components that need FOUC prevention.
避免使用通配符隐藏所有未定义元素:
css
/* 不要这样做 - 破坏渐进式增强 */
*:not(:defined) {
  display: none;
}
这会破坏渐进式增强期间应可见的元素(表单、链接等)。相反,应明确列出需要FOUC预防的组件。

When to Use This Pattern

何时使用此模式

  • Building reusable UI components
  • Creating design systems
  • Server-side rendering (SSR) with hydration
  • Progressive web apps (PWAs)
  • Micro-frontends
  • Accessibility-first applications
  • 构建可复用UI组件
  • 创建设计系统
  • 服务端渲染(SSR)与水化
  • 渐进式Web应用(PWA)
  • 微前端
  • 优先考虑可访问性的应用

When NOT to Use

何时不使用

  • Simple static websites (use plain HTML)
  • Heavy data visualization (consider Canvas/WebGL)
  • Applications requiring IE11 support (no polyfills allowed)
  • 简单静态网站(使用纯HTML)
  • 重型数据可视化(考虑Canvas/WebGL)
  • 需要支持IE11的应用(不允许使用polyfill)

Instructions for Implementation

实现指南

When implementing web components:
  1. Choose Element Type: Prefer customized built-ins over autonomous elements
  2. Define State Contract: Document all attributes and events
  3. Use HandleEvent: Implement
    handleEvent()
    for event delegation
  4. Avoid DOM Selection: NEVER use
    querySelector
    or similar methods
  5. Use DSD: Implement Declarative Shadow DOM for instant rendering
  6. Expose Styling API: Use CSS custom properties and
    ::part()
  7. Implement Accessibility: Add ARIA attributes and keyboard support
  8. Test Progressive Enhancement: Verify behavior with JavaScript disabled
  9. Document Usage: Provide clear HTML examples
实现Web Components时:
  1. 选择元素类型:优先选择定制内置元素而非自主元素
  2. 定义状态契约:记录所有属性和事件
  3. 使用HandleEvent:实现
    handleEvent()
    进行事件委托
  4. 避免DOM选择:绝对禁止使用
    querySelector
    或类似方法
  5. 使用DSD:实现声明式Shadow DOM以实现即时渲染
  6. 暴露样式API:使用CSS自定义属性和
    ::part()
  7. 实现可访问性:添加ARIA属性和键盘支持
  8. 测试渐进式增强:验证JavaScript禁用时的行为
  9. 记录用法:提供清晰的HTML示例

File Organization

文件组织

components/
├── async-action/
│   ├── async-action.js
│   ├── async-action.test.html
│   └── README.md
├── product-card/
│   ├── product-card.js
│   ├── product-card.test.html
│   └── README.md
└── index.js (exports all components)
components/
├── async-action/
│   ├── async-action.js
│   ├── async-action.test.html
│   └── README.md
├── product-card/
│   ├── product-card.js
│   ├── product-card.test.html
│   └── README.md
└── index.js(导出所有组件)

References

参考资料