Web Components 实战:用原生自定义元素构建可复用 UI

Web Components 是一套浏览器原生支持的组件标准,让你无需引入任何框架,就能构建出可在任意项目里复用的 UI 组件。当团队同时维护 Vue、React 与原生页面时,用 Web Components 沉淀一套统一的设计资产,能显著降低重复造轮子与样式冲突的成本。

一、为什么需要 Web Components

现代前端往往是多框架并存:后台用 Vue,营销页用 React,老系统还停留在 jQuery。同一套按钮、日期选择器、图表卡片,在每个技术栈里都要重写一遍,样式还容易互相污染。Web Components 的出现就是为了解决「跨框架复用」这一顽疾——它把组件封装成浏览器原生认识的自定义元素,任何页面只要引入一段 JS,就能像 <user-card></user-card> 这样直接当原生标签使用,与宿主框架彻底解耦。

举个真实的场景:同一个「用户头像卡片」在 Vue 后台、React 落地页、原生 jQuery 旧站里被写了三遍,每次设计改版都要同步三处,稍有遗漏就出现样式错位。把它改造成一个 <user-card> 之后,三端只需各自引入一段脚本,改一处源码即可全站统一生效,维护成本直接降了一个维度。

二、三大核心基石

Web Components 并非单一 API,而是三套标准的组合,各自解决一个关键问题:

标准作用解决的问题
Custom Elements注册自定义标签并绑定行为让浏览器识别并实例化你的组件
Shadow DOM把 DOM 与样式封装在独立作用域彻底隔离样式,避免全局污染
HTML Template / Slot声明可复用的结构模板与插槽结构复用与内容分发

三者配合,就能交付一个「自带样式、跨项目可用、不依赖任何运行时」的组件。

三、实战:从零定义 <user-card>

3.1 用 class 定义组件与生命周期

每个 Web Component 都是一个继承自 HTMLElement 的类,通过 customElements.define 注册标签名。标签名必须包含连字符,这是规范强制的命名约定。

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }); // 开启 Shadow DOM
  }
  connectedCallback() {
    this.render(); // 元素插入 DOM 时触发
  }
  render() {
    this.shadowRoot.innerHTML = '<p>Hello Web Components</p>';
  }
}
customElements.define('user-card', UserCard);

3.2 Shadow DOM 封装样式

把模板和样式写进 shadowRoot,外部 CSS 无论如何都影响不到组件内部——这正是「样式隔离」的核心。组件自身用 :host 选择宿主元素。

connectedCallback() {
  this.shadowRoot.innerHTML = `
    <style>
      :host { display: block; font-family: system-ui; }
      .card { padding: 16px; border-radius: 12px; background: #f5f7ff; }
      .name { color: #3b5bff; font-weight: 600; }
    </style>
    <div class="card">
      <span class="name"></span>
    </div>
  `;
}

3.3 用 <template> 与 <slot> 做内容分发

模板放进 <template> 后不会立即渲染,克隆进 Shadow DOM 即可复用;<slot> 则允许外部传入内容,实现「插槽」式组合,兼具封装与灵活。

