Web Components 实战:从零封装可复用组件

Web Components 是一组浏览器原生标准,包含 Custom Elements、Shadow DOM、HTML Template 与 ES Modules。它让你在不引入任何前端框架的前提下,封装出可跨项目、跨框架复用的 UI 组件。在微前端、统一设计系统、旧系统渐进式改造等场景里,原生组件化正变得越来越吃香。

一、为什么还需要原生 Web Components

Vue 单文件组件、React 函数组件很好用,但它们强绑定框架运行时:一旦团队换了技术栈,组件往往要重写一遍;当公司想把同一套设计系统同时服务于 React、Vue、Angular 多个项目时,重复开发的成本会陡增。Web Components 把组件能力下沉到浏览器本身,产出的就是一个标准 HTML 标签,任意框架都能像写 <user-card> 一样直接复用。它不取代框架,而是补齐了「跨框架复用」这一层能力。

二、Web Components 的四大基石

2.1 Custom Elements:定义你自己的标签

Custom Elements 让你把一个继承自 HTMLElement 的类注册成新的 HTML 标签。标签名必须包含连字符(如 user-card),以避免和未来的原生标签冲突。

2.2 Shadow DOM:样式与 DOM 隔离

Shadow DOM 为组件创建一个独立的 DOM 子树,内部的样式和节点默认不会泄露到外面,外部样式也不会误伤内部。这正是组件样式隔离的关键。

2.3 HTML Template:可复用的标记片段

<template> 里的标记不会被渲染,却能被 cloneNode 复制进影子根,避免每次都用字符串拼 HTML。

2.4 ES Modules:组件即模块

组件逻辑写成一个 ES Module,注册一次即可全局可用,配合打包工具还能做按需加载与 Tree Shaking。

三、动手封装一个 user-card 组件

先写一个最小可用的类:定义标签、在 connectedCallback 里渲染。注意构造函数里必须先调用 super(),否则浏览器会直接抛错。

class UserCard extends HTMLElement {
  constructor() {
    super();              // 必须先调用,否则报错
    this._name = '匿名用户';
  }

  // 元素被插入文档时触发,适合做初始化渲染
  connectedCallback() {
    this.render();
  }

  render() {
    this.innerHTML = `<section>你好,${this._name}</section>`;
  }
}

// 注册自定义元素:标签名必须含连字符
customElements.define('user-card', UserCard);

注册完成后,页面上任何位置写 <user-card></user-card> 都会渲染出「你好,匿名用户」。这就是一个零依赖的原生组件。

四、用 Shadow DOM 做样式隔离

上面的写法把内容直接写进 innerHTML,样式会和全局混在一起。更稳妥的做法是 attachShadow 创建影子根,再 clone 一个带 <style> 的 template 进去。影子根里的 :host 选择器用来给组件自身(即标签)设置样式。

class UserCard extends HTMLElement {
  connectedCallback() {
    // 创建影子根,开启样式隔离
    const shadow = this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template');
    tpl.innerHTML = `
      <style>
        :host { display: block; font-family: system-ui; }
        .card { padding: 12px; border-radius: 8px; background: #f5f7fa; }
        .name { color: #2563eb; font-weight: 600; }
      </style>
      <div class="card">
        <span class="name"></span>
      </div>`;
    shadow.appendChild(tpl.content.cloneNode(true));
    shadow.querySelector('.name').textContent =
      this.getAttribute('name') || '匿名用户';
  }
}
customElements.define('user-card', UserCard);

现在即使页面全局有 .name 的样式,也不会影响到组件内部——这正是微前端里多个团队并行开发却不互相污染样式的基础。组件内部的响应式布局可配合 CSS 容器查询 进一步做基于容器宽度的适配。

五、属性变化如何响应:observedAttributes

HTML 组件通常通过特性(attribute)传参。声明 observedAttributes 后,对应特性变化会触发 attributeChangedCallback,你可以在这里做响应式更新。

