用原生customElements与ShadowDOM、slot从零封装可复用自定义元素,讲透样式隔离、内容分发与生命周期回调。
Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析)
Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析)
深入拆解 Web Components 三件套:用 customElements.define 注册属于你自己的标签,用 Shadow DOM 把样式牢牢锁在组件内部,用 slot 把外部内容灵活分发进来,再配合完整的生命周期回调把组件的生老病死串成一条清晰的逻辑链。读完你就能在不依赖任何框架的前提下,封装出真正可复用、可移植、跨框架通用的自定义元素。
前言
聊到前端组件化,绝大多数人的第一反应是 React、Vue 或者 Angular。的确,这些框架把"把界面拆成一个个独立、可复用单元"这件事做到了极致,也几乎是今天前端工程的标配。但有没有一种可能:你写出来的组件,不绑定任何框架,能在原生页面里直接用,也能被 React、Vue、Svelte 任意一方无缝嵌入?答案就是浏览器原生提供的标准能力——Web Components。
很多同学对 Web Components 的印象还停留在"概念很美好、生态不完善、实际没人用"。这种印象在几年前或许成立,但今天情况已经完全不同。无论是设计系统(如 Material Web、Fast)、跨框架的通用组件库,还是需要嵌入到第三方站点的营销挂件、聊天组件、表单组件,Web Components 都是最稳妥的底座,因为它不依赖任何运行时框架,只依赖浏览器本身。
本文不会简单地罗列 API,而是带你从"为什么需要它"一路摸到"怎么把它用到生产"。我们会先讲清楚三件套各自解决什么问题,再用真实的代码示例一步步封装一个可复用的卡片组件,最后补充事件通信、无障碍、与框架协作这些进阶但绕不开的话题。哪怕你之前从未写过一行自定义元素,跟着敲完也能上手。
为了让你对整体有个预期,先把三件套的角色摆出来:Custom Elements 负责"定义并注册一个新的 HTML 标签";Shadow DOM 负责"给这个标签套一层样式与结构的隔离舱";Templates 与 Slots 负责"让组件既能封装内部实现,又能接纳外部传入的内容"。三者各管一段,合起来就是一套完整的组件化方案。
一、Web Components 到底是什么:一次说清三件套
我们先把概念层面的迷雾拨开。Web Components 并不是一个具体的库或者框架,它是一组浏览器原生标准的统称,核心由三块拼成。理解它们各自解决什么痛点,比记住 API 签名重要得多。
第一块是 Custom Elements(自定义元素)。它解决的是"标签不够用"的问题。HTML 原生只提供了 div、span、button 这些有限的元素,而 Custom Elements 允许你通过 JavaScript 类定义自己的标签,比如 <user-card>、<price-tag>,并且让浏览器在解析到这个标签时,自动把你的类实例化、挂载到 DOM 上。你不再需要为了复用一段结构而复制粘贴一大坨 HTML。
第二块是 Shadow DOM(影子 DOM)。它解决的是"样式互相污染"的问题。在大型项目里,全局 CSS 是灾难的源头:你写了一个 .title样式,别人的.title 可能瞬间就把你的布局冲掉。Shadow DOM 给组件内部开了一棵独立的 DOM 子树,这棵树里的样式只作用于自己,外面的全局样式也选不进去,天然实现了样式隔离,和框架里 scoped style 是同一个诉求。
第三块是 HTML Templates 与 Slots(模板与插槽)。它解决的是"内容怎么灵活填进来"的问题。组件的内部骨架可以由 <template>预先定义好,而真正变化的部分——标题、正文、图标——则通过<slot> 这个"窗口"从外部透传进来。这就让组件既保持了封装,又不失灵活,类似框架里的 children 或者具名插槽。
这三者加起来的威力在于:你可以封装一个功能完整、样式自包含、内容可定制的元素,把它打包成一段脚本,然后在任何页面里像用原生标签一样<your-component></your-component>直接用,完全不需要构建工具、不需要框架运行时。
先看一个最小的例子,感受一下"注册一个标签"有多简单。下面这段代码定义了一个会在挂载时给自己填上问候语的自定义元素:
javascript// 定义一个最基础的自定义元素:挂载时给自己填上一段问候语class HelloWorld extends HTMLElement { // connectedCallback:元素被插入文档时触发,可以理解为框架里的 mounted connectedCallback() { // this 指向自定义元素实例本身 this.textContent = '你好,我是用原生 API 封装的自定义元素!'; }}// 注册元素:第一个参数是标签名(必须包含连字符),第二个参数是对应的类customElements.define('hello-world', HelloWorld);
<
到这里,你已经拥有了一个真正意义上的自定义元素。在页面里只要写一行<hello-world></hello-world>,浏览器就会把它渲染成那句问候语。这里最值得记住的一点是:自定义元素的逻辑全部写在一个继承自HTMLElement的类里,而customElements.define则是把这个类"登记"到浏览器的元素注册表里,让某个标签名和这个类绑定起来。
不过你可能会问:为什么标签名一定要带连字符?这是规范故意留下的约定,目的是避免你定义的标签和未来的原生 HTML 标签撞名。HTML 标准承诺永远不会出现带连字符的原生标签,所以带连字符的名字(如 my-button、x-card)就安全地归开发者所有。这也是为什么所有自定义元素名都长得像xxx-yyy的样子。
二、Custom Elements:用 class 与 customElements.define 封装自己的标签
理解了整体定位,我们深入第一块:Custom Elements。它的核心玩法就是"写一个类 + 注册它"。但类里哪些事情该做、哪些事情不该做,规范有明确要求,踩错一个就会在控制台报错。
类必须继承 HTMLElement,构造函数里先调 super
自定义元素本质上是对原生元素能力的扩展,所以你的类必须继承自HTMLElement(独立自定义元素)或某个具体原生元素(自定义内置元素,比如扩展<p>)。构造函数里第一件事永远是super(),否则原型链建不起来,浏览器会直接抛错。
构造函数不该动属性和子节点
这是一个高频踩坑点:规范强烈建议,不要在构造函数里读取或设置属性、不要往this上添加子节点、不要查询this的子元素。原因是构造函数执行时,元素可能还没真正挂到文档上,属性也还没被解析。所有"依赖元素已有状态"的初始化工作,都该放到connectedCallback 里去做。构造函数只适合做轻量的、不依赖外部状态的事情,比如创建 Shadow Root、绑定方法。
用 observedAttributes 与 attributeChangedCallback 监听属性变化
这是自定义元素"响应式"的关键。如果你想让组件在某个 HTML 属性变化时重新渲染,要先通过静态的 observedAttributes声明你要监听哪些属性,然后实现attributeChangedCallback(name, oldValue, newValue)。只有被声明过的属性变化,才会触发这个回调,否则浏览器不会无端调用它,这也是一种性能保护。
下面这个例子实现了一个随属性变化重新渲染的价格牌,逻辑很接近业务里常见的"数据驱动视图":
javascript// 一个会随属性变化重新渲染的价格牌组件class PriceTag extends HTMLElement { // observedAttributes:声明要监听哪些属性,只有它们变化才会触发回调 static get observedAttributes() { return ['amount', 'currency']; } constructor() { super(); // 构造函数必须最先调用 super(),且不要在这里操作子节点 } // 元素插入 DOM 时触发,适合做初始渲染 connectedCallback() { this.render(); } // 被监听的属性变化时触发,可拿到属性名、旧值、新值 attributeChangedCallback(name, oldValue, newValue) { console.log(`属性 ${name} 从 ${oldValue} 变成了 ${newValue}`); this.render(); // 属性一变就重新渲染内容 } // 把渲染抽成独立方法,属性变化时可复用,避免逻辑重复 render() { const amount = Number(this.getAttribute('amount') || 0); const currency = this.getAttribute('currency') || 'CNY'; this.textContent = `${currency} ${amount.toLocaleString('zh-CN')}`; }}customElements.define('price-tag', PriceTag);
上面这段代码的用法是<price-tag amount="1999" currency="¥"></price-tag>。当你用 JS 去 setAttribute('amount', '2999') 时,价格牌会自动更新成新数字。这种"属性即状态、变化即重渲染"的思路,和框架里"改数据驱动视图更新"是同一回事,只不过这里没有虚拟 DOM,我们直接手动重渲染。
属性(attribute)与特性(property)的映射与反射
这里有个绕不开的坑:HTML 的属性(写在标签上的 amount="1999")和 JavaScript 对象的特性(写在实例上的 el.amount)并不是天然打通的。属性永远是字符串,而特性可以是任意类型;布尔值、对象、数组都没法直接塞进属性里。
为了让组件在框架里也能正常用(Vue、React 传的都是特性或表达式),我们通常要手动做"反射":用 getter/setter 把特性访问桥接到属性上。下面这个开关组件就是标准写法:
javascript// 通过 getter/setter 手动实现 property 与 attribute 的双向反射class SwitchToggle extends HTMLElement { // 读取 this.active 时,返回是否带 active 属性 get active() { return this.hasAttribute('active'); } // 设置 this.active = true 时,自动补上/移除属性,供 CSS 与框架感知 set active(value) { if (value) { this.setAttribute('active', ''); // 真值就写上属性 } else { this.removeAttribute('active'); // 假值就移除属性 } }}customElements.define('switch-toggle', SwitchToggle);
这样外部既可以用el.active = true来开,也可以用el.setAttribute('active', '') 来开,二者语义一致。这层反射看似啰嗦,却是 Web Components 能和主流框架和谐共处的命门,因为框架通常是通过特性来传值的。如果你对"拦截属性读写、做一层代理转换"这套机制很感兴趣,可以顺手读读 Proxy 与 Reflect 元编程:从拦截器到 Vue3 响应式原理,那里讲的拦截器思想和这里的 getter/setter 反射是一脉相承的。
三、Shadow DOM:样式隔离的核心武器
如果说 Custom Elements 负责"定义标签",那 Shadow DOM 就是负责"把组件关进隔离舱"。没有它,你的组件样式就可能污染全局;有了它,组件内部自成一统,外部完全插不进手。
创建 Shadow Root
在构造函数(或连接时)调用this.attachShadow({ mode: 'open' })就能挂上一棵影子树。返回的那个shadowRoot对象,就是你后续往里塞结构、写样式的地方。参数mode有两个取值:'open'表示外部可以通过element.shadowRoot访问这棵树;'closed' 则把访问权限收起来,但说实话,closed 只是增加一点访问成本,并不能真正防住刻意逆向,生产里多数场景用 open 就够了,也方便调试。
样式隔离到底隔离了什么
Shadow DOM 的隔离是双向的:内部写的 <style> 只作用于影子树内部,不会影响页面其它部分;反过来,页面里全局的 CSS(比如 reset 样式、第三方 UI 库的样式)也选不中影子树里的节点。这等于天然解决了"样式冲突"这个老大难问题,你再也不用为了不污染全局而给每个类名加一长串前缀。
三个必须认识的选择器
在 Shadow DOM 里写样式,有三个专属选择器要认识:
:host选中组件宿主元素本身(也就是那个自定义标签),可以用来给组件最外层定样式;:host([active])这种带属性的写法,可以依据宿主上的属性做状态样式,等价于"组件根节点有某个属性时换肤";::slotted(selector)选中从外部透过插槽传进来的节点,但注意它只能选中"插槽的直接子节点",选不中更深的后代。
CSS 变量能穿过边界:主题切换的钥匙
隔离虽好,但有时你确实希望外部能定制组件的主题色。好消息是:CSS 自定义属性(也就是 CSS 变量)是可以穿透 Shadow 边界的。这意味着在组件内部写 color: var(--brand-color, #3b82f6),外部只要在宿主元素上设一个--brand-color,就能改掉组件内的颜色。这正是实现"一套设计令牌、全局统一换肤"的底层机制。想系统掌握变量怎么用、如何一行var()搞定全局主题,强烈推荐看CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换。
下面这段代码把上面几点揉进一个带影子树的卡片组件里:
javascript// 用 Shadow DOM 把组件内部结构、样式一起封装起来,实现样式隔离class FancyCard extends HTMLElement { constructor() { super(); // attachShadow 创建影子根,mode 为 open 时外部可用 this.shadowRoot 访问 const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { display: block; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; } :host([active]) { border-color: var(--brand-color, #3b82f6); // 变量可穿透边界,外部可定制 } .title { font-weight: 600; } ::slotted(img) { width: 48px; height: 48px; border-radius: 50%; } </style> <div class="title"><slot name="title">默认标题</slot></div> <div class="body"><slot>这里放正文</slot></div> `; }}customElements.define('fancy-card', FancyCard);
需要提醒的是,影子树里的内容布局和任何普通 DOM 一样,你照样可以用 flex、grid 来排。如果组件内部需要复杂的二维布局,直接上 Grid 即可,相关套路可以参考 CSS Grid 网格布局完全指南:从网格线到 auto-fit 响应式,把网格思维搬进 Shadow DOM 里完全通用。
四、slot 插槽:让组件既封装又可定制内容
Shadow DOM 解决了"内部隔离",但如果一个组件什么都包死了、外部一点内容都塞不进来,那它就不灵活了。插槽(slot)就是来解决"封装与灵活兼得"这个问题的。
默认插槽与具名插槽
在组件的影子模板里写<slot>就是开了一个"内容窗口"。外部写在自定义元素标签内部的内容,会自动落到这个窗口里,这就是默认插槽。如果你写了多个插槽,就得用name区分,比如<slot name="title">,外部则用slot="title"告诉某个节点"你该进哪个窗口",这就是具名插槽。
没被具名插槽认领的内容,会全部归到默认插槽。而插槽标签里的文字(如上面例子里的"默认标题")是兜底内容——只有当外部没有传对应内容时才会显示。
监听 slotchange 感知内容变化
有时候组件需要根据透传进来的内容动态调整自己,比如 Tab 组件要数一数有几个面板。这时可以监听 slotchange事件,并在回调里用slot.assignedNodes()拿到真正被分发进来的节点列表。注意assignedNodes()默认不含插槽兜底内容,想连兜底一起拿要传{ flatten: true }。
下面这段展示了一个 Tab 容器如何通过 slotchange 感知子节点变化:
javascript// 监听插槽内容变化,适合做动态内容适配(比如数一数有几个面板)class Tabs extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style>:host { display: block; }</style> <div class="panels"><slot></slot></div> `; // 拿到默认插槽,并在其内容变化时做处理 const slot = shadow.querySelector('slot'); slot.addEventListener('slotchange', () => { const nodes = slot.assignedNodes(); // 获取实际透传进来的节点 console.log('插槽里现在有', nodes.length, '个节点'); }); }}customElements.define('my-tabs', Tabs);
插槽的语义,其实和框架里"内容分发"是同一个概念。Vue 的 <slot>、React 的 children 都是它的近亲。区别在于:原生插槽是浏览器标准,不挑框架,这也是 Web Components 能"一次编写、处处运行"的底气所在。
五、生命周期回调:把组件的生老病死串成逻辑
一个组件从被创建、挂到页面、属性变化、被移除,到被移动到别的文档,浏览器会在关键时刻回调你类里对应的方法。这些方法就是生命周期回调,它们让组件能"感知自己的处境"并做出反应。
constructor:元素被实例化时调用。只做最轻量的初始化(建 Shadow Root、绑定方法),别碰属性和子节点。connectedCallback:元素每次被插入文档时调用。这是做初始渲染、添加事件监听、启动定时器、发起数据请求的最佳时机,相当于框架的mounted。disconnectedCallback:元素每次从文档移除时调用。这里务必清理事件监听和定时器,否则会造成内存泄漏,相当于unmounted。attributeChangedCallback:被监听的属性变化时调用,用于响应式重渲染,相当于watch。adoptedCallback:元素被移动到另一个文档时调用,比如用document.adoptNode跨文档搬运,日常较少用到,但做微前端或跨 iframe 时要留意。
生命周期里最容易翻车的地方是"只挂监听、不拆监听"。下面这个时钟组件演示了正确的姿势:在connectedCallback里加监听、起定时器,在disconnectedCallback里严丝合缝地全部卸掉。
javascript// 一个会每秒刷新时间、并在卸载时清理资源的时钟组件class LiveClock extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); // 先准备好影子根 this.shadowRoot.innerHTML = ` <style>:host { font-variant-numeric: tabular-nums; }</style> <span class="time"></span>`; } // 挂载:添加事件监听、启动定时器 connectedCallback() { this.timer = setInterval(() => this.tick(), 1000); window.addEventListener('resize', this.onResize); this.tick(); // 立即先刷一次,避免首屏空白 } // 卸载:务必移除监听与定时器,否则组件被删后仍在跑,造成内存泄漏 disconnectedCallback() { clearInterval(this.timer); window.removeEventListener('resize', this.onResize); } // 用箭头函数绑定,保证 removeEventListener 时能拿到同一个引用 onResize = () => { }; tick() { this.shadowRoot.querySelector('.time').textContent = new Date().toLocaleTimeString(); }}customElements.define('live-clock', LiveClock);
这里有个极易踩的坑:你在connectedCallback里addEventListener的监听器,如果在disconnectedCallback里removeEventListener时拿到的不是同一个函数引用,那么清理就会失效。上面的例子用类的箭头函数字段onResize = () => {}来保证引用一致,这是社区里最省心的写法。
另外,如果你的组件渲染重型内容(比如大图、长列表),不一定要在connectedCallback里一股脑全画出来。一个更聪明的做法是等组件真正进入视口再加载,这时候配合Intersection Observer API 实战:高性能滚动触发动画与懒加载里的观察者,就能做到"用到才渲染",对首屏性能和流量都更友好。
六、综合实战:从零封装一个可复用卡片组件
前面几块都是拆解动作,现在我们把它们合起来,封装一个相对完整的user-card 组件:它支持通过属性传用户名和头像、通过插槽传自定义正文、用 Shadow DOM 做样式隔离、用 CSS 变量支持换肤,并且在生命周期里管理渲染。这基本就是一个生产级组件的雏形。
javascript// 综合实战:一个带标题/内容/头像的卡片组件,支持属性定制与插槽分发class UserCard extends HTMLElement { // 声明要监听的属性:用户名、头像地址、加载态 static get observedAttributes() { return ['name', 'avatar', 'loading']; } constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { display: flex; gap: 12px; padding: 16px; border-radius: 12px; background: var(--card-bg, #fff); font-family: system-ui; } :host([loading]) .meta { opacity: .5; } // 加载态降低透明度 img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; } .name { font-weight: 600; } ::slotted(p) { margin: 4px 0 0; color: #6b7280; } // 透传进来的正文样式 </style> <img part="avatar" alt="头像"> <div class="meta"> <div class="name"><slot name="name">匿名用户</slot></div> <slot></slot> </div> `; } // 挂载时做首次渲染 connectedCallback() { this.render(); } // 被监听的属性一变就重渲染 attributeChangedCallback() { this.render(); } // 集中处理渲染:把属性值填进影子树对应位置 render() { const img = this.shadowRoot.querySelector('img'); const avatar = this.getAttribute('avatar'); if (avatar) img.src = avatar; // 设置头像地址 this.shadowRoot.querySelector('.name').textContent = this.getAttribute('name') || '匿名用户'; // 兜底显示匿名 }}customElements.define('user-card', UserCard);
使用时只需要一行标签,正文通过插槽传入:
html// 在页面里这样使用 user-card:标题走具名插槽,正文走默认插槽<user-card name="前端哥" avatar="/avatar.png"> <span slot="name">前端哥</span> <p>十年老前端,专注把复杂概念讲简单。</p></user-card>
注意组件内部头像的<img>上我写了一个part="avatar"。这是给外部开的"样式后门":配合::part(avatar) 选择器,外部即使隔着 Shadow 边界,也能精准改头像的圆角、尺寸。它是 ::slotted之外另一种受控定制手段,适合暴露少数需要被外部微调的内部节点。一个设计良好的组件,应该把"允许外部改什么"用::part收敛好,而不是把整个内部都摊开。
到这里,你已经拥有了一个功能完整的组件:样式隔离、内容可定制、属性驱动、加载态可切换。把它单独打成一个 JS 文件,任何项目引入即可用,不需要 React,也不需要 Vue。
七、进阶话题:事件、无障碍与框架协作
能写出来只是第一步,真正用到生产还要补几块关键知识。
自定义事件如何穿透 Shadow 边界
组件内部发生的事(比如按钮被点击)往往要通知外部。标准做法是this.dispatchEvent(new CustomEvent('change', { detail, bubbles: true, composed: true }))。这里两个开关尤其重要:bubbles: true让事件能冒泡,composed: true 让事件能穿过 Shadow DOM 边界冒到外部文档。漏掉 composed: true,外部就永远收不到这个事件——这是新手最常掉的坑。
别忘了无障碍
自定义元素默认对屏幕阅读器而言是"透明"的,如果你封装的是一个本来该是按钮、输入框、对话框的东西,却没给role、没处理键盘操作、没管理焦点,那它就对障碍用户完全不可用。给交互元素补上语义角色、保证能用键盘触发、维持足够的对比度,是每个负责任的组件都该做的。关于 ARIA、键盘导航和对比度的系统方法,建议读 前端无障碍(Accessibility)指南:ARIA、键盘导航与对比度那些事,把无障碍当成组件验收标准的一部分。
与框架协作的边界
Web Components 的强项是"通用底座",但它本身没有虚拟 DOM、没有细粒度响应式、没有路由。所以当你在做一整个由单一框架驱动的应用时,用框架自家的组件往往更顺手;而当你需要跨框架共享、需要嵌入第三方站点、需要长期不随框架版本过时的基础设施时,Web Components 就是最优解。二者不是替代关系,而是各管一段。很多团队正是用 Web Components 做设计系统,再让 React/Vue 业务层去消费它。
想省手写?可以上轻量库
原生 API 写多了,你一定会厌倦反复手写 attachShadow、重复的渲染模板。这时候可以引入 Lit 这类轻量封装库,它用 lit-html 的模板语法帮你处理高效的局部更新,底层仍是标准 Web Components,不绑架你。理解原生原理之后再用库,你才知道它在替你省哪些事、又在哪里加了约束。
八、常见陷阱与避坑清单
落地 Web Components 时,有几个高频坑值得单列出来,提前避掉能省下不少调试时间。
坑一:在构造函数里操作属性或子节点。规范明确禁止,且浏览器可能直接报错或行为异常。所有依赖元素状态的初始化,一律放到connectedCallback里做。
坑二:改了属性组件却不更新。忘了声明observedAttributes,或者属性名拼写不一致,导致attributeChangedCallback根本不触发。声明和读写两边的名字必须完全对上。
坑三:自定义事件外部收不到。八成是dispatchEvent时漏了composed: true,事件卡在 Shadow 边界里冒不出来。记住跨边界必须 bubbles: true加composed: true。
坑四:disconnectedCallback里没清理,导致内存泄漏。定时器、全局监听(resize、scroll)、订阅,只要在connectedCallback加了,就必须对称地在卸载时移除,且注意函数引用要一致。
坑五:把复杂数据塞进属性。属性只能是字符串,对象、数组要用JSON.stringify 转,或者更推荐直接通过 property(特性)传对象引用,而不是硬塞进 HTML 属性里。
除了上面五条,还有两个小提醒:其一,customElements.define对同一个名字只能注册一次,重复注册会抛错,动态加载组件时要注意去重;其二,样式隔离虽好,但字体、继承类属性(如color、font-family)仍会从宿主继承到影子树里,这正是 CSS 变量能穿透换肤的前提,也是调试"为什么内部颜色不对"时该看的方向。
总结
本文从"为什么需要脱离框架也能组件化"出发,系统拆解了 Web Components 的三件套:用 customElements.define配合继承自HTMLElement 的类注册你自己的标签;用 Shadow DOM 创建样式隔离的影子树,靠 :host、::slotted、::part 与可穿透的 CSS 变量掌控封装与定制;用 slot把外部内容灵活地分发进组件;再用connectedCallback、disconnectedCallback、attributeChangedCallback等生命周期回调,把组件的挂载、卸载、属性响应串成一条清晰的逻辑链。最后我们还封装了一个可复用的user-card,并补充了事件穿透、无障碍与框架协作这些生产级话题。
Web Components 的价值不在于"取代框架",而在于它是一层不依赖任何运行时的标准底座:一次编写,React、Vue、Svelte 乃至原生页面都能直接用。当你需要跨团队、跨项目、跨框架共享一套组件,或者要把能力嵌入别人家的站点时,它会是最稳的那块地基。掌握它,不只是多学一套 API,更是让你看清"组件化"这件事在浏览器层面到底是怎么发生的。如果觉得本文对你有帮助,欢迎点赞收藏,也欢迎在评论区聊聊你用 Web Components 踩过的坑。

发表评论发布评论后审核成功,即可显示在列表中...