Web Component 开发入门:创建可复用的自定义 HTML 元素

Web Component 允许你创建自定义的 HTML 元素,它们样式封装、行为独立、可在任何项目中使用——无论项目使用 React、Vue 还是原生 HTML。它是一组浏览器原生 API,不需要编译、不需要引入框架运行时,写出来的组件天然跨项目复用。

如果你维护过多个技术栈的项目,一定体会过这种痛苦:同一个"按钮"在 React 项目里是一个组件、在 Vue 项目里是另一个组件、在服务端渲染的页面里还得手写一遍 HTML。Web Component 的思路完全不同——组件就是浏览器的一部分,谁都能直接用。

核心技术

Web Component 由三项核心技术组成:

  1. Custom Elements — 定义新的 HTML 元素
  2. Shadow DOM — 样式和行为封装
  3. HTML Templates — 定义可复用的 HTML 结构

Custom Elements 的生命周期

自定义元素有四个标准的生命周期回调,理解它们才能写出健壮组件:

回调 触发时机 典型用途
connectedCallback() 元素被插入文档 绑定事件、发起数据请求
disconnectedCallback() 元素被移出文档 解绑事件、清理定时器
attributeChangedCallback() 监听属性变化 响应属性更新重新渲染
adoptedCallback() 元素被移动到新文档 少见,跨文档迁移时用

注意,connectedCallback() 可能被多次调用(比如元素在文档里被搬来搬去),所以事件绑定要在里面做、解绑要在 disconnectedCallback() 里做,否则会重复绑定。

创建一个 Web Component

// 自定义按钮组件
class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

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

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    const variant = this.getAttribute('variant') || 'primary';
    const disabled = this.hasAttribute('disabled') ? 'disabled' : '';

    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 10px 20px;
          border: none;
          border-radius: 6px;
          cursor: pointer;
          font-size: 16px;
        }
        button.primary {
          background: #4F46E5;
          color: white;
        }
        button.primary:hover {
          background: #4338CA;
        }
        button.outline {
          background: transparent;
          border: 2px solid #4F46E5;
          color: #4F46E5;
        }
        button[disabled] {
          opacity: 0.6;
          cursor: not-allowed;
        }
      </style>
      <button class="${variant}" ${disabled}>
        <slot></slot>
      </button>
    `;

    // 事件处理
    this.shadowRoot.querySelector('button')
      .addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('my-click', {
          detail: { id: this.id }
        }));
      });
  }
}

// 注册自定义元素
customElements.define('my-button', MyButton);

使用自定义元素

<my-button variant="primary" id="submit-btn">提交</my-button>
<my-button variant="outline">取消</my-button>
<my-button disabled>已禁用</my-button>

<script>
  document.querySelector('#submit-btn')
    .addEventListener('my-click', (e) => {
      console.log('Button clicked:', e.detail.id);
    });
</script>

更复杂的例子:计数器组件

class Counter extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.count = 0;
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('#inc')
      .addEventListener('click', () => this.update(1));
    this.shadowRoot.querySelector('#dec')
      .addEventListener('click', () => this.update(-1));
  }

  update(amount) {
    this.count += amount;
    this.shadowRoot.querySelector('#value').textContent = this.count;
    this.dispatchEvent(new CustomEvent('count-change', {
      detail: { count: this.count }
    }));
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-flex; align-items: center; gap: 12px; }
        button {
          width: 36px; height: 36px;
          border: 1px solid #ccc;
          border-radius: 50%;
          background: white;
          cursor: pointer;
          font-size: 18px;
        }
        span { min-width: 30px; text-align: center; font-size: 20px; }
      </style>
      <button id="dec">-</button>
      <span id="value">0</span>
      <button id="inc">+</button>
    `;
  }
}

customElements.define('my-counter', Counter);
<my-counter></my-counter>
<my-counter></my-counter>

Web Component 的优势

特性 说明
框架无关 在任何项目中都能使用
样式隔离 Shadow DOM 确保样式不冲突
原生支持 无需编译、无需 polyfill(现代浏览器)
可复用 一次编写,处处使用
封装 HTML、CSS、JS 打包在一个文件中

一个设计系统场景

假设公司有三套产品:管理后台用 React,官网用 Vue,营销页是直接渲染的 HTML。过去要做统一按钮样式,就得在三个技术栈里各维护一份;现在把 <my-button><my-counter> 这些组件封装成 Web Component 发布到内部 npm 包,三个项目只需要引入脚本标签:

<script type="module" src="/components/my-button.js"></script>
<my-button variant="primary">提交订单</my-button>

改样式只改组件一处,全站同步更新;升级组件版本也只需改一个版本号。这正是 Web Component 在大型组织中流行的核心原因——组件不再是"某个框架的东西",而是团队共同的基础设施。

如果你所在团队正打算统一多端组件,不妨先拿一个高频组件(比如按钮、弹窗)试点:在三个项目里替换掉各自的实现,跑一两个迭代再决定要不要全面铺开。风险小,收益可衡量。

什么时候用 Web Component

  • 多框架团队共享组件库:React、Vue、Angular 并存时,用 Web Component 做共享 UI 层,各框架都能直接 <my-card> 一样使用;
  • 长期维护的组件:框架更新换代快,但浏览器 API 很稳定,投资在 Web Component 上的组件十年后依然可用;
  • 渐进增强:在服务端渲染页面里嵌入交互组件,不需要整个页面重构成 SPA。

不太适合的场景:重度依赖框架生态的组件(比如需要访问 React Context)、需要服务端渲染 SEO 的整页内容——这时 Web Component 的"运行时渲染"反而是短板。

兼容性与最佳实践

现代浏览器(Chrome、Edge、Firefox、Safari)都原生支持 Web Component,无需 polyfill。但要注意:

  • customElements.define() 注册后,同一个名字不能重复注册,组件库要注意命名冲突;
  • 大量同步渲染大量组件时,考虑用 connectedCallback 里的异步或 requestAnimationFrame 分批更新,避免首屏卡顿;
  • 给组件加 observedAttributes 时,记得同步更新 attributeChangedCallback 里的渲染逻辑,否则属性变了界面不变;
  • 表单类组件要注意 formAssociated 与原生表单联动,否则提交时拿不到值。

常见问题

Web Component 支持 SEO 吗? 组件内部通过 Shadow DOM 渲染的内容,搜索引擎的抓取能力在不断完善,但关键正文建议同时放在组件标签内或使用 declarative shadow DOM 服务端输出,避免完全依赖运行时渲染。

能在老浏览器用吗? IE 已退出历史舞台;如果你仍需要支持旧版 Edge 或很老的内核,可以引入 @webcomponents/webcomponentsjs polyfill,但绝大多数现代站点已不需要。

和 Vue/React 的组件冲突吗? 不冲突。框架组件是"框架内部的语言",Web Component 是"浏览器原生的元素",两者可以嵌套混用,这也是很多团队做"框架无关 UI 库"的底气。

16IDC 观察

Web Component 是最佳的前端组件封装方式——不依赖任何框架,可以在任何项目中复用。对于设计系统(Design System)和 UI 组件库来说,Web Component 是比框架特定组件更长寿的选择。如果你的团队需要维护多个不同技术栈的项目,Web Component 是一次编写、处处运行的最佳实践。