class UserCard extends HTMLElement {
  // 声明要监听的属性
  static get observedAttributes() {
    return ['name', 'avatar'];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    if (name === 'name') this._updateName(newVal);
    if (name === 'avatar') this._updateAvatar(newVal);
  }
}

这样外部改 <user-card name="张三"> 时,组件会自动重渲染对应部分,无需手动调方法。

六、生命周期回调一览

Custom Elements 提供五个标准回调,理解它们才能写好初始化与清理逻辑:

回调触发时机典型用途
constructor元素被 new 或解析器创建初始化状态、绑定字段
connectedCallback插入文档渲染、拉数据、加监听
disconnectedCallback移出文档解绑监听、清定时器
attributeChangedCallback监听的属性变化响应式更新视图
adoptedCallback被 moveTo 到别的文档跨文档适配(极少用)

七、和 Vue / React 组件化到底差在哪

Web Components 不是要取代框架,而是补上「跨框架复用」这块拼图。下表从几个维度对比:框架组件适合单应用内快速开发,Web Components 适合做成可被任意技术栈消费的标准零件。关于 Vue 的组件化范式可参考 Vue3 + Pinia 实战,关于 React 的渲染模型可看 React Server Components 实战

维度Vue / React 组件Web Components
依赖需框架运行时浏览器原生,零依赖
复用边界同一框架内部任意框架 / 纯 HTML
样式隔离scoped / CSS-in-JSShadow DOM 原生隔离
典型场景单应用快速开发跨栈设计系统、微前端

八、真实项目里怎么落地

第一,微前端:每个子应用把挂载点导出成一个 Web Component,主框架只负责排布,子应用互不干扰。第二,设计系统:按钮、表单、卡片等基础元件一次开发,React、Vue、Angular 项目都能直接引用,彻底消灭「每栈一套 UI 库」的重复。第三,旧系统渐进式改造:在老页面里嵌入一个新的原生组件提供新功能,不必整页重写。想让首屏更快,可结合 前端性能优化 的思路给组件做懒加载。

九、踩坑与排查

最容易踩的坑有三个。其一,Shadow DOM 内部的事件默认不会冒泡到外部,要跨影子边界必须给 CustomEvent 设置 composed: true。其二,:host 之外的全局样式进不去影子根,组件没样式时先确认选择器是否写在影子内部。其三,DevTools 默认折叠影子节点,需在设置里勾选 ‘Show user agent shadow DOM’ 才能看到内部 DOM 便于调试。

// 1) 影子内部的事件要冒泡到外部,必须设置 composed:true
this.dispatchEvent(new CustomEvent('card-click', {
  bubbles: true,
  composed: true,        // 关键:允许穿过影子边界
  detail: { id: this.dataset.id }
}));

// 2) 外部监听(即便事件来自影子内部也能收到)
document.querySelector('user-card')
  .addEventListener('card-click', (e) => {
    console.log('点击了', e.detail.id);
  });

// 3) 调试:DevTools 勾选 "Show user agent shadow DOM" 才能看到内部节点
// 4) 样式不生效?检查 :host 外是否写了会冲突的全局选择器

十、兼容性与渐进增强

现代浏览器(Chrome、Edge、Safari、Firefox 新版本)已原生支持全部能力;对老浏览器可用官方 polyfill @webcomponents/webcomponentsjs 兜底。推荐做法是渐进增强:组件不可用时降级为普通标签的静态内容,保证核心信息始终可读。这样既能享受原生组件化的红利,又不牺牲老用户的可用性。

补充一点:Web Components 与框架并非二选一。很多团队会在底层用 Web Components 沉淀不变量(设计令牌、基础控件),上层再用框架组装业务页面,两者各取所长。当你需要在多个项目间共享同一份组件实现时,原生标准往往比「再写一遍」更省心。

上一篇 语义缓存实战:用向量相似度给大模型 API 降本
下一篇 K8s 持久化存储:PV/PVC 与 StorageClass