拓冰建站拓冰建站
首页 / 资讯中心 / 正文

原生Web Components构建分层HTML组件系统

先问大家一个问题当你在做多个页面发现同一套“卡片 头像 按钮 弹窗”被复制了几十遍改一处样式要全局搜索替换的时候是不是开始怀疑人生我在早期的业务页面里就经常遇到这种局面。HTML 结构复制粘贴、CSS 类名互相覆盖、JavaScript 逻辑散落在各个script标签里。后来接触并落地了一套“分层 HTML 组件系统”的思路才慢慢把这类问题理顺。这篇文章就围绕“分层”和“组件化”两个关键词从概念到实战完整拆解一套基于原生 Web Components 的轻量级组件系统。无论你是刚接触前端组件化还是想在项目中减少框架依赖都有一定参考价值。1. 什么是分层 HTML 组件系统1.1 从“写网页”到“造组件”传统写网页的方式是把 HTML 结构、CSS 样式、JavaScript 逻辑全部堆在一个页面里。页面简单时还好一旦业务复杂就会出现几个典型问题相同的页面片段卡片、弹窗、表格无法复用。CSS 类名冲突难以排查。JavaScript 逻辑与 DOM 结构强耦合改一处牵一发动全身。新人接手代码时很难快速判断某段逻辑属于哪个功能模块。组件化的核心思路就是把可复用的 UI 和逻辑封装成一个独立单元。这个单元有自己的结构、样式和行为能被外部稳定调用。就像积木一样单块积木内部可以很复杂但对外只需要暴露一个“插拔接口”。1.2 分层到底分的是什么“分层 HTML 组件系统”中的 layered指的是在组件内部继续做职责拆分。一个组件不再是一坨混在一起的代码而是分成多个层面分层职责对应技术模板层定义组件的 HTML 结构template、innerHTML样式层定义组件的外观与布局style、Shadow DOM 样式隔离逻辑层处理交互、事件、状态更新Custom Element 生命周期、JavaScript 方法数据层约定外部传入的属性、组件内部状态observedAttributes、attributeChangedCallback这样拆分之后每个层面都有清晰的边界。模板层只负责“长什么样”逻辑层只负责“怎么交互”数据层只负责“怎么流动”。当需要修改一个按钮颜色时不需要去翻逻辑代码当需要修改交互行为时也不需要从一堆结构字符串里找位置。1.3 这套系统适合谁想脱离大型框架用原生 HTML/CSS/JavaScript 实现组件化开发的前端初学者。需要在多页面项目中保持 UI 统一又不想引入过重构建流程的团队。正在学习 Web Components、Custom Elements、Shadow DOM 的开发者。想理解组件内部“分层思想”为后续学习 Vue/React 组件机制打基础的人。读完本文你可以独立实现一个带样式隔离、属性传参、事件通信的组件系统并把它嵌入到普通 HTML 页面中使用。2. 技术选型为什么选原生 Web Components2.1 原生方案的优势实现 HTML 组件系统通常有几条路线方案优点缺点原生 Web Components浏览器原生支持、无需构建工具、可跨框架复用API 相对底层需要自己处理一些细节Vue/React 单文件组件生态完善、模板语法友好需要引入框架和构建流程组件与框架绑定jQuery 插件式组件上手简单、历史兼容性好全局命名空间污染、样式隔离弱自行封装 JavaScript 类 渲染字符串灵活度高需要自己维护完整渲染和更新流程工程量大我不主张所有场景都抛弃框架但如果你只是想在一批静态页面或多页面项目中统一 UI 组件原生 Web Components 是很划算的选择。它不需要 Node 环境不需要 Webpack直接在浏览器里就能跑。2.2 核心浏览器 API这套方案依赖三个关键 APICustom Elements自定义元素允许你注册一个新的 HTML 标签例如user-card并把它绑定到一个类上。Shadow DOM影子 DOM给组件创建一个独立的 DOM 子树组件内部的样式不会泄漏到外部外部样式也不会随意影响组件内部。template标签定义一个不会被浏览器渲染的 HTML 片段JavaScript 可以在需要时克隆它。这三个能力组合起来就是“分层组件系统”的技术底座。2.3 浏览器兼容性现代浏览器Chrome、Edge、Firefox、Safari基本都支持 Custom Elements 和 Shadow DOM。如果只需要支持现代浏览器可以放心使用。如果需要兼容很旧的浏览器建议先做能力检测再决定是否降级处理。if (customElements in window) { // 支持 Custom Elements } else { // 降级方案或提示用户升级浏览器 }这点在实际项目中很重要可以在入口处加类似判断。3. 环境准备与项目结构3.1 运行环境本文示例只需要现代浏览器Chrome/Edge 最新版即可。一个文本编辑器或 IDEVS Code、WebStorm 都可以。不需要安装 Node.js、不需要打包工具。版本需要根据你的项目实际情况调整本文重点演示实现思路。3.2 项目目录结构建议按下面的结构组织代码。虽然这是一个纯前端示例但合理的目录结构能帮助后续维护。layered-component-system/ ├── index.html ├── css/ │ └── app.css └── js/ ├── components/ │ ├── user-card.js │ └── user-list.js └── app.jsjs/components/放组件类每个组件一个文件。js/app.js放页面初始化逻辑。index.html是组件的使用方只负责引入脚本、组合组件。3.3 一个最简组件的分层拆解先看一个最基础的组件它展示一段文字// 文件路径js/components/hello-box.js class HelloBox extends HTMLElement { connectedCallback() { this.innerHTML style .box { border: 1px solid #ddd; padding: 16px; border-radius: 8px; } /style div classbox你好分层组件/div ; } } customElements.define(hello-box, HelloBox);在页面中使用!-- index.html -- hello-box/hello-box这个组件虽然简单但已经包含三个层面的雏形模板层div classbox结构。样式层style里的边框和间距。逻辑层connectedCallback生命周期回调负责在挂载时渲染内容。不过这个写法还不算真正意义上的“分层”因为它把模板和逻辑揉在一起了。接下来我们会逐步改造成更清晰的分层结构。4. 核心实现逐层搭建组件4.1 模板层使用template分离结构把 HTML 结构放到template里好处是结构代码不再混在 JavaScript 字符串中便于阅读和修改。先在 HTML 中定义一个模板template iduser-card-template div classuser-card img classavatar alt用户头像 / div classinfo p classname/p p classrole/p /div button classfollow-btn关注/button /div /template然后在组件逻辑中克隆这个模板class UserCard extends HTMLElement { connectedCallback() { const template document.getElementById(user-card-template); const content template.content.cloneNode(true); this.appendChild(content); } }这样一来模板层就从逻辑层中独立出来了。后续如果结构变化直接改template即可不需要动 JavaScript。4.2 样式层Shadow DOM 实现样式隔离上面直接appendChild模板内容样式是全局共享的。组件之间的类名一旦相同就可能互相干扰。更推荐使用 Shadow DOM。只要在构造器中调用attachShadow组件内部就自动拥有隔离的样式作用域。class UserCard extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); const template document.getElementById(user-card-template); const content template.content.cloneNode(true); shadow.appendChild(content); } }这时模板里的样式只对组件内部生效。比如模板里有.user-card { ... }外部页面的.user-card样式不会影响它组件内部样式也不会泄漏出去。为了让组件完全自包含通常会把style也放进template里template iduser-card-template style .user-card { display: flex; align-items: center; gap: 12px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; } .avatar { width: 48px; height: 48px; border-radius: 50%; background: #e0e0e0; } .name { font-size: 16px; font-weight: 600; margin: 0; } .role { font-size: 13px; color: #888; margin: 4px 0 0; } .follow-btn { margin-left: auto; border: none; background: #1a73e8; color: #fff; padding: 8px 16px; border-radius: 8px; cursor: pointer; } /style div classuser-card img classavatar alt用户头像 / div classinfo p classname/p p classrole/p /div button classfollow-btn关注/button /div /template这就是“样式层”落地的最好方式隔离、清晰、不污染外部。4.3 逻辑层生命周期与交互Custom Elements 提供了几个生命周期回调回调触发时机constructor元素实例化时适合初始化状态、创建 Shadow DOMconnectedCallback元素被插入文档时适合绑定事件、执行首次渲染disconnectedCallback元素从文档移除时适合解绑事件、清理定时器attributeChangedCallback监听属性变化时触发下面是一个带交互的示例。按钮点击后切换“关注/已关注”状态class UserCard extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); const template document.getElementById(user-card-template); this.shadow shadow; shadow.appendChild(template.content.cloneNode(true)); this.isFollowing false; } connectedCallback() { const btn this.shadow.querySelector(.follow-btn); btn.addEventListener(click, this.toggleFollow.bind(this)); this.render(); } toggleFollow() { this.isFollowing !this.isFollowing; this.render(); } render() { const btn this.shadow.querySelector(.follow-btn); if (this.isFollowing) { btn.textContent 已关注; btn.style.background #999; } else { btn.textContent 关注; btn.style.background #1a73e8; } } } customElements.define(user-card, UserCard);在这个实现里逻辑层通过toggleFollow方法控制状态通过render方法同步视图。状态和渲染是分离的这就是逻辑层的核心职责。4.4 数据层属性与响应式更新组件往往需要从外部接收数据。Custom Elements 的 standard 做法是观察标签属性。先声明要观察的属性static get observedAttributes() { return [name, role, avatar]; }然后在构造器中渲染初始值在attributeChangedCallback中响应属性变化class UserCard extends HTMLElement { static get observedAttributes() { return [name, role, avatar]; } constructor() { super(); const shadow this.attachShadow({ mode: open }); const template document.getElementById(user-card-template); shadow.appendChild(template.content.cloneNode(true)); this.shadow shadow; } connectedCallback() { this.render(); } attributeChangedCallback() { this.render(); } render() { const name this.getAttribute(name) || 未知用户; const role this.getAttribute(role) || 普通成员; const avatar this.getAttribute(avatar) || ; const nameEl this.shadow.querySelector(.name); const roleEl this.shadow.querySelector(.role); const avatarEl this.shadow.querySelector(.avatar); nameEl.textContent name; roleEl.textContent role; if (avatar) { avatarEl.src avatar; } } } customElements.define(user-card, UserCard);使用方式user-card name张三 role前端工程师 avatarhttps://example.com/avatar.png /user-card这样外部只需要通过属性传值组件内部自动更新视图。数据层和模板层通过render()方法桥接起来。4.5 组件间通信自定义事件组件之间不能互相直接操作 DOM更合适的做法是通过自定义事件向上层广播。例如在用户列表组件中当点击某个用户卡片的“关注”按钮时把用户名传给外部页面class UserCard extends HTMLElement { connectedCallback() { const btn this.shadow.querySelector(.follow-btn); btn.addEventListener(click, () { this.dispatchEvent(new CustomEvent(user-follow, { detail: { name: this.getAttribute(name) }, bubbles: true, composed: true })); }); } }外部页面监听const card document.querySelector(user-card); card.addEventListener(user-follow, (event) { console.log(关注了用户, event.detail.name); });这里的bubbles: true和composed: true很关键。bubbles让事件能向父节点冒泡composed让事件能穿透 Shadow DOM 边界传递到文档层。如果不设置这两个值在 Shadow DOM 内部触发的事件外部很难收到。5. 完整实战实现一个分层可复用的用户卡片系统5.1 需求分析我们来实现一个用户卡片系统包含两个组件user-card单张用户卡片展示姓名、角色、头像支持关注/取消关注。user-list用户列表容器接收一组用户数据渲染多个user-card。页面整体结构页面 └── user-list ├── user-card ├── user-card └── user-card5.2 创建项目目录按第 3 节的目录结构创建文件layered-component-system/ ├── index.html ├── css/ │ └── app.css └── js/ ├── components/ │ ├── user-card.js │ └── user-list.js └── app.js5.3 编写 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title分层 HTML 组件系统示例/title link relstylesheet hrefcss/app.css /head body main classpage h1团队成员列表/h1 user-list idteam-list/user-list /main script srcjs/components/user-card.js/script script srcjs/components/user-list.js/script script srcjs/app.js/script /body /html5.4 编写全局样式/* 文件路径css/app.css */ body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; background: #f5f7fa; margin: 0; padding: 40px 20px; } .page { max-width: 720px; margin: 0 auto; } .page h1 { font-size: 24px; color: #1a1a1a; margin-bottom: 24px; }这些是页面级样式只作用于页面本身不会渗透到组件内部的 Shadow DOM 中正好用来验证样式隔离效果。5.5 实现 user-card 组件// 文件路径js/components/user-card.js class UserCard extends HTMLElement { static get observedAttributes() { return [name, role, avatar]; } constructor() { super(); const shadow this.attachShadow({ mode: open }); this.shadow shadow; this.isFollowing false; const template document.createElement(template); template.innerHTML style .user-card { display: flex; align-items: center; gap: 12px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; transition: box-shadow 0.2s ease; } .user-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); } .avatar { width: 48px; height: 48px; border-radius: 50%; background: #e0e0e0; flex-shrink: 0; object-fit: cover; display: block; } .info { min-width: 0; } .name { font-size: 16px; font-weight: 600; margin: 0; color: #1a1a1a; } .role { font-size: 13px; color: #888; margin: 4px 0 0; } .follow-btn { margin-left: auto; border: none; background: #1a73e8; color: #fff; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 14px; transition: background 0.2s ease; flex-shrink: 0; } .follow-btn.following { background: #9ca3af; } /style div classuser-card img classavatar alt用户头像 / div classinfo p classname/p p classrole/p /div button classfollow-btn关注/button /div ; shadow.appendChild(template.content.cloneNode(true)); } connectedCallback() { const btn this.shadow.querySelector(.follow-btn); btn.addEventListener(click, this.handleFollowClick.bind(this)); this.render(); } handleFollowClick() { this.isFollowing !this.isFollowing; this.render(); this.dispatchEvent(new CustomEvent(user-follow, { detail: { name: this.getAttribute(name), following: this.isFollowing }, bubbles: true, composed: true })); } attributeChangedCallback() { this.render(); } render() { const name this.getAttribute(name) || 未知用户; const role this.getAttribute(role) || 普通成员; const avatar this.getAttribute(avatar) || ; this.shadow.querySelector(.name).textContent name; this.shadow.querySelector(.role).textContent role; const avatarEl this.shadow.querySelector(.avatar); if (avatar) { avatarEl.src avatar; } const btn this.shadow.querySelector(.follow-btn); if (this.isFollowing) { btn.textContent 已关注; btn.classList.add(following); } else { btn.textContent 关注; btn.classList.remove(following); } } } customElements.define(user-card, UserCard);组件分层说明样式层Shadow DOM 内的style保证卡片样式不影响外部页面外部样式也进不来。模板层template.innerHTML定义卡片结构。逻辑层connectedCallback中绑定事件handleFollowClick处理交互。数据层observedAttributesattributeChangedCallback完成外部属性到内部视图的同步。5.6 实现 user-list 组件列表容器组件负责接收数据循环渲染user-card。因为要在 JavaScript 中动态创建组件所以这里没有用模板字符串而是直接创建 DOM 元素。// 文件路径js/components/user-list.js class UserList extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); this.shadow shadow; const style document.createElement(style); style.textContent .list-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 16px; } .list-container { display: flex; flex-direction: column; gap: 12px; } .empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; border: 1px dashed #ddd; border-radius: 12px; } ; shadow.appendChild(style); const title document.createElement(p); title.className list-title; title.textContent this.getAttribute(title) || 用户列表; this.titleEl title; shadow.appendChild(title); const container document.createElement(div); container.className list-container; this.containerEl container; shadow.appendChild(container); } connectedCallback() { this.render(); } set users(value) { this._users value; this.render(); } get users() { return this._users || []; } render() { // 清空容器 while (this.containerEl.firstChild) { this.containerEl.removeChild(this.containerEl.firstChild); } const list this.users; if (list.length 0) { const emptyTip document.createElement(div); emptyTip.className empty-tip; emptyTip.textContent 暂无用户数据; this.containerEl.appendChild(emptyTip); return; } list.forEach((user) { const card document.createElement(user-card); card.setAttribute(name, user.name); card.setAttribute(role, user.role); if (user.avatar) { card.setAttribute(avatar, user.avatar); } this.containerEl.appendChild(card); }); } } customElements.define(user-list, UserList);user-list组件内部有一个render()方法外部可以通过users属性传入数据const list document.querySelector(#team-list); list.users [ { name: 张三, role: 前端工程师 }, { name: 李四, role: 后端工程师 }, { name: 王五, role: 产品经理 } ];5.7 编写入口脚本// 文件路径js/app.js const teamData [ { name: 张三, role: 前端工程师, avatar: }, { name: 李四, role: 后端工程师, avatar: }, { name: 王五, role: 产品经理, avatar: } ]; const userList document.querySelector(#team-list); userList.users teamData; // 监听关注事件 userList.addEventListener(user-follow, (event) { console.log(关注操作${event.detail.name}状态${event.detail.following ? 已关注 : 未关注}); });5.8 运行与验证直接用浏览器打开index.html。预期效果页面展示一个列表容器组件。列表内动态渲染三张用户卡片。点击“关注”按钮文字变成“已关注”按钮颜色变灰。点击按钮后浏览器控制台输出关注日志。在浏览器中右键检查能看到user-card下有一个#shadow-root组件的内部结构都在这个 shadow root 中。这也是判断组件是否生效的最直观方式。5.9 结果说明到这里一个完整的“分层 HTML 组件系统”就已经可以运行了。user-card是基础单元负责单卡片的展示与交互。user-list是容器组件负责数据的循环渲染与布局。组件之间通过自定义事件user-follow通信。页面本身只需要定义一个user-list标签不再关心内部实现。这种结构在业务中最大的优势是如果后续需要新增“用户卡片”的展示形态只需要修改user-card.js一个文件如果后续要改列表布局只需要修改user-list.js。模块之间互不干扰。6. 常见问题与排查思路问题现象常见原因解决思路自定义标签显示为空脚本未加载或注册失败检查customElements.define是否执行确认脚本引入顺序组件样式被外部影响没有使用 Shadow DOM在构造函数中调用attachShadow并将内容挂载到 shadow root组件内部图片不显示图片路径或src属性未正确设置检查avatar属性是否传值检查图片地址是否可访问点击事件不生效事件绑定发生在render()之前元素被重新渲染覆盖在connectedCallback中绑定事件或使用事件委托属性变化不会触发更新忘记声明observedAttributes在类中定义static get observedAttributes()Shadow DOM 内部事件外部收不到自定义事件没有设置composed: true创建CustomEvent时传入{ bubbles: true, composed: true }多个组件实例状态互相影响直接把共享变量挂在类静态属性上使用构造函数constructor中的实例属性保存状态组件脚本加载顺序不对页面还没准备好就执行脚本将script放在 body 底部或使用DOMContentLoaded逐个说明几个高频问题问题一自定义元素不渲染最常见原因是脚本没有真正执行。打开浏览器控制台先确认有没有报错信息。如果customElements.define(user-card, UserCard)运行时报错通常会提示类名未定义或标签名不合法。自定义标签名必须包含短横线比如user-card、user-listusercard这种命名会被浏览器拒绝。问题二样式没有隔离如果组件模板是直接this.innerHTML ...写入的那它就不在 Shadow DOM 中样式也必然全局共享。要使用attachShadow并挂载到 shadow root才能获得样式隔离。问题三事件绑定丢失如果render()方法内部重建了整个 DOM比如每次渲染都用this.shadow.innerHTML ...那么之前绑定的按钮事件会因为 DOM 被替换而丢失。解决方案有两个在构造器中用变量保存按钮引用只更新按钮的textContent和class不重建整个 DOM。使用事件委托在 shadow root 上监听按钮点击。我推荐第一种也是本文示例采用的方式简单直接。7. 最佳实践与工程建议7.1 命名规范自定义元素标签名必须包含短横线且全小写。推荐采用“业务前缀 组件名”的方式user-carduser-listchart-linenav-menu统一前缀不仅减少命名冲突还能在代码中一眼看出组件归属哪个业务模块。7.2 属性与状态边界外部传入的数据通过 HTML 属性传递组件内部可变状态保存在this实例属性上。不要把内部状态回写到 HTML 属性上否则容易混淆数据来源。如果需要传递对象、数组这类复杂数据可以把数据直接挂在组件的 JavaScript 属性上比如list.users [...]。这是自定义元素支持的不需要通过属性字符串传递。7.3 渲染性能attributeChangedCallback有可能会在高频更新属性时频繁触发。如果组件渲染逻辑较重可以加一层节流或防抖attributeChangedCallback() { clearTimeout(this._renderTimer); this._renderTimer setTimeout(() { this.render(); }, 16); }7.4 组件卸载清理在disconnectedCallback中清理全局事件监听、定时器、异步请求disconnectedCallback() { if (this._timer) { clearInterval(this._timer); this._timer null; } }这是很容易被忽略的一点尤其是在长列表或 SPA 场景中组件反复挂载卸载如果不清理会造成内存泄漏。7.5 组件复用与团队协作给每个组件文件加文件头注释标明组件职责、使用属性、对外事件对团队协作很有帮助/** * user-card 用户卡片组件 * * 属性 * - name: 用户昵称 * - role: 用户角色 * - avatar: 头像地址 * * 事件 * - user-follow: 点击关注按钮时触发 */7.6 浏览器兼容性检查如果项目需要兼容旧版浏览器可以在入口脚本中统一做能力检测。不支持时给出提示或加载 polyfill而不是让页面直接白屏。if (!(customElements in window)) { document.body.innerHTML 当前浏览器不支持 Web Components请升级浏览器; }7.7 使用时机原生 Web Components 不是万能的。如果需要复杂状态管理、数据响应式机制、大型组件树的高性能 diffVue、React 这类框架依然是更成熟的选择。但在多页面应用、内容型站点、或者需要长期维护且不想引入构建链路的场景中这套分层组件系统完全值得一试。8. 总结与后续学习方向这篇文章从“如何避免页面重复代码”的问题出发完整实现了一个基于原生 Web Components 的分层 HTML 组件系统。核心要点可以归结为以下几点组件系统天然要分层模板层管结构、样式层管外观、逻辑层管交互、数据层管输入输出。Shadow DOM 是样式隔离的关键没有它组件就谈不上“封装”。Custom Elements 生命周期是组件逻辑的骨架connectedCallback和attributeChangedCallback最常用。组件间通信优先使用自定义事件并记得设置bubbles: true和composed: true。合理的目录结构和命名规范比代码本身更能决定组件系统的可维护性。如果你还不熟悉 HTML 基础语法建议先补齐 HTML、CSS、JavaScript 的基础知识再回头看组件的实现如果你已经掌握了本文内容下一步可以继续深入::part和::slotted的用法它们用来外部控制 Shadow DOM 内部的局部样式。slot插槽机制它能让组件内部插入外部传入的 HTML 结构。Form-associated custom elements它可以让自定义组件像表单项一样参与表单提交和校验。浏览器模块化加载方式例如用 ES Module 的import和export重构组件文件。动手实践是最好的学习方式。建议你先把本文示例源码敲一遍然后在此基础上增加一个“删除用户”按钮再想想删除后列表容器如何感知这条消息。把这个功能实现完你对整个分层组件系统的理解会上一个台阶。如果本文对你有帮助可以收藏备用。后续遇到组件样式泄漏、事件穿透等问题时回来翻一翻这几节内容应该能节省不少排查时间。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门