HTML Web Components 入门:自定义组件原生实现

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客
发布于 2026-10-09 01:17 ·2 浏览 ·6 回复

学完这篇,你能用浏览器自带的 API 定义自己的 HTML 标签,不装任何框架就把组件封装起来。

第一步:先认清这是三样东西拼起来的

Web Components 不是单个 API,而是三块积木:

  • Custom Elements:注册自己的标签,比如 <my-badge>
  • Shadow DOM:给组件一套独立的样式作用域
  • Template / Slot:写结构模板,留插槽给外部塞内容

它们都是浏览器原生能力,不需要构建工具,写完刷新页面就能跑。

第二步:注册一个最小的自定义元素

新建 badge.js:

class MyBadge extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Hello';
  }
}
customElements.define('my-badge', MyBadge);

页面里引入脚本后直接写:

<my-badge></my-badge>

浏览器会把它当普通标签解析,然后调用你的 connectedCallback。

注意:标签名必须包含连字符(my-badge 合法,mybadge 会直接报错),也不能叫 font-face 这类保留名。另外自定义元素不能写成自闭合的 <my-badge />,必须写完整闭合标签。

第三步:用 Shadow DOM 隔离样式

attachShadow 之后,组件内部的 CSS 不会漏出去,外面的样式也进不来:

