分层HTML组件系统:基于Web Components的架构实践
做 Web 前端的人大概都经历过这样一个阶段:组件数量不断膨胀,样式文件越写越长,你只是改了一个按钮的圆角,结果发现好几个页面的样式同时变了。很多人第一反应是“CSS 命名规范没做好”,于是去加前缀、用 BEM、上 CSS Modules。但真到了项目后期你会发现,根本问题不是命名,而是组件的职责边界没有在架构层面被划分清楚。命名规范是在“同一层内部”做约束,而当一个组件既管样式又管业务逻辑还管接口请求时,再严格的命名也救不了代码的可维护性。
这篇文章要讲的分层 HTML 组件系统,是一种从架构层面解决组件复杂度问题的思路。它把组件按照“基础单元、组合单元、业务页面”三个层级组织,每一层只做自己该做的事。上层通过标准接口依赖下层,下层不反向依赖上层。这样设计之后,样式隔离、逻辑复用、状态管理会清晰很多,团队多人协作时也更容易划分边界。
读完这篇文章,你会明白三件事:分层 HTML 组件系统到底在分什么;如何用原生 Web Components(Custom Elements + Shadow DOM + Slot)从零搭建一个可运行的分层组件系统;这套架构在真实项目中会遇到哪些坑,以及怎么规避。
1. 分层 HTML 组件系统:为什么现在值得关注
先看一个真实场景。假设项目里只有一个按钮组件,初期很简单,十几个页面共用,大家都开心。后来 A 页面需要一个“次要按钮”,你给组件加了一个secondary属性;B 页面需要一个禁用态,你加了disabled;C 页面需要加载状态,你加了loading。半年之后,这个按钮组件的属性列表越来越长,if/else分支越来越多,每一个新需求都要来改这个组件。更麻烦的是,有人发现页面需要一个“带图标、带气泡提示、带异步请求的按钮”,他不敢直接改公共组件,于是在自己的页面里复制了一份样式,又造了一个半新不旧的组件。
这就是无分层组件系统的典型症状:组件因为承载了过多职责而腐化,团队因为害怕改公共代码而选择复制粘贴,最终样式和逻辑同时在项目里扩散。
分层 HTML 组件系统的价值就在这里。它不依赖某个人的自觉,而是通过明确的分层规则,从架构上阻止这种腐化。基础层只提供原子组件,组合层负责把原子组件拼成可复用的功能模块,页面层只处理业务逻辑和页面编排。每一层都有明确的能力边界,跨层调用被规则限制。
为什么现在值得关注?因为原生 Web Components 技术已经足够成熟。Custom Elements、Shadow DOM、HTML Templates、Slot 这些标准能力,让开发者不需要引入任何框架就能实现组件封装、样式隔离和组合复用。换句话说,分层组件系统不一定要绑定 React 或 Vue,它可以是一套“框架无关”的架构方案。对于团队里同时存在多个前端框架、或者想长期维护一套 UI 组件的项目来说,这种方案非常有吸引力。
这篇文章的读者,适合以下三种情况:第一种,你正在维护一个长期演进的中后台项目,组件越来越多,需要一套可落地的组件架构规范;第二种,你想用原生 HTML/JavaScript 实现一个不依赖框架的组件库,并且希望结构经得起推敲;第三种,你听过 Web Components 但一直没找到一套完整的分层实践案例,想看看它在真实项目里怎么组织。
2. 核心概念:组件系统、分层架构与 Web Components
2.1 HTML 组件系统是什么
广义地说,HTML 组件系统就是把页面拆成若干个独立、可复用、职责清晰的 UI 单元。一个按钮、一个输入框、一张卡片、一个表单,都可以是组件。组件系统的核心能力有三个:封装(内部细节不暴露给外部)、复用(同一个组件可以出现在多个地方)、组合(多个小组件能拼成大组件)。
在原生 HTML 时代,页面是“一块一块”静态拼出来的,组件概念只存在于后端模板或者前端工程约定里。后来前端框架崛起,React 的组件、Vue 的 SFC(单文件组件)把组件系统推向了主流。但框架组件有一个问题:它们和框架运行时深度绑定。你今天用 React 写的组件,明天要拿到 Vue 项目里,基本不可能直接用。
2.2 “分层”到底分什么
“分层”这个词在软件工程里并不新鲜,三层架构、MVC、MVP,都是分层思想的应用。放到 HTML 组件系统里,分层的本质是把“结构、样式、行为、数据”这些不同的关注点拆开,再把组件按“抽象程度”从低到高组织起来。
这里要区分两个容易混淆的概念:
- 组件内部的分层:单个组件内部,HTML 管结构、CSS 管表现、JavaScript 管行为。这是纵向拆分。
- 组件之间的分层:整个系统里,基础组件在最底层,组合组件在中间,业务页面组件在最上层。这是横向聚合。
很多人说“我会写组件”,其实只做到了一半。他能把一段 HTML 封装成一个自定义元素,但组件之间没有层级约束,组件职责越写越杂。分层 HTML 组件系统真正解决的,是横向聚合这一层的问题。
2.3 Web Components 四项核心能力
要落地分层组件系统,先要理解 Web Components 的四项标准能力。
Custom Elements(自定义元素):允许开发者定义新的 HTML 标签,比如<my-button>、<my-card>。自定义元素必须包含短横线命名,这是硬性规范,目的是避免与原 HTML 标签冲突。
Shadow DOM(影子 DOM):为组件提供独立的 DOM 树和样式作用域。组件内部的样式默认不会泄漏到外部,外部样式也默认无法穿透进入组件内部。这是实现样式隔离的关键能力。
HTML Templates(模板):<template>标签可以声明一段不会立即渲染的 HTML 片段,需要时再克隆使用。它适合定义组件的静态结构,避免反复用 JavaScript 拼接字符串。
Slot(插槽):让组件的使用者可以向组件内部“投影”内容。插槽实现了组合能力:组件外壳是固定的,里面的内容可以按需替换。
这几项能力配合起来,正好覆盖了组件系统需要的封装、复用和组合。下面这张表可以更直观地看到分层组件系统和传统写法的差异。
| 维度 | 传统 DIV + 类名约束 | 原生 Web Components 分层系统 |
|---|---|---|
| 样式隔离 | 靠命名规范人工维持 | Shadow DOM 默认隔离 |
| 组件定义 | 无标准,靠框架或约定 | Custom Elements 标准定义 |
| 组合方式 | 嵌套或拼接模板 | Slot 插槽,结构清晰 |
| 逻辑复用 | 复制粘贴或抽公共函数 | 按层复用,职责边界明确 |
| 框架绑定 | 取决于所选框架 | 框架无关,任何项目可直接使用 |
2.4 与现代前端框架的关系
分层 HTML 组件系统和 React/Vue 并不互斥。它更像是一种底层组件策略:你可以用原生 Web Components 建设底层组件库,然后在 React/Vue 项目中直接引用这些组件。Vue 官方文档专门有“Web Components 集成”章节,React 18 之后也支持在 JSX 中直接使用自定义元素。这意味着,把基础组件层做得足够“框架无关”,可以同时服务于多个上层技术栈。
当然,框架组件在响应式更新、状态管理、生态工具方面仍然有巨大优势。分层架构的关键判断是:基础组件层和组合组件层尽量保持框架无关,页面业务层可以自由使用框架。这种“混合”策略在实际项目中非常常见。
3. 三层架构设计:基础层、组合层、页面层
这套分层 HTML 组件系统把组件分为三个层级。设计核心是两条规则:上层可以依赖下层,下层绝对不能依赖上层;同层之间尽量不要互相依赖,必须协作时通过事件解耦。
3.1 基础组件层(Base Layer)
基础组件层是系统的原子单元,对应设计系统里的“原子”。这一层的组件通常不包含业务概念,只负责具体的 UI 交互。按钮、输入框、复选框、单选按钮、图标、通知提示,都属于这一层。
基础组件的特征有三个:
- 不感知业务数据,不发起接口请求。
- 通过属性和事件对外通信。
- 样式完全自包含,外部不需要也不能侵入其内部实现。
3.2 组合组件层(Composite Layer)
组合组件层把多个基础组件组合成有意义的“功能模块”,对应设计系统里的“分子”和“组织”。例如一个搜索框,内部由输入框、按钮、下拉列表组合而成;一个表单项,由标签、输入框、校验提示组合而成。
组合组件可以包含少量业务逻辑,比如表单校验、状态切换、请求编排,但这些逻辑应该与具体业务场景解耦。组合组件面向的是“通用功能”,而不是“某个具体页面”。
3.3 页面业务层(Page Layer)
页面业务层是分层系统的最上层,直接对应用户看到的页面。这一层负责编排组合组件,处理具体业务数据,管理页面级状态,调用接口服务。页面组件可以依赖任意下层组件,但下层组件不应该感知页面组件的存在。
页面层是唯一允许出现“这个页面专属逻辑”的地方。比如“用户资料管理页面”的数据结构、校验规则、保存回调,都应该集中在页面组件里,而不是散落到基础组件中。
3.4 层间通信规则
组件层之间的通信必须遵守方向约束:
- 属性向下传递:父组件通过 HTML 属性给子组件传值,例如
<my-input label="姓名" value="张三">。 - 事件向上抛出:子组件通过自定义事件通知父组件发生了交互,父组件监听事件并处理。
- 禁止跨层穿透:页面组件不能直接修改基础组件的内部 DOM;基础组件也不能反向调用页面组件的业务方法。
这条规则的工程意义在于:当某一个下级组件需要重构时,只要它对外暴露的属性和事件不变,上层调用方就不需要改动。层与层之间通过“接口契约”连接,而不是通过具体实现连接。
4. 环境准备与项目结构
4.1 运行环境
这个示例使用原生 Web Components,不需要安装任何前端框架,也不需要 Webpack 或 Vite 构建。需要满足两个条件:
- 使用现代浏览器,推荐 Chrome、Edge、Firefox 最新版,以正常支持 Custom Elements 和 Shadow DOM。版本请以实际浏览器为准,本文重点演示通用思路。
- 通过 HTTP 服务访问页面,不要直接双击
index.html文件。因为示例会使用 ES Module 的import/export语法,file://协议下浏览器会拦截模块加载,这是安全策略的正常行为。
如果你本机安装了 Python,可以在项目根目录执行下面的命令启动一个临时静态文件服务:
python3 -m http.server 8080如果没有 Python,也可以用 VS Code 的 Live Server 插件,或者任意静态文件服务器。只要能通过 HTTP 访问到index.html即可。
4.2 项目目录结构
整个项目按“分层”组织目录,目录结构本身就是架构的直观表达。
layered-html-components/ ├── index.html └── src/ ├── core/ # 核心基础类与工具 │ └── base-component.js ├── components/ │ ├── base/ # 基础组件层 │ │ ├── my-button.js │ │ └── my-input.js │ ├── composite/ # 组合组件层 │ │ └── my-card.js │ └── page/ # 页面业务层 │ └── my-user-profile.js目录命名习惯建议固定:base放基础组件,composite放组合组件,page放页面组件。新组件属于哪一层,直接由目录决定,不需要开会讨论。这是分层架构最落地的一种约束方式。
5. 完整实现:从零搭建分层组件系统
下面用一个“用户个人信息表单”作为演示场景。它的结构是:页面层my-user-profile组合了卡片my-card,卡片内部放输入框my-input和按钮my-button。输入框、按钮属于基础层,卡片属于组合层,用户信息表单属于页面层。
5.1 核心基础类:BaseComponent
先定义一个所有组件都可以继承的基础类。它负责统一的生命周期入口和状态管理约定。注意,它是核心层的辅助类,本身不是一个可见组件。
// 文件路径:src/core/base-component.js export class BaseComponent extends HTMLElement { constructor() { super(); // 统一状态对象,子组件可以按需使用 this.state = {}; } // 组件被挂载到文档后触发 connectedCallback() { if (this.mounted) { this.mounted(); } } // 子组件统一通过 setState 更新状态 setState(nextState) { this.state = { ...this.state, ...nextState }; this.afterStateChange && this.afterStateChange(); } }有了这个基础类,所有组件都拥有统一的state管理入口和connectedCallback生命周期。后面实现具体组件时,只需继承BaseComponent,覆盖需要的方法即可。
5.2 基础组件层:my-button
基础按钮组件是最典型的原子组件。它具备三种视觉变体(默认、次要、禁用态),对外抛出一个my-click自定义事件。按钮不关心点击之后要做什么,它只负责把“用户点击了”这个消息抛出去。
// 文件路径:src/components/base/my-button.js import { BaseComponent } from '../../core/base-component.js'; export class MyButton extends BaseComponent { static get observedAttributes() { return ['variant', 'disabled', 'label']; } constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { display: inline-block; } button { border: none; border-radius: 6px; padding: 8px 16px; font-size: 14px; cursor: pointer; background: #2f6fed; color: #ffffff; transition: opacity 0.2s; } button:hover { opacity: 0.85; } button:disabled { opacity: 0.4; cursor: not-allowed; } button.secondary { background: #f0f1f3; color: #333333; } `; const button = document.createElement('button'); button.addEventListener('click', () => { if (this.disabled) { return; } this.dispatchEvent(new CustomEvent('my-click', { detail: { value: this.getAttribute('value') || '' }, bubbles: true, composed: true })); }); shadow.appendChild(style); shadow.appendChild(button); this._button = button; } attributeChangedCallback(name, oldValue, newValue) { if (!this._button) { return; } if (name === 'label') { this._button.textContent = newValue; } if (name === 'disabled') { this._button.disabled = newValue !== null; } if (name === 'variant') { this._button.classList.toggle('secondary', newValue === 'secondary'); } } } customElements.define('my-button', MyButton);注意两个细节。第一,自定义事件my-click设置了bubbles: true和composed: true。bubbles让事件可以在普通 DOM 树中向上冒泡,composed让事件可以跨越 Shadow DOM 边界继续冒泡。如果漏掉composed,父组件在 Shadow DOM 外部可能监听不到这个事件。第二,attributeChangedCallback在属性变化时同步更新按钮内部状态,这是“属性向下传递”的落地实现。
5.3 基础组件层:my-input
输入框组件同样属于基础层。它封装了<label>、<input>、错误提示三部分,并支持label、value、placeholder、error四个属性。输入事件通过my-input自定义事件向上抛出。
// 文件路径:src/components/base/my-input.js import { BaseComponent } from '../../core/base-component.js'; export class MyInput extends BaseComponent { static get observedAttributes() { return ['label', 'value', 'placeholder', 'error']; } constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { display: block; margin-bottom: 12px; } label { display: block; font-size: 13px; color: #555555; margin-bottom: 4px; } input { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid #cccccc; border-radius: 6px; font-size: 14px; } input:focus { outline: none; border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.15); } .error-hint { color: #d93025; font-size: 12px; margin-top: 4px; display: none; } </style> <label></label> <input /> <div class="error-hint"></div> `; this._label = shadow.querySelector('label'); this._input = shadow.querySelector('input'); this._errorHint = shadow.querySelector('.error-hint'); this._input.addEventListener('input', () => { this.dispatchEvent(new CustomEvent('my-input', { detail: { value: this._input.value }, bubbles: true, composed: true })); }); } static createAttributeChangedCallback() { return null; } attributeChangedCallback(name, oldValue, newValue) { if (!this._input) { return; } if (name === 'label') { this._label.textContent = newValue; } if (name === 'value') { // 仅当外部属性值与当前输入值不一致时才更新,避免覆盖用户输入 if (this._input.value !== newValue) { this._input.value = newValue; } } if (name === 'placeholder') { this._input.placeholder = newValue; } if (name === 'error') { this._errorHint.textContent = newValue || ''; this._errorHint.style.display = newValue ? 'block' : 'none'; this._input.style.borderColor = newValue ? '#d93025' : ''; } } get value() { return this._input.value; } set value(v) { this._input.value = v; } } customElements.define('my-input', MyInput);这里有一个新手容易踩的坑:在attributeChangedCallback里更新value属性时,如果外部设置的值和用户当前输入的值不一致,直接覆盖;如果一致,就不要重复赋值,否则可能把光标位置重置到末尾,影响输入体验。这个“属性与状态同步”的问题,是属性反射设计的核心难点。
5.4 组合组件层:my-card
卡片组件是组合层的一个简单例子。它本身不包含具体的业务逻辑,只负责提供统一的视觉容器,并通过 Slot 插槽让使用方自由填写标题、操作区和内容区。
// 文件路径:src/components/composite/my-card.js import { BaseComponent } from '../../core/base-component.js'; export class MyCard extends BaseComponent { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { display: block; border: 1px solid #e5e6eb; border-radius: 12px; padding: 16px; background: #ffffff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .card-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f1f3; padding-bottom: 10px; margin-bottom: 12px; } .card-title { font-size: 16px; font-weight: 600; } .card-actions { font-size: 13px; } .card-body { font-size: 14px; color: #444444; } </style> <div class="card-header"> <span class="card-title"> <slot name="title">默认标题</slot> </span> <span class="card-actions"> <slot name="actions"></slot> </span> </div> <div class="card-body"> <slot></slot> </div> `; } } customElements.define('my-card', MyCard);Slot 是组合层实现“组件拼接”的关键。<slot name="title">表示使用方可以提供slot="title"的内容;没有提供时,显示插槽内的默认内容“默认标题”。这种机制让组件外壳保持稳定,内容可以灵活替换,非常符合组合层“通用功能模块”的定位。
5.5 页面业务层:my-user-profile
页面层组件负责真正“接业务”。在这个示例里,my-user-profile内部组装了卡片、输入框、按钮,并实现了表单校验、保存、重置功能。注意,业务数据校验逻辑写在这一层,而不是写在my-input里。
// 文件路径:src/components/page/my-user-profile.js import { BaseComponent } from '../../core/base-component.js'; import '../base/my-button.js'; import '../base/my-input.js'; import '../composite/my-card.js'; export class MyUserProfile extends BaseComponent { constructor() { super(); this.state = { name: '', email: '' }; const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> .profile-form { max-width: 360px; } .form-actions { display: flex; gap: 8px; margin-top: 8px; } .status { font-size: 13px; color: #888888; margin-top: 8px; } </style> <my-card> <span slot="title">个人信息</span> <my-input label="姓名" placeholder="请输入姓名"><!-- 文件路径: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> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f7f8fa; padding: 32px; display: flex; justify-content: center; } .page-container { width: 100%; max-width: 420px; } </style> </head> <body> <div class="page-container"> <my-user-profile></my-user-profile> </div> <script type="module" src="./src/components/page/my-user-profile.js"></script> </body> </html>页面业务层组件在被引入时,会自动导入它依赖的下层组件模块。这种“按需导入、层层依赖”的机制,让整个系统的依赖关系清晰可追踪。你在浏览器 DevTools 的 Sources 面板里,可以清楚地看到每个模块从哪里来。
6. 运行与验证
6.1 启动服务
在项目根目录执行:
python3 -m http.server 8080如果本机是 Windows 且没有python3,可以使用:
python -m http.server 8080也可以使用 VS Code 的 Live Server 插件,右键index.html选择 “Open with Live Server”。
6.2 预期效果
浏览器访问http://localhost:8080,应该看到一张“个人信息”卡片,包含:
- 标题“个人信息”。
- 两个输入框,标签分别为“姓名”和“邮箱”。
- 一个“保存”按钮和一个“重置”按钮。
交互行为预期如下:
- 直接点击“保存”,姓名输入框下方出现红色提示“姓名不能为空”。
- 输入姓名、不输入邮箱,点击“保存”,邮箱输入框提示“邮箱不能为空”。
- 输入姓名和格式错误的邮箱,点击“保存”,邮箱输入框提示“邮箱格式不正确”。
- 输入正确的姓名和邮箱,点击“保存”,卡片底部显示“保存成功:{...}”。
- 点击“重置”,所有输入框清空,错误提示消失,底部状态文本清空。
6.3 用 DevTools 验证封装效果
这是验证分层组件系统最直观的一步。打开 Chrome DevTools 的 Elements 面板,展开<my-user-profile>元素。你会看到里面有一层#shadow-root (open),继续展开,里面又嵌套了<my-card>,再次展开又能看到它自己的#shadow-root。这说明每一层组件的内部 DOM 都被 Shadow DOM 隔离了。
把输入框的error属性设置为错误后,再在 Elements 面板里查看 Shadow DOM 内部,你会发现.error-hint元素的样式完全作用于组件内部,没有影响页面其他区域。这就是样式封装的意义。
6.4 失败时的排查顺序
如果页面空白或者组件没有渲染,按下面的顺序检查:
| 排查顺序 | 检查项 | 判断方法 |
|---|---|---|
| 1 | 是否通过 HTTP 访问 | 地址栏必须是http://localhost:8080,不能是file:// |
| 2 | 浏览器控制台是否有 Module 加载错误 | F12 打开 Console,查看报错路径 |
| 3 | 自定义元素是否注册成功 | Console 执行customElements.get('my-button'),应返回构造函数 |
| 4 | 标签名是否全部包含短横线 | mybutton不会被浏览器识别为自定义元素 |
| 5 | 是否引入了对应 JS 文件 | 检查index.html中的script路径是否正确 |
7. 常见问题与排查思路
分层 HTML 组件系统在实践中会遇到不少问题,下面列出最高频的几类。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 页面空白,无任何组件渲染 | script模块加载失败,或自定义元素未注册 | 打开 Console 查看报错,检查type="module"和文件路径 | 统一通过 HTTP 服务访问,保证模块路径正确 |
| 控制台报错 “Identifier already declared” | 某个组件 JS 文件被重复引入 | 检查模块 import 关系,避免一个组件被多个入口重复加载 | 在页面层统一引入,子模块只导出不重复注册 |
| 自定义元素标签名无效 | 标签名没有包含短横线 | 检查customElements.define的第一个参数 | 标签名必须满足xxx-yyy规范 |
| 样式没有生效或者相互污染 | Shadow DOM 内部样式被外部覆盖,或外部样式穿透 | 检查样式是否写在:host或组件内部<style>中 | 样式写在 Shadow DOM 内部;需要外部定制时使用 CSS 自定义属性,而不是全局类名 |
| 父组件监听不到子组件事件 | 自定义事件缺少composed: true | 在父组件 Shadow DOM 中尝试监听事件,判断是否触发 | 派发事件时设置bubbles: true, composed: true |
外部设置value属性后,输入框光标跳到最后 | 属性回调重复赋值导致光标位置被重置 | 在attributeChangedCallback中打印oldValue/newValue | 只有当外部值与当前输入值不一致时才更新输入框值 |
| Slot 内容不显示 | 插槽名称不匹配或父组件未传对应内容 | 检查<slot name="title">与slot="title"是否一致 | 插槽名统一小写、用短横线连接 |
上面这些坑大多不是分层本身带来的,而是 Web Components 的底层细节。这其实印证了一个观点:分层架构解决的是“组织复杂度”,但底层标准能力不熟练,依然会在细节上翻车。建议在团队推广之前,先让核心成员把 Custom Elements 和 Shadow DOM 的边界行为吃透。
8. 最佳实践与工程建议
8.1 命名规范
- 自定义元素标签名统一采用
前缀-组件名格式,比如my-button、my-card。前缀建议用公司或团队的缩写,避免与第三方组件冲突。 - 属性名使用小写短横线,比如
label、placeholder、>button { background: var(--my-button-bg, #2f6fed); color: var(--my-button-color, #ffffff); }外部要定制主题时,给组件设置:
my-button { --my-button-bg: #16a34a; --my-button-color: #ffffff; }这样既保持了样式隔离,又开放了可控的定制能力。禁止的做法是:在全局样式里直接写
my-button button { ... }试图强行覆盖 Shadow DOM 内部样式,这种做法在封闭 Shadow DOM 下无效,在开放模式下也容易失控。8.4 状态归属与通信
状态应该尽可能放在离使用场景最近的层级。基础组件只维护内部 UI 状态,组合组件维护模块级状态,页面组件维护业务级状态。层间通信严格遵循“属性向下、事件向上”。如果业务状态实在复杂,可以引入现代的轻量状态库,但要注意,状态库应该只作用于页面业务层,不要渗透到基础组件层。否则基础组件会失去“框架无关”的价值。
8.5 性能优化
- 基础组件尽量保持“轻”:减少不必要的
observedAttributes监听,减少 Shadow DOM 内部的 DOM 节点数。 - 大批量渲染场景下,优先使用
document.createDocumentFragment()或<template>来批量创建节点,避免反复触发重排。 - 组件体积较大时,采用动态导入。比如页面组件在
connectedCallback里import()按需加载,而不是在入口一次性引入所有组件。 - 注意 Shadow DOM 带来的事件开销:事件在跨越多个 Shadow 边界时有一定性能损耗,高频事件(如
mousemove、scroll)要避免从组件内部频繁向外派发。
8.6 可访问性
组件系统很容易忽视可访问性(Accessibility)。基础按钮组件要把内部
button的原生语义保留,而不是用div模拟。输入框必须绑定<label>与<input>的关联关系,或者使用aria-label。自定义事件抛出时,也应该同步维护aria状态,比如禁用态设置aria-disabled="true"。8.7 测试策略
分层系统的边界清晰,反而更容易写测试。推荐采用两种测试:
- 单元测试:用 Playwright 或 Web Test Runner 直接挂载单个组件,传入属性、触发事件、断言结果。基础组件和组合组件是单元测试的主力。
- 端到端测试:用 Playwright 打开
index.html,模拟用户输入和点击,验证表单校验、保存、重置完整链路。
测试的关键是把断言写在外部的“公共接口”上。不要断言 Shadow DOM 内部的某个类名,而应该断言组件暴露的属性、事件、可见文本。否则内部实现一调整,测试就大面积失败,维护成本极高。
8.8 安全边界
用原生 JavaScript 拼接组件内容时,最容易犯的安全错误是把用户输入当 HTML 插入。永远不要用
innerHTML直接拼接外部数据。示例代码中全部使用textContent或createElement创建节点,这是安全做法。如果不得不使用模板字符串渲染,也必须经过转义函数处理。组件库作为公共基础设施,安全要求比普通业务页面更高,因为一个组件可能被几十个页面引用。9. 总结与后续学习方向
这篇内容真正讲清楚了几件事。第一,分层 HTML 组件系统的本质,是把组件按照“基础层、组合层、页面层”划分职责边界,用“属性向下、事件向上”的规则约束层间通信。第二,原生 Web Components 提供了实现这套架构的标准能力:Custom Elements 负责定义组件,Shadow DOM 负责样式隔离,Slot 负责组合复用。第三,分层不是银弹,它解决的是组织复杂度,但组件内部的属性反射、事件穿透、光标体验这些底层细节,仍然需要逐个认真处理。
如果你接下来要动手实践,建议按这个顺序推进:先把本文的示例跑通,围绕
my-button和my-input扩展更多基础组件;然后在真实项目里选择两三个核心页面,把现有代码迁移到分层结构,对比迁移前后的改动成本和维护体验;再逐步补充设计令牌(Design Tokens)、主题变量和自动化测试。值得继续深入的方向还有几个:一是如何用设计令牌统一管理各层组件的颜色、间距、圆角,让基础组件层的主题定制更系统化;二是如何把分层组件系统和 Vite、Rollup 等构建工具结合,实现按需打包和组件文档自动生成;三是如何与现有 React/Vue 项目集成,让基础组件层真正做到一次封装、多栈复用。分层架构不是宏大理论,它是把一个朴素的事实反复落在工程里:边界清晰,代码才不会腐烂。建议把本文收藏备用,下次重构组件代码时,按“分层规则”先画边界,再动手写。
- 基础组件尽量保持“轻”:减少不必要的
