先问大家一个问题:当你在做多个页面,发现同一套“卡片 + 头像 + 按钮 + 弹窗”被复制了几十遍,改一处样式要全局搜索替换的时候,是不是开始怀疑人生?
我在早期的业务页面里,就经常遇到这种局面。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 Components
2.1 原生方案的优势
实现 HTML 组件系统,通常有几条路线:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 原生 Web Components | 浏览器原生支持、无需构建工具、可跨框架复用 | API 相对底层,需要自己处理一些细节 |
| Vue/React 单文件组件 | 生态完善、模板语法友好 | 需要引入框架和构建流程,组件与框架绑定 |
| jQuery 插件式组件 | 上手简单、历史兼容性好 | 全局命名空间污染、样式隔离弱 |
| 自行封装 JavaScript 类 + 渲染字符串 | 灵活度高 | 需要自己维护完整渲染和更新流程,工程量大 |
我不主张所有场景都抛弃框架,但如果你只是想在一批静态页面或多页面项目中统一 UI 组件,原生 Web Components 是很划算的选择。它不需要 Node 环境,不需要 Webpack,直接在浏览器里就能跑。
2.2 核心浏览器 API
这套方案依赖三个关键 API:
- Custom 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 最新版即可)。
- 一个文本编辑器或 IDE(VS 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 class="box">你好,分层组件!</div> `; } } customElements.define('hello-box', HelloBox);在页面中使用:
<!-- index.html --> <hello-box></hello-box>这个组件虽然简单,但已经包含三个层面的雏形:
- 模板层:
<div class="box">结构。 - 样式层:
<style>里的边框和间距。 - 逻辑层:
connectedCallback生命周期回调,负责在挂载时渲染内容。
不过这个写法还不算真正意义上的“分层”,因为它把模板和逻辑揉在一起了。接下来我们会逐步改造成更清晰的分层结构。
4. 核心实现:逐层搭建组件
4.1 模板层:使用<template>分离结构
把 HTML 结构放到<template>里,好处是结构代码不再混在 JavaScript 字符串中,便于阅读和修改。
先在 HTML 中定义一个模板:
<template id="user-card-template"> <div class="user-card"> <img class="avatar" alt="用户头像" /> <div class="info"> <p class="name"></p> <p class="role"></p> </div> <button class="follow-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 id="user-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 class="user-card"> <img class="avatar" alt="用户头像" /> <div class="info"> <p class="name"></p> <p class="role"></p> </div> <button class="follow-btn">关注</button> </div> </template>这就是“样式层”落地的最好方式:隔离、清晰、不污染外部。
4.3 逻辑层:生命周期与交互
Custom Elements 提供了几个生命周期回调:
| 回调 | 触发时机 |
|---|---|
constructor | 元素实例化时,适合初始化状态、创建 Shadow DOM |
connectedCallback | 元素被插入文档时,适合绑定事件、执行首次渲染 |
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="前端工程师" avatar="https://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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>分层 HTML 组件系统示例</title> <link rel="stylesheet" href="css/app.css"> </head> <body> <main class="page"> <h1>团队成员列表</h1> <user-list id="team-list"></user-list> </main> <script src="js/components/user-card.js"></script> <script src="js/components/user-list.js"></script> <script src="js/app.js"></script> </body> </html>5.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 class="user-card"> <img class="avatar" alt="用户头像" /> <div class="info"> <p class="name"></p> <p class="role"></p> </div> <button class="follow-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处理交互。 - 数据层:
observedAttributes+attributeChangedCallback完成外部属性到内部视图的同步。
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-list,usercard这种命名会被浏览器拒绝。
问题二:样式没有隔离
如果组件模板是直接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 不是万能的。如果需要复杂状态管理、数据响应式机制、大型组件树的高性能 diff,Vue、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重构组件文件。
动手实践是最好的学习方式。建议你先把本文示例源码敲一遍,然后在此基础上增加一个“删除用户”按钮,再想想删除后列表容器如何感知这条消息。把这个功能实现完,你对整个分层组件系统的理解会上一个台阶。
如果本文对你有帮助,可以收藏备用。后续遇到组件样式泄漏、事件穿透等问题时,回来翻一翻这几节内容,应该能节省不少排查时间。