<template id="card-tpl">
  <style>.card{border:1px solid #eee;padding:12px}</style>
  <div class="card">
    <slot name="avatar"></slot>
    <slot>默认内容</slot>
  </div>
</template>
<script>
  const tpl = document.getElementById('card-tpl');
  class Card extends HTMLElement {
    constructor(){ super(); this.attachShadow({mode:'open'}); }
    connectedCallback(){
      this.shadowRoot.appendChild(tpl.content.cloneNode(true));
    }
  }
  customElements.define('x-card', Card);
</script>

四、属性反射与响应式更新

通过 observedAttributes 声明要监听的属性,浏览器会在属性变化时回调 attributeChangedCallback,组件据此更新视图,实现「属性驱动」的响应式。

class UserCard extends HTMLElement {
  static get observedAttributes() { return ['name', 'role']; }
  attributeChangedCallback(name, oldV, newV) {
    if (oldV === newV) return;
    if (name === 'name') {
      this.shadowRoot.querySelector('.name').textContent = newV;
    }
  }
}
// 使用:<user-card name="小李" role="admin"></user-card>

这里有个新手极易踩的坑:HTML 属性(attribute)和 JS 属性(property)是两回事。用户在 DOM 上写 name="小李" 是 attribute,组件内部通常通过 this.getAttribute('name') 读取;而直接给元素赋值 el.name='小李' 则是 property,它不会自动触发 attributeChangedCallback。如果你需要属性与特性双向同步,记得在 setter 里手动调用 setAttribute,否则外部赋值不会反映到视图上。

五、与 Vue / React 协作

Web Components 的最大魅力就是「框架无关」。在 Vue3 组合式 API 或 React Server Components 项目里,你只需 import 一段定义脚本,就能像原生标签一样使用它,无需任何适配器。对于需要统一视觉语言的团队,配合 Tailwind CSS 原子化方案 做主题 token 注入也很顺手。

// Vue3 单文件组件中直接当标签用
import './user-card.js';
// 模板:<user-card :name="user.name"></user-card>

// React(含 RSC / 客户端组件)
import './user-card.js';
function Page() {
  return <user-card name="小李" />;
}

在 VS Code 里开发组件时,装好 几款提效插件 能让自定义元素的智能提示与片段补全更顺手。

六、样式隔离的坑与 :host / ::slotted

Shadow DOM 虽好,也有边界:外部样式默认进不来,组件内部样式也出不去。想让外部控制主题,要靠 :host 接收继承的 CSS 变量;想给插槽内容设样式,用 ::slotted() 且仅能选中顶层节点。千万别指望用全局 class 去改组件内部——那是设计上就禁止的。

如果确实想让外部主题切入组件,最干净的做法是在 :host 里读取 CSS 自定义属性(例如 --card-bg),由外部在宿主元素上赋值。这样既保持了样式隔离,又留了一个可控的调节口:外部只改变量、不碰内部结构,组件作者也无需为每种主题写分支。

七、生产落地检查清单

检查项建议
命名必须含连字符,避免与未来标准标签冲突
样式隔离用 :host 控制外部样式入口,少写全局副作用
可访问性提供 role / aria 与键盘交互,别只支持鼠标
构建打包用 Vite 库模式输出 ES module,便于按需引入
兼容性旧浏览器引入 @webcomponents polyfills 兜底

八、调试技巧与浏览器兼容性

开发时打开 Chrome DevTools 的「Elements → Shadow DOM」面板,就能直接展开组件内部节点、逐层审查样式的真实来源;如果组件没有渲染,优先确认 customElements.whenDefined('user-card') 是否已经 resolve,以及定义脚本是否在 DOMContentLoaded 之前就被加载执行。兼容性方面,Custom Elements 与 Shadow DOM 自 2020 年起就已被所有主流浏览器原生支持,只有极少数老旧 WebView 才需要 @webcomponents polyfills 兜底,因此可以放心用于生产环境。

九、什么时候不适合用 Web Components

Web Components 并不是银弹。如果你的应用只跑在单一框架里、且不需要把组件对外输出,那么框架自带的组件体系开发体验更好、生态也更完整;此外,像 React Server Components 这类「在服务端渲染、不携带客户端运行时」的场景,自定义元素也很难发挥作用。判断标准其实很简单:当你需要「一次编写、多处、跨框架复用」时再上 Web Components,否则不要为了用而用,避免引入额外的心智负担。

十、小结

Web Components 用浏览器原生能力补齐了「跨框架复用 UI」的最后一公里:Custom Elements 负责定义、Shadow DOM 负责隔离、Template/Slot 负责结构复用。它没有虚拟 DOM、没有运行时依赖,特别适合做设计系统、嵌入式 widget 与多技术栈共存下的公共组件。下次当你准备在三个项目里第三次重写同一个下拉框时,不妨先想想:它能不能是一个 <user-card>?

上一篇 jOOQ 实战:用类型安全的方式写 SQL
下一篇 Pulumi 实战:用 TypeScript 定义云基础设施
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]