本节摘要:Web Components 是平台原生的组件方案,三件套各管一层:自定义元素扩展词汇表(新标签怎么定义、何时生灭),影子 DOM 提供样式隔离(组件内外互不污染),模板元素提供惰性声明(标记先写好、用时再激活)。本节讲透三件套的最小可用集,并回答"有了框架还要不要学它"。
阅读完本节,你应当能够:
先看它解决的问题。HTML 的词汇表是标准固定的——没有"用户卡片"标签、没有"分页器"标签,过去二十年开发者用两套办法凑:一是复制粘贴相同的标记块(改一处要改八处,第 4.1 节的坏味道),二是把结构交给脚本或框架在运行时拼出来(裸 HTML 里看不到结构,第 4.3 节讲过爬虫与读屏的代价)。两套办法各有重伤。Web Components 的思路是让平台本身支持"发明新标签":定义一次、处处可用、裸 HTML 里可见,且任何框架、任何无框架的项目都能消费——因为它是标准,不是某家框架的私有约定。
三件套的分工先立框架:自定义元素回答"这个标签叫什么、行为是什么";影子 DOM回答"组件内部的样式会不会漏出去、外面的样式会不会漏进来";模板回答"组件的结构声明在哪里放着最合适"。三者可独立使用,但组合起来才是完整方案。
定义一个自定义元素只需两步:写一个继承基础元素类的类,注册它。
<script> class UserCard extends HTMLElement { constructor() { super(); // 必须先调用父类构造 this.innerHTML = ` <div class="card"> <h3>用户卡片</h3> <p>姓名来自属性:${this.getAttribute('name') || '未命名'}</p> </div>`; } } customElements.define('user-card', UserCard); </script> <user-card name="阿页"></user-card> <user-card></user-card>
命名规则硬性两条:必须含连字符(如 user-card,为了与现在及未来的原生元素永不撞名——这是标准预留的命名空间协议),全小写。未注册的自定义标签不会报错,它被当作未知元素渲染为普通行内盒子(浏览器对未知元素的容错,第 1 章讲过)——这也是一种渐进增强:脚本没加载时内容仍在(虽然没行为)。
生命周期四个回调,各自在生产代码里高频出现:
class UserCard extends HTMLElement { connectedCallback() { // 元素进入文档:做初始化、绑事件、拉数据的时机 } disconnectedCallback() { // 元素离开文档:解绑、清理定时器的时机,泄漏防线 } attributeChangedCallback(name, oldV, newV) { // 被观察的属性变化时触发:属性驱动的更新 } static get observedAttributes() { return ['name']; } // 声明要观察谁 }
connected 与 disconnected 一进一出,是资源管理的对称钩子——在里面申请什么,就在配对的钩子里释放什么。observedAttributes 加 attributeChangedCallback 组成"属性响应"机制:外界改属性,组件自动更新,这是自定义元素与外界通信的主通道之一(另一个是事件)。
组件要可复用,先要保证"装进任何页面都不被弄脏"。常规文档里,页面样式与组件样式共享一个命名空间——页面的 h3 { color: red } 会渗进组件内部,组件的类名也可能撞上页面的。影子 DOM 给组件修一道墙:把内部结构挂在影子根上,外部样式进不来(默认),内部样式出不去。
<my-badge></my-badge> <script> customElements.define('my-badge', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> /* 这里的样式只作用于影子内部,页面的 h3 规则进不来 */ .tag { background: #2980b9; color: #fff; padding: 2px 10px; border-radius: 999px; } </style> <span class="tag"><slot>默认徽标</slot></span>`; } }); </script>
这段代码还顺带展示了 slot 插槽:组件声明一个洞,使用者在标签里写的内容自动填进洞里——<my-badge>会员</my-badge> 的"会员"会出现在槽位。插槽是组件"内容分发"的标准机制,相当于给自定义元素装上了类似原生元素内容模型的能力。
墙的边界也要说清:外部页面可以穿透的口子是 CSS 自定义属性(变量)与部分继承属性(字体、颜色会渗入影子)——这是有意设计的"受控开口",让主题定制有门可进。隔离不是绝交,是"默认隔离、留有协议接口"。
模板元素解决"结构声明放哪":写在模板里的内容不渲染、不执行、不加载资源,直到脚本把它激活。这让它适合存组件的骨架:
<template id="row-tpl"> <tr> <td class="name"></td> <td class="score"></td> </tr> </template> <script> const tpl = document.getElementById('row-tpl'); const tbody = document.querySelector('tbody'); [{ name: '阿页', score: 98 }, { name: '李设计', score: 95 }].forEach(row => { const frag = tpl.content.cloneNode(true); // 克隆模板内容 frag.querySelector('.name').textContent = row.name; frag.querySelector('.score').textContent = row.score; tbody.appendChild(frag); }); </script>
对比两种旧做法就懂它的价值:内容写在 display:none 的 div 里(浏览器仍然解析、资源仍然加载、读屏可能误读)或写在脚本字符串里(无高亮、无语法检查、转义地狱)。模板是标准给出的第三条路:声明用 HTML 的写法、激活前零成本。文档片段(content 克隆)还自带"批量插入"的性能优势——一个片段一次进树,优于逐个追加。

"有了主流前端框架,Web Components 还有用吗"是高频疑问。答案藏在两者解决的问题层级里:框架提供的是开发体验与工程体系(响应式状态、构建工具链、生态组件库),Web Components 提供的是运行时标准(浏览器原生支持的封装单位)。分界的三种典型形态:跨团队共享的基础组件(设计系统的按钮、图标)用 Web Components 发布最省心——消费方无论用什么技术栈都能用;应用内部的大规模 UI 开发,框架的响应式与工具链效率仍占优;两者嵌套(框架应用里使用 Web Components 基础件)也是成熟实践。判断口诀:给全公司全技术栈用的零件走标准,单个应用内的开发走框架。
组件不是孤岛,它要跟使用它的页面对话。对话的两个方向各有一条标准通道,理解它们才算真正会用组件。
向内:属性与插槽。属性传配置(数据型信息:值、状态、选项),插槽传内容(结构型信息:标签里写的子内容)。判断用哪条通道的问题:"这是参数还是内容?"——"这个徽标显示 98 分"是参数,走属性;"这个卡片里装一段自定义的富文本"是内容,走插槽。两条通道都已经在上一节代码里出现过,这里点破它们的分工。
向外:自定义事件。组件内部发生了什么(用户点了关闭、加载完成了),要对使用方广播,用标准的事件构造与派发:
class DateRange extends HTMLElement { connectedCallback() { this.querySelector('button').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('range-change', { detail: { from: '0301', to: '0331' }, bubbles: true // 允许冒泡,使用方可以在上层统一监听 })); }); } } // 使用方监听,语法与原生事件完全一致 document.querySelector('date-range') .addEventListener('range-change', e => console.log(e.detail));
派发方 new 一个自定义事件、带上 detail 载荷;监听方用最普通的 addEventListener 接——使用方完全不需要学习新的订阅机制,原生事件的那套知识(冒泡、委托)原样适用。这种"接口一致性"是标准组件相对私有组件库的隐性优势。
第 4 章的可访问性纪律在组件里要重新强调一遍,因为组件作者常犯一个类别错误:把"内部实现"当成可访问性的边界。实际上读屏用户感知到的是组件的对外行为——一个自定义下拉框,无论内部多精巧,如果没有正确的角色、键盘导航(方向键换项、回车选中、Esc 关闭)、状态播报,在读屏里就是一团迷雾。原生 select 这些全部内置,这正是第 4.2 节"原生优先"原则的分量所在:每重造一个原生控件,就接走一份原本平台承担的可访问性责任。重造前先问能不能基于原生元素扩展(比如用可定制内置元素继承原生行为),确要重造就把键盘与语义清单逐项补齐。
⚠️ 常见坑:把影子 DOM 当成安全隔离。它隔离的是样式与结构可见性(封闭模式下脚本也拿不到内部),不是安全边界——组件内部的脚本仍然运行在页面同一环境里,敏感数据与权限控制不能指望影子墙。
收束一句:组件化的本质是把重复结构与行为封装成有名字的词汇——名字定下来,复用与协作才有共同的指称。
问:有了主流前端框架,Web Components 还有用吗?
回到分界口诀:给全公司全技术栈用的零件走标准,单个应用内的开发走框架。补充一个时间维度的判断:团队技术栈稳定且单一时,框架组件的效率优势明显;组织里多个技术栈并存、或组件要发布给外部客户使用时,标准组件的通用性优势压倒一切。两个答案都对,取决于你站在哪个约束下。
问:自定义元素能继承原生元素吗,值得吗?
能(可定制内置元素,第 5.5 节提过方向),且在"想保留原生行为只改外观"的场景里非常值得:继承原生列表或按钮,键盘与语义全部保留,只重写渲染。代价是兼容进度与调试时影子结构的理解成本——决策前查一眼目标用户覆盖,再按降级路径评估。
跑实验前先交代观察姿势:本节的三个观察点分别验证墙、属性响应、结构隔离,建议每验完一个就在代码里做反向修改(比如把 attachShadow 那行删掉)再看效果差异,正反对照比单看正面印象深刻一倍。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>组件实验</title> <style> /* 页面样式试图渗入组件,会被影子墙挡住 */ .tag { background: red !important; } </style> </head> <body> <stat-badge value="98" label="得分"></stat-badge> <stat-badge value="4.8" label="评分"></stat-badge> <script> class StatBadge extends HTMLElement { static get observedAttributes() { return ['value', 'label']; } constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = ` <style> .box { display: inline-flex; flex-direction: column; align-items: center; padding: 10px 18px; border: 1px solid #ccc; border-radius: 10px; } .num { font-size: 26px; font-weight: bold; color: #2980b9; } .lbl { font-size: 13px; color: #7f8c8d; } </style> <div class="box"> <span class="num"></span> <span class="lbl"></span> </div>`; this._num = this.shadowRoot.querySelector('.num'); this._lbl = this.shadowRoot.querySelector('.lbl'); } connectedCallback() { this._render(); } attributeChangedCallback() { this._render(); } _render() { this._num.textContent = this.getAttribute('value') || '-'; this._lbl.textContent = this.getAttribute('label') || ''; } } customElements.define('stat-badge', StatBadge); // 属性驱动更新的验证:三秒后改属性,组件自动重渲染 setTimeout(() => { document.querySelector('stat-badge').setAttribute('value', '100'); }, 3000); </script> </body> </html>
三个观察点:页面里那句 red 样式对组件无效(墙的证明);三秒后第一个徽标的数字变成 100(attributeChangedCallback 的证明);用开发者工具查看 stat-badge,能看到影子根(shadowRoot)节点(结构隔离的证明)。一个实验验完全部三件套里最关键的两个。
(三件套不必一次学透:先把自定义元素与模板用顺,影子 DOM 等遇到样式冲突时再补,需求驱动的学习在这里最有效率。)
(通信两通道的口诀再念一遍:配置走属性、内容走插槽、广播走事件——三句话带走本节一半的工程价值。)
(三件套的学习顺序建议:先用模板与自定义元素做几个小组件,跑通生命周期与属性响应,影子墙最后加——先体会组件化的价值,再体会隔离的必要,顺序反了容易把工具当目的。)
下一节讲组件与页面的"行为"从哪来——脚本如何与文档树交互,选取、监听、读写三件事的机制是什么。