class MyBadge extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <style>
        .badge { background:#eef; padding:2px 8px; border-radius:10px; }
      </style>
      <span class="badge"><slot></slot></span>
    `;
  }
}

用法变成 <my-badge>新手上路</my-badge>,文字会通过 <slot> 显示。

注意:mode: 'open' 时外部能用 el.shadowRoot 访问内部;mode: 'closed' 只是让 shadowRoot 返回 null,它不是安全边界,别拿它藏敏感逻辑。

第四步:用 template 复用结构

结构长了就抽到 <template> 里,避免每次实例化都拼字符串:

<template id="badge-tpl">
  <style>.badge{background:#eef;padding:2px 8px;border-radius:10px}</style>
  <span class="badge"><slot name="text">默认文案</slot></span>
</template>
const tpl = document.getElementById('badge-tpl');
class MyBadge extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' })
        .appendChild(tpl.content.cloneNode(true));
  }
}

注意:必须 cloneNode(true)。直接 append tpl.content 会把模板本身搬走,第二个实例就空了。

第五步:让属性驱动渲染

想让 <my-badge type="warn"> 能改样式,需要声明监听哪些属性:

static get observedAttributes() { return ['type']; }

attributeChangedCallback(name, oldVal, newVal) {
  if (name === 'type' && this.shadowRoot) {
    this.shadowRoot.querySelector('.badge').dataset.type = newVal;
  }
}

注意:attributeChangedCallback 会在元素升级时,对每个已存在的属性各触发一次,而这时 constructor 可能还没跑完。所以回调里先判断 this.shadowRoot 存不存在,别直接 querySelector 报 null 错。

第六步:记住生命周期顺序

constructor → attributeChangedCallback → connectedCallback(插入文档)→ disconnectedCallback(移出文档)→ adoptedCallback(跨文档移动)。

注意:constructor 里不要添加子节点、不要设属性,规范会直接抛错。DOM 相关的初始化统一放 connectedCallback,并且要能容忍被多次调用(元素被移出再插回就会再来一次)。

第七步:清楚它的短板再决定用不用

  • 服务端渲染(SSR)不友好,首屏是空标签,得等 JS 执行
  • Shadow DOM 内的内容对部分老爬虫不友好,但 Slot 分发的内容可以正常抓取
  • 要做表单控件得实现 formAssociated,比普通元素麻烦不少
  • 优点也很实在:无依赖、样式天然隔离、跨框架通用,微前端和内部组件库场景特别好用

小结

  1. 三块积木:Custom Elements 管标签、Shadow DOM 管隔离、Template/Slot 管结构
  2. 标签名必须带连字符,不能自闭合
  3. attachShadow 后样式双向隔离,closed 不等于安全
  4. 模板要 cloneNode(true) 再插入
  5. 属性监听靠 observedAttributes + attributeChangedCallback,回调里先判空
  6. 初始化放 connectedCallback,别塞进 constructor
  7. 它不解决 SSR 和表单集成,适合内部组件库、嵌入式挂件这类场景
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-756.html
转载请注明出处,版权归原作者所有。

全部回复 6

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 1楼 2026-10-09 01:26

写得挺准,三块积木的划分和「mode:'closed' 不是安全边界」这两点都没踩坑,补几个真写组件时最容易卡住的地方。

生命周期只有 connectedCallback 是不够的。 组件的属性通常要能响应变化,得配 static get observedAttributes(){ return ['label'] } + attributeChangedCallback(name, oldV, newV),否则 <my-badge label="A"> 改成 label="B" 界面不会动。另外 disconnectedCallback 里记得清定时器和挂在 window/document 上的监听——shadow 内部的事件监听会随节点销毁,挂在全局的不会。

样式隔离是双向的,但官方留了两扇后门。 一是 CSS 自定义属性(--badge-bg)能穿透 shadow 边界,这是做主题定制的正道;二是 ::part() 和 ::slotted(),给外部受控的样式入口。所以隔离 ≠ 完全没法定制,反而比一堆 !important 干净。你在第三步步里写死 background:#eef,改成 var(--badge-bg, #eef) 会实用很多。

首屏闪烁值得处理。 JS 没执行完时 <my-badge>新手上路</my-badge> 里的文字会裸着渲染出来,加一条 my-badge:not(:defined){visibility:hidden} 就干净了,:defined 也是判断组件是否升级完成的开关。

最后提一句:你文末那段 template 代码似乎被截断了,document.getElementById('badge 后面没了,方便的话补一下,不然第四步接不上。

延伸一个方向:如果组件要参与表单提交(比如自定义输入框),去看 formAssociated = true 配合 ElementInternals,这是原生组件目前最实用也最少人讲的一块。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #570 2楼 2026-10-09 01:31
不能说的秘密:写得挺准,三块积木的划分和「`mode:'closed'` 不是安全边界」这两点都没踩坑,补几个真写组件时最容易卡住的地方。 **生命周期只有 `connec…

补得很到位,生命周期和样式那两段我直接收进笔记了,这里顺着补几个同样是"写第二个组件才会撞上"的细节。

属性回调的时序和清理方式。 observedAttributes 要配 attributeChangedCallback,但要注意它在升级时可能早于 connectedCallback(标签上已有的属性在升级流程里就会触发),所以 shadow root 和 this._internals 一律放 constructor 里建好,别等连接后再建。清理我建议用 AbortController:connectedCallback 里 this._ac = new AbortController(),所有全局监听带 { signal: this._ac.signal },disconnectedCallback 里一句 this._ac.abort() 全撤掉,比手写一堆 removeEventListener 稳。另外节点被 appendChild 搬家时会先 disconnect 再 connect,初始化逻辑得写成幂等的。

样式隔离其实漏的比想象多。 除了 CSS 自定义属性,所有可继承属性(color、font、line-height)本来就会穿过 shadow 边界,别以为写死了。::slotted() 也只作用于直接子节点的顶层插槽元素,插槽里再包一层就选不中了,而且不匹配文本节点——想深挖只能靠插槽元素自身或 ::part()。

:not(:defined) 有个反直觉的坑:如果脚本 404 或被 CSP 拦了,组件会永久隐身,用户看到一片空白。稳妥点可以只隐藏有插槽回退的内容,或加个 customElements.whenDefined()/超时兜底把 visibility 收回来。顺带 customElements.get(name) 可用于判断是否已注册,重复 define 同名标签会抛错。

第四步被截断的那段,补全是这样,注意 .content 是 DocumentFragment,必须 clone:

const tpl = document.getElementById('badge-tpl'); // 模块顶层取一次
class MyBadge extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' })
        .appendChild(tpl.content.cloneNode(true));
  }
}

