ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Web Component实战指南:三大核心原理与跨框架组件复用

Web Component实战指南:三大核心原理与跨框架组件复用 Web Component这个话题前端圈聊了好几年但很多人对它还是“好像听过又不知道具体是啥”。我见过不少同学的项目里明明能用原生的Web Component结果还是硬塞了一个两百KB的依赖库就为了做几个卡片组件。这篇文章我打算用最直白的大白话把Web Component的原理、用法、通信方式和适合的场景一次讲透。不管你是一年经验的新人还是被框架“惯坏”了的老手只要跟着过一遍应该都能写出自己的原生组件并且能判断出它到底该用在哪儿。1. 内容整体设计与思路拆解1.1 为什么前端会冒出“Web Component”这个东西要理解Web Component得先回顾一下组件化是怎么走过来的。早期写页面我们拿着HTML、CSS、JavaScript直接在全局里干活公共的导航栏、弹窗、按钮全靠复制粘贴。后来出现模板引擎把HTML片段抽成模板字符串但本质上还是在“拼字符串”。再到后来主流框架用组件化把DOM、样式、逻辑封装在一起这套思路确实很舒服。问题在于框架的组件是“私有的”。你在一个框架里写的组件天然没法直接拿到另一个框架里用更没法给纯HTML页面用。团队里同时有多个技术栈时组件库就得为每个框架各写一套维护成本翻着倍涨。Web Component就是浏览器原生的组件标准它不需要任何框架直接用JavaScript就能定义一个属于你自己的HTML标签。这就像把“USB-C接口”直接做进了系统底层不管上面跑的是什么系统、什么应用插上就能用。它解决的是组件跨框架、跨项目复用的根本问题。1.2 三大核心技术到底是怎么分工的Web Component不是一个新东西它是三个浏览器原生API的组合拳Custom Elements自定义元素让浏览器认识你的新标签。你以为写了个my-card浏览器之前不认识它现在通过customElements.define()注册之后浏览器就学会了这个标签的行为。Shadow DOM影子DOM给组件一个“独立房间”。组件内部的DOM结构和样式默认与外界隔离外面改不进来里面也漏不出去。HTML Templates模板定义一个不会被渲染的内容片段需要用的时候再复制一份出来。用一个生活化的例子自定义元素是“给房间挂上门牌号”Shadow DOM是“房间的墙”模板则是“装修图纸”。图纸本身不占地方但照着图纸装修完你就可以在房间里开派对外面的噪音和里面的声音互不干扰。1.3 选择Web Component而不是自己造轮子的原因有人可能会问我自己写一个class然后手动innerHTML再配上addEventListener不也能实现封装吗确实能但会踩很多坑而且每个项目都要重新踩。比如组件插入DOM的时机、属性变化时重新渲染的时机、多个实例之间样式互串的问题这些都是重复劳动。Web Component把这套生命周期管理、样式隔离、模板克隆的机制都固化成了浏览器标准。你只需要按规矩写浏览器就帮你处理好大部分脏活累活。这也是为什么我建议所有前端都至少学习一遍原生Web Component不是为了抛弃框架而是为了理解自定义元素的运行机制以后用框架组件时也能更清楚它到底帮你做了什么。2. 原理拆解三大核心标准在浏览器里的运作方式2.1 自定义元素浏览器是怎么“学会”一个新标签的自定义元素的本质是一个继承自HTMLElement的JavaScript类。你可以把它理解为浏览器内置了一套关于“HTML标签”的类体系div是HTMLDivElementspan是HTMLSpanElement。你现在要做的是创建一个MyCardElement类注册进customElements浏览器就会把它当成一个原生标签一样对待。注册的代码很简单class MyCard extends HTMLElement { constructor() { super(); } connectedCallback() { this.innerHTML p我是一个卡片/p; } } customElements.define(my-card, MyCard);这里有四个生命周期回调你只要记住它们触发的时机就能搞定大部分场景回调方法触发时机使用场景constructor()元素被创建时还未插入DOM初始化状态、创建Shadow DOM、绑定事件不能操作DOM属性connectedCallback()元素被插入到文档时发送请求、渲染内容、设置监听disconnectedCallback()元素从文档移除时清理定时器、移除监听、释放资源attributeChangedCallback()被观察的属性值变化时根据属性变化更新组件的展示一个很容易踩的点constructor里千万不能去读取元素的innerHTML或属性因为此时组件还没有连接到文档上很多信息拿不到。这个阶段只适合做“初始化变量”和“创建阴影树”其他事情等到connectedCallback再做。2.2 Shadow DOM的“玻璃罩”隔离机制Shadow DOM是整个Web Component里最重要也最容易被忽略的部分。它允许你给一个宿主元素就是你的自定义标签挂一棵独立的DOM树。这棵树的特点是外部CSS选择器无法进入内部CSS也无法渗透出去。看一个最简单的例子class MyCard extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); shadow.innerHTML p这个段落生活在影子里/p style p { color: red; } /style ; } } customElements.define(my-card, MyCard);当你在页面里写my-card/my-card时即使页面其他地方有一段p { color: blue }的全局样式这个组件里的p依然是红色的。这就是“玻璃罩”的含义组件内部的样式规则只在罩子内生效。attachShadow方法的mode参数有两个值open外部JS可以通过element.shadowRoot拿到影子根方便调试和操作。closed外部拿不到shadowRoot隐私性更好但调试时也更麻烦一般除非特别重视封装性否则用open就够了。提示如果元素已经有子元素或者你尝试再次attachShadow()浏览器会直接抛错。一个自定义元素只能有一个影子根。2.3 模板template不占渲染位置的“零件仓库”template标签是很特殊的存在。浏览器解析它时不会渲染其内容也不会让它占据布局位置但它保存在DOM树里随时可以通过content.cloneNode(true)复制出一份“实物”。使用场景很清晰组件内部往往有一段固定的结构和样式。与其在每次创建实例时拼接字符串不如把这段结构放在template里需要时克隆。这样做有两个好处一是结构清晰二是浏览器可以预先解析模板性能上略微有优势。template idcard-tpl style .wrapper { border: 1px solid #ccc; padding: 16px; } /style div classwrapper slot/slot /div /template配合slot占位符模板还能让使用方传入内容。你别看slot好像只是“挖了个坑”它其实是Web Component组合机制的核心外部的内容可以插进模板内部的坑位实现“结构由组件定内容由调用方填”的效果。这部分在讲通信时会再展开。3. 保姆级实操从零手写一个可用组件3.1 搭建最小可运行环境Web Component不需要任何构建工具也不需要npm安装包一个浏览器就够了。新建一个index.html直接写代码就能跑!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleWeb Component Demo/title /head body my-button typeprimary disabled点击我/my-button script class MyButton extends HTMLElement { constructor() { super(); } connectedCallback() { this.innerHTML button${this.textContent}/button; } } customElements.define(my-button, MyButton); /script /body /html在浏览器里打开你会看到一个按钮文本内容来自标签内部。这里有个细节自定义元素的标签名必须包含短横线my-button中的-这是浏览器的硬性规定用于区分原生HTML标签和自定义标签。3.2 样式隔离与属性响应做一个完整的按钮组件上面的最小例子没有做样式隔离用的是innerHTML这种叫“非Shadow DOM组件”。接下来正经写一个带Shadow DOM、支持属性变化响应的按钮。my-button typedanger round删除/my-button script class MyButton extends HTMLElement { static get observedAttributes() { return [type, round, disabled, loading]; } constructor() { super(); this.attachShadow({ mode: open }); } connectedCallback() { this._render(); } attributeChangedCallback() { // 属性变化时重新渲染 this._render(); } _render() { if (!this.shadowRoot) return; const type this.getAttribute(type) || default; const round this.hasAttribute(round) ? border-radius: 6px; : ; const disabled this.hasAttribute(disabled) ? disabled : ; const text this.textContent.trim(); this.shadowRoot.innerHTML style :host { display: inline-block; } button { padding: 8px 16px; border: none; background: #3b82f6; color: #fff; cursor: pointer; } button[data-typedanger] { background: #ef4444; } button[disabled] { background: #ccc; cursor: not-allowed; } /style button>// React 中 function App() { return ( div my-button typeprimary round onClick{handleClick}提交/my-button /div ); }!-- Vue 中 -- template my-button typeprimary round clickhandleClick提交/my-button /template运行没有问题。这是因为浏览器已经“认识”了这个标签。这里要小心一个细节框架的事件代理机制在自定义元素上可能不生效。比如React事件系统是挂载在根容器上的它利用事件冒泡来捕获事件。Web Component内部Shadow DOM里的自定义事件经过重定向后冒泡到外部时原生事件对象会变成CustomEventReact早期版本对这类事件处理不完善。如果你在React 18或更早版本里给my-button绑onClick没反应别怀疑是组件问题直接在组件内部通过addEventListener处理即可。实际上这个问题在新版本React中已经得到修复但为了防止踩坑我建议在自定义组件内部主动派发事件或者在你选择的框架里把自定义元素的事件用原生监听方式处理会更稳。4. 通信方式全解组件之间到底怎么传话4.1 用自定义事件向外“喊话”Web Component最常见的对外通信方式就是派发事件。和其他事件不同的是它需要携带数据时使用CustomEvent。class MyButton extends HTMLElement { // ... 其他逻辑 _handleClick() { this.dispatchEvent(new CustomEvent(my-click, { detail: { id: this.dataset.id, name: this.textContent }, bubbles: true, composed: true })); } } // 使用方 document.querySelector(my-button).addEventListener(my-click, (e) { console.log(e.detail); // { id: xxx, name: 点击我 } });这里有两个参数特别关键bubbles: true让事件能够冒泡出Shadow DOM。composed: true允许事件跨越Shadow DOM的边界对外部监听可见。如果不加composed组件Shadow内部的事件会被“玻璃罩”挡住外部根本收不到。我相信很多人第一次写自定义组件事件时都会在这里卡住。还有一点事件名最好带前缀比如my-click、card-select避免和原生事件撞车。你发一个click事件原生也发click到时候监听就混乱了。4.2 属性与属性的区别传参的两种姿势在HTML时代“属性”这个词指的就是attribute比如div idxxx中的id。在JS世界里DOM元素上还可以直接挂JavaScript对象属性叫property。两者最常见的区别是对比项attributeproperty类型字符串任意JavaScript值对象、函数等访问方式getAttribute()/setAttribute()element.xxx ...影响渲染可以通过attributeChangedCallback监听需要主动调用渲染方法典型用途配置项直观可见传递复杂数据举个例子你想给组件传一个对象配置用attribute只能传字符串还得JSON.parse解析用property则可以直接传对象省掉序列化const card document.querySelector(my-card); card.userInfo { name: 张三, id: 1 }; // 传对象为了支持这个特性你需要在组件类里定义对应的getter和setterclass MyCard extends HTMLElement { set userInfo(value) { this._userInfo value; this._updateView(); } get userInfo() { return this._userInfo; } }顺带提一个常见的坑布尔型attribute。比如disabled当它存在时hasAttribute(disabled)是true但是getAttribute(disabled)返回的是空字符串或true判断时要小心。如果不是非要传复杂数据用hasAttribute判断布尔标志用getAttribute获取字符串值就可以避开大部分问题。4.3 插槽slot内容分发与组合的妙用插槽可以让组件“留白”让使用方往里面塞内容。这个机制很像给一张表格挖了几个空填什么由用户决定。class MyDialog extends HTMLElement { constructor() { super(); this.attachShadow({ mode: open }); this.shadowRoot.innerHTML div classdialog div classdialog-header slot nametitle默认标题/slot /div div classdialog-body slot/slot /div /div ; } } customElements.define(my-dialog, MyDialog);使用方这样写my-dialog span slottitle自定义标题/span 这里是正文内容 /my-dialog如果没有提供slottitle的内容就会显示默认的“默认标题”。slot/slot没有name属性就是默认插槽接收所有未指定slot属性的内容。slot在组件组合中尤其好用比如弹窗组件本质上就是“壳”加“内容”。有了插槽组件只需要负责壳子的逻辑和样式内容由外界决定想塞什么塞什么。5. 应用场景分析什么时候用它什么时候绕过它5.1 真正适合的三大场景我把实际项目里适合用Web Component的场景总结成三类场景具体描述为什么适合跨框架组件库一套组件库同时供应给React、Vue、Angular原生页面使用原生的组件可以被任何框架识别不需要为每个框架写一套包装微前端架构多个子应用由不同框架开发需要共享公共组件子应用之间是隔离的用原生组件避免“我的组件里嵌套了你的运行时”第三方嵌入页面嵌入到别的网站的按钮、聊天窗口、卡片插件目标网站环境未知原生组件不污染对方样式自带样式隔离在这些场景里我实际做过最典型的项目是某公司内部有十几个团队维护不同系统的前端使用框架各不相同。后来统一抽了一套基础的“用户选择器”“时间选择器”等组件全用Web Component封装发布到私有Registry里一次开发各端通用。上线后维护成本下降得很明显。5.2 不适合的场景以及原因Web Component不是银弹遇到下面这些情况我反而会劝你别硬上复杂的业务组件组件的内部如果涉及大量数据请求、状态管理和路由联动纯靠Web Component手搓会很痛苦。它只解决“封装和复用”不解决“数据流和状态管理”。这类需求还是用框架组件更合适。需要SSR服务端渲染的页面自定义元素是浏览器API服务端环境里没有customElements、没有HTMLElement直接使用会报错。虽然有Declarative Shadow DOM这类方案在弥补但生态和成熟度仍然不如框架的SSR方案。团队极度依赖框架生态如果你的团队已经深扎在某个框架里那框架自己的组件系统比Web Component更高效。比如你拿Vue组件库里的el-date-picker和自写的自定义日期组件比光是要补的交互、键盘操作、无障碍支持就够折腾半天。5.3 Web Component与框架组件的本质差异有一个认知容易被忽略框架组件是“框架管理”的Web Component是“浏览器管理”的。你写Vue组件时Vue负责创建实例、更新DOM、销毁实例。你写Web Component时浏览器的Custom Element机制负责这一切。从这个角度看Web Component是更底层的标准框架组件是更上层的抽象。它们之间不是替代关系而是可以嵌套的关系。你在一个Vue组件里用my-button完全没有问题在Web Component内部也可以用React去渲染内容不过一般不这么干显得很沉重。在实际选型时我一般遵循一个原则“这个组件未来要不要被多个技术栈使用要不要嵌入到未知环境”如果答案是“要”就用Web Component如果只是当前项目内部用那用框架组件效率更高。6. 常见问题与排查技巧实录6.1 组件不显示、样式不生效等经典报错我把这几年被问烂的问题列成一张自查表优先排查这几项现象可能原因处理方式页面打开后自定义标签是空的customElements.define()报错或代码顺序不对确认标签名带短横线确认define已执行且script在组件使用之前加载外部样式改不动组件内部Shadow DOM的样式隔离生效使用CSS自定义属性变量实现外部主题定制attributeChangedCallback不触发忘记在observedAttributes中声明检查静态getter有没有写对组件被显示为“Unknown”浏览器不支持老IE检查兼容性必要时引入polyfill组件重复注册报错同一组件名被定义了两次在define前加!customElements.get(my-button)判断注意一下第一项自定义元素标签名必须带短横线这是标准规定。我见过有人注册了mytime结果浏览器完全不识别换成语义化的组件名加短横线后立刻正常。6.2 生命周期里的异步操作坑connectedCallback里发请求是常规操作但异步返回时组件可能已经被移出文档此时更新DOM会白白报错。权威的做法是在回调里检查组件是否还在文档中async connectedCallback() { this._isConnected true; const data await fetch(/api/data); if (this._isConnected) { this._render(data); } } disconnectedCallback() { this._isConnected false; }还有一个生命周期细节connectedCallback可能在同一次操作中触发多次。比如把一个元素从A容器移动到B容器浏览器会先触发disconnectedCallback再触发connectedCallback而不会重新执行constructor。这意味着你的初始化逻辑不能只放在constructor里必须在两次连接之间保持状态可靠。6.3 性能与兼容性自查清单性能方面一个容易忽视的点是Shadow DOM内部每次都重设innerHTML会带来不必要的重排和重绘。如果你的组件频繁更新应该尽量把更新范围限制在要变化的那部分节点上而不是整个影子树重写。另外template里克隆内容比每次字符串拼接快所以涉及大量DOM创建时优先考虑模板克隆template idmy-tpl !-- 需要重复的结构 -- /templateconst node document.querySelector(#my-tpl).content.cloneNode(true); this.shadowRoot.appendChild(node);兼容性方面现代浏览器对Web Component的支持已经很成熟。如果非要兼容很老的浏览器就需要引入polyfill和一点构建手段。不过我的建议是如果你的产品用户绝大多数都在现代浏览器上那就放心用polyfill只是最后一道保险。6.4 调试技巧看清影子树内部每次调试Shadow DOM内部样式时我都在DevTools的Elements面板右上角开启“Show user agent shadow DOM”。开启后不仅能看到my-button影子树内部的结构还能看到浏览器原生控件比如input内部的结构。这样排查样式问题会快很多。还有一个小工具分享在浏览器的Console里可以直接通过document.querySelector(my-card).shadowRoot访问开放模式的影子树手动修改内部节点看看效果不用反复刷新页面。代码调试时特别有用。我个人在写Web Component的这几年里最大的体会是它解决的是封装和复用而不是数据流和状态管理。如果你正被“组件只能在某个框架里用”这件事困扰花一个下午试试原生Web Component大概率会觉得相见恨晚。最后再复用一次那个技巧调试时优先开“Show user agent shadow DOM”很多看着玄乎的样式问题会立刻变得明朗。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进