K 的一隅

浏览器 浏览器核心机制

不用框架也能做组件:Web Components 的原生边界

从可插拔的标准柜子说起,理解 Custom Elements、Shadow DOM、模板与 slot,以及它和 Vue 组件的关系。

5 分钟阅读 更新于 2026-07-24
本文目录

一家店想把收银台、菜单牌和取餐屏做成可以搬走的标准柜子。柜子内部怎么布线由它自己决定,外部只需要知道插口和使用方法。Web Components(网页组件标准)提供了类似的浏览器原生组件边界:组件有自己的标签、生命周期和样式隔离方式,不要求页面先选 Vue 或 React。

四块积木如何拼起来

Custom Elements 负责注册标签和生命周期,Shadow DOM 提供封装边界,<template> 保存尚未插入页面的结构,slot 让外部内容进入组件预留位置。它们可以组合使用,也不是必须一次全部采用。

从标签开始定义组件

js
class UserCard extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <article>
        <slot name="avatar"></slot>
        <strong><slot></slot></strong>
      </article>
    `
  }
}

customElements.define('user-card', UserCard)

自定义元素名称必须包含连字符,避免和未来的 HTML 原生标签冲突。connectedCallback 表示元素进入文档,disconnectedCallback 适合清理监听器,attributeChangedCallback 可以响应声明过的属性变化。

Shadow DOM 隔离了什么

js
class NoticeBox extends HTMLElement {
  constructor() {
    super()
    const root = this.attachShadow({ mode: 'open' })
    root.innerHTML = `
      <style>p { color: tomato; }</style>
      <p><slot>默认提示</slot></p>
    `
  }
}

customElements.define('notice-box', NoticeBox)

Shadow DOM 的样式选择器默认不会穿过边界,页面外部的 p {} 也不会直接改写内部 pmode: 'open' 允许通过 element.shadowRoot 观察和调试,closed 隐藏引用但不等于真正的安全隔离。组件仍然运行在同一个页面和同一个 JavaScript 环境里。

slot 是内容插口

html
<user-card>
  <span slot="avatar">头像</span>
  小 K
</user-card>

slot 让组件决定结构,使用者决定部分内容。组件内部可以监听 slotchange,但不要把 slot 当作任意模板注入机制;数据和事件边界仍然需要设计。自定义元素的属性通常是字符串,复杂对象可以通过属性配合属性、方法或自定义事件传递。

它和 Vue 组件怎么相处

Vue 组件有响应式系统、模板编译、组合式 API 和框架级生命周期;Web Components 提供跨框架、跨页面的浏览器级标签和封装边界。Vue 可以使用自定义元素,自定义元素也可以被普通 HTML 使用,但两者的数据绑定、事件命名和样式穿透规则不完全相同。

如果组件只服务一个 Vue 应用,直接使用 Vue 组件通常更简单。如果要把一个日期选择器、地图控件或设计系统组件交给不同技术栈复用,Web Components 的原生边界更有价值。不要为了“无框架”而牺牲项目已有的响应式和测试能力。

生命周期和属性观察

自定义元素的生命周期是浏览器给出的连接点:元素插入文档时执行 connectedCallback,从文档移除时执行 disconnectedCallback。如果组件注册了 observedAttributes,对应属性发生变化时会调用 attributeChangedCallback。这些回调可能多次发生,所以初始化和清理都要可重复,不能每次连接都累加一个永远不移除的事件监听器。

属性适合表达字符串化的配置,例如 variant="warning";复杂数据可以通过公开方法、属性赋值或自定义事件传递。不要把所有对象都 JSON.stringify 后塞进 HTML 属性,这会让更新、类型和错误处理变得模糊。

事件如何穿过组件边界

组件内部可以派发自定义事件:

js
this.dispatchEvent(new CustomEvent('remove', {
  bubbles: true,
  composed: true,
  detail: { id: this.dataset.id },
}))

bubbles 决定事件是否向祖先冒泡,composed 决定它能否穿过 Shadow DOM 边界。一个事件是否对外公开,应当像组件方法一样被设计;不要把内部点击、内部 DOM 节点直接暴露给使用者。

样式隔离也需要协议

Shadow DOM 内部样式默认隔离,但组件仍可通过 CSS 自定义属性接收主题变量,例如 --notice-color::part 可以把明确标记的内部部件开放给外部样式,::slotted() 可以选择插槽进来的元素。它们都是有边界的协议,比让使用者依赖内部 class 名称更稳定。

这也意味着设计系统需要提前约定颜色、间距、状态和可访问名称。一个组件如果只在默认主题下好看,却无法响应宿主页面的字体、焦点和暗色模式,封装就变成了隔离问题,而不是解决问题。

可访问性不会自动获得

自定义元素不会因为名字里有连字符就自动拥有按钮、输入框或对话框语义。需要根据功能选择合适的原生元素,补充 role、键盘操作、焦点管理和可见状态。能用原生 <button> 就不要把点击事件挂在一个没有键盘行为的 <div> 上。

Web Components 的价值是提供浏览器级的封装和分发边界,不是替你完成组件设计。越接近通用组件,越要把属性、事件、插槽、样式变量和无障碍行为写成稳定的公开契约。

判断是否采用它时,可以先问三个问题:组件是否需要跨框架复用,边界是否需要由浏览器而不是应用框架维护,公开契约是否已经稳定。如果三个答案都是否定的,项目内的 Vue 组件通常更直接;如果答案明确,原生组件才真正有发挥空间。

组件一旦发布,属性、事件和样式变量就会成为长期契约。先把契约写清楚,再决定要不要用 Web Components;这比组件内部用了哪种语法更重要。

容易踩的坑

Shadow DOM 不会把组件变成“完全绝缘的黑箱”:事件仍可能跨边界冒泡,只是 target 与事件路径会按封装规则改写。自定义元素也不能随便起大写标签名——规范要求名称含连字符,而且要考虑注册前后的升级时机。更关键的是预期:Web Components 解决封装与分发边界,不自动给你应用级状态管理;跨框架复用前,先把属性、事件、插槽和样式变量写成稳定契约。