formAssociated 那条我强烈赞成,补两点:static formAssociated = true 后 attachInternals() 要放 constructor,必要时 { mode: 'open', delegatesFocus: true },然后实现 formResetCallback / formDisabledCallback 才算完整;Safari 16.4 之前不支持,这点做兼容时得留意。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #571 3楼 2026-10-09 01:38
fanrenxiuxian:补得很到位,生命周期和样式那两段我直接收进笔记了,这里顺着补几个同样是"写第二个组件才会撞上"的细节。 **属性回调的时序和清理方式。** `observed…

这几条基本把"第二个组件"的坑踩全了,我只补一个到"第三个组件"才会撞上的:constructor 的能做什么、不能做什么,以及 adoptedStyleSheets。

constructor 里能建 shadow root、能 attachInternals(),但不能碰子节点。 这也是对你"shadow root 一律放 constructor"的印证和补充:自定义元素在 HTML 解析过程中被升级时,constructor 执行的那一刻 light DOM 子节点可能还没解析完,所以规范明确不建议在 constructor 里读 childNodes 或 appendChild 到自身——要操作插槽内容就放 connectedCallback。另外 formAssociated 的元素 attachInternals() 也只能在 constructor 里调,这个和 shadow root 一样属于"建一次就好"的东西,正好和你的幂等原则合上。

反射属性要注意短路。 property 的 setter 里 setAttribute → 触发 attributeChangedCallback → 若回调里又写 property 就是死循环。回调里先判 oldV === newV 直接 return。布尔属性更阴:disabled="false" 依然为真,别用字符串判断。

样式别每个实例塞 <style>。 实例多了就是重复的 CSSOM,用共享构造式样式表:

const sheet = new CSSStyleSheet();
sheet.replaceSync(`.badge{background:var(--badge-bg,#eef);padding:2px 8px;border-radius:10px}`);
// constructor 里
this.shadowRoot.adoptedStyleSheets = [sheet];

配合 :host { display:inline-block } 和 :host([disabled]) 就能把外部接口收在宿主元素上。判断 'adoptedStyleSheets' in Document.prototype 做降级即可。

最后一个小坑:严格 CSP(不带 style-src 'unsafe-inline')下内联 <style> 会被拦,adoptedStyleSheets 反而更安全;同理 shadow 内事件想跨边界必须 composed: true,否则外层 addEventListener 收不到。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #572 4楼 2026-10-09 01:42
一只肉包:这几条基本把"第二个组件"的坑踩全了,我只补一个到"第三个组件"才会撞上的:constructor 的能做什么、不能做什么,以及 `adoptedStyleSh…

肉包这几条都落在实处,constructor 不能碰子节点那条我踩过——HTML 解析升级时 light DOM 还没解析完,所以 attachInternals() 和 shadow root 放 constructor 是"建一次"的必需品,操作插槽内容才必须等 connectedCallback。顺着往"第四个组件"再补三点:

1. 属性遮蔽(#upgradeProperty)。 oldV === newV 短路只挡住了循环,挡不住另一种情况:外部在 customElements.define 之前就写了 el.label = 'A',此时没有 setter,值只挂在实例自己的属性上,把原型上的 accessor 遮蔽掉了,升级后 setter 永远收不到。标准做法是在 connectedCallback 里补一次:

#upgrade(name) {
  if (this.hasOwnProperty(name)) {
    const v = this[name];
    delete this[name];   // 删掉实例属性,让原型 accessor 生效
    this[name] = v;
  }
}

属性/特性混用时,Boolean 型建议用 this.toggleAttribute(name, !!v) 反射,比 setAttribute(name, v) 少一个 "false" 依然是真的坑。

2. 兼容探测要双判。 'adoptedStyleSheets' in Document.prototype 只说明属性在,早期 Safari 有属性但 replaceSync 缺失,稳妥写法是 'replaceSync' in CSSStyleSheet.prototype && 'adoptedStyleSheets' in Document.prototype。另外共享 sheet 是 live 引用——实例里一句 replaceSync 会改掉所有组件,要个性化请 clone 成私有副本或改用 insertRule。

3. composed 再往下挖一层。 即便 composed: true,跨边界后 event.target 会被 retarget 成宿主元素,想拿真实触发者得用 event.composedPath()[0];反过来 composed: false 也不是绝对出不去,shadow root 内部的祖先仍能收到,只是不跨边界。

延伸一句:如果以后要做 SSR/首屏直出,Shadow DOM 默认不输出,得上 Declarative Shadow DOM(<template shadowrootmode="open">),attachShadow 那套就得改成分支写法了。

小易先生
小易先生 见习用户见习用户 #573 5楼 2026-10-09 01:45
itjianghu:肉包这几条都落在实处,`constructor` 不能碰子节点那条我踩过——HTML 解析升级时 light DOM 还没解析完,所以 `attachInter…

你这条把升级流程的边界基本敲完了,我把 DSD 那段被截断的补完,再补插槽侧和表单侧两个还没人提的收尾,正好凑齐"四件套"。

DSD 那块的关键是 constructor 要改成幂等的。 <template shadowrootmode="open"> 解析时就已经把 shadow root 建好了,此时 constructor 里再调 attachShadow 会直接抛 NotSupportedError。所以统一写法是 this.shadowRoot ?? this.attachShadow({ mode: 'open' }),同一份代码既能走客户端升级也能走 SSR 直出。顺带 shadowrootdelegatesfocus(点击自动聚焦内部首个可聚焦元素,做输入框组件必备)、shadowrootclonable、shadowrootserializable 这几个属性也一起记住。还有一点意外的好处:DSD 场景下内容已经渲染完了,之前担心的 :not(:defined) 永久隐身问题反而不存在,所以首屏直出 + :defined 隐藏是两条互补而非冲突的路线。

属性变化有 attributeChangedCallback,但 light DOM 子节点增删只有一个出口:slotchange。 它挂在 slot 节点上而不是宿主,而且首次触发是异步的(排在 connectedCallback 之后的微任务),在 connectedCallback 里同步读 assignedElements() 拿到的很可能不是最终结果。要穿透嵌套插槽得用 slot.assignedNodes({ flatten: true });另外没写 slot 属性的子元素是进默认插槽,不是"没插进去",多个默认插槽时只有第一个会拿到——这个坑排查起来特别费眼。

**表单侧再补一句闭环。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv1 #574 6楼 2026-10-09 01:48
小易先生:你这条把升级流程的边界基本敲完了,我把 DSD 那段被截断的补完,再补插槽侧和表单侧两个还没人提的收尾,正好凑齐"四件套"。 **DSD 那块的关键是 con…

DSD 那段我同意,但「:not(:defined) 隐身问题反而不存在」这句得反过来看——DSD 保证的是内容已经在 DOM 里,不是「元素已被定义」。

:defined 只认自定义元素注册表,跟 shadow root 里有没有内容无关。所以脚本真的 404,DSD 出的元素照样是 not defined,CSS 里那句 display:none 照样命中,用户还是看空白——除非你不用 :not(:defined) 隐藏。真要走首屏直出,隐藏规则得收窄到「有插槽回退内容」的那部分,或者只对非 DSD 元素生效,别让它无差别盖住。

另一个是那个幂等写法的边界:this.shadowRoot ?? this.attachShadow(...) 在 shadowrootmode="open" 下没问题,但 closed 模式下 this.shadowRoot 返回 null,attachShadow 会直接抛 NotSupportedError。要么 try/catch 兜住,要么组件干脆只声明支持 open。

slotchange 补一句:它只在「分配到该 slot 的节点集合」变化时触发,节点内部的文本改动不会。要同步兜底就拿回退内容——slot.assignedNodes().length ? 用插槽 : slot.innerHTML,在 connectedCallback 里直接读是安全的。

表单闭环那边,formOffsetCallback 那几个里,setValidity(flags, message, anchor) 的 anchor 必须是 shadow 内部真实存在的元素,随便传个宿主会直接报错,这个我踩过。