说起 HTML 组件系统,很多人第一反应是 Vue 组件、React 组件,或者 Web Components。但今天要聊的是另一条路线:不依赖框架,纯靠 HTML、CSS、JavaScript 本身的分层能力,把页面拆成一个可维护、可复用、可覆盖的组件体系。这个方向最适合三类人:写原生前端但项目越来越乱的人、想给团队定一套组件规范的人、以及被各种框架复杂度劝退后想回到 Web 基础底层的开发者。
我先说结论:一个分层 HTML 组件系统,核心价值不是“能用”,而是让样式优先级、组件边界和覆盖规则都变得可预测。很多人项目后期最大的痛苦不是功能写不出来,而是改一个按钮样式要翻半天全局 CSS,加一个新页面才发现旧组件的副作用漏了一地。分层要解决的正是这个问题。
下面按实际落地顺序拆一遍,从为什么分层、怎么分、怎么写,到怎么排查和避坑,尽量讲清楚每一个判断背后的原因。
1. 为什么需要把 HTML 组件拆成“层”而不是“文件夹”
1.1 不分层的项目,问题大多出在样式优先级
先看一个很典型的原生页面结构。常见的写法是每个人都维护一个全局 CSS,往后面追加样式,越加越多,最后出现“新样式不生效、只能加 !important 硬压”的现象。
问题不是开发者不认真,而是样式优先级没有规则。CSS 的优先级本身只认选择器权重和出现顺序,不认你的文件夹结构有多整齐。你拆了几十个组件目录,但所有样式最终编译到同一个层级时,谁在后面谁说了算。组件之间的隔离只停留在“心理层面”,浏览器根本不知道你的业务边界。
所以真正要做的,不是把文件拆开,而是把“层”这个概念显式地建起来,让浏览器按你定义的顺序去解析和覆盖样式。这就是分层组件系统的本质。
1.2 分层之后解决了什么实际问题
一个设计合理的分层系统,至少要能回答三个问题:
- 基础样式改一处,全站按钮、表单、标题是否都能同步生效?
- 新页面里要覆盖某个组件的局部样式,应该写在哪一层,而不是依赖文件加载顺序?
- 第三方组件或插件引入了自己的样式,能不能控制它的影响范围?
这三个问题如果靠“约定”来解决,人一多就会失效。如果靠 CSS 原生机制来解决,至少能在技术层面兜底。
实际收益也很直接:代码 review 的时候只看改的位置在哪个层,就能判断是否符合规范;接新需求时不用全局搜索样式名;重构组件时不用害怕误伤其他页面。这些收益在项目超过十个页面之后会越来越明显。
2. 分层之前,先确认环境和基础条件
2.1 现代浏览器优先,老旧项目要额外处理
我建议的分层方案以 CSS @layer 为核心。这是现代浏览器原生支持的 CSS 特性,能够在样式表内部显式声明层顺序,让组件覆盖规则变得清晰。Chrome、Edge、Firefox、Safari 的较新版本都已经支持,但如果你的项目还需要兼容年代较久的浏览器,就要先加上降级方案,或者改用命名空间加 CSS Modules 这类替代思路。
具体怎么判断?打开目标环境和浏览器,直接执行一段测试代码:
@layer base, components, utilities;然后看控制台有没有报错,以及后面的样式是否按预期覆盖。如果不支持,浏览器会直接忽略这段规则,这时候所有层顺序都会失效,样式会退回传统优先级逻辑。所以,不是所有项目都能直接采用这个方案,先花五分钟确认环境比什么都重要。
2.2 项目目录结构建议
分层不只是 CSS 文件层面的拆分,HTML 组件的目录也应该对应这个分层思想。我常用的结构是这样:
src/ styles/ base/ reset.css variables.css components/ button.css card.css utilities/ spacing.css visibility.css main.css components/ button.html card.html nav-bar.html pages/ index.html detail.html这里的关键是:components 目录里是组件的 HTML 片段,styles 目录里是组件的样式。组件目录可以自己看习惯调整,但层的顺序必须保持一致。也就是说,base 永远是第一层,components 第二层,utilities 第三层。
2.3 用一个入口文件统一加载顺序
分层最忌讳的就是每个页面手动引入七八个 CSS 文件,结果顺序写错,覆盖关系全乱。更稳妥的做法是只引入一个 main.css,在入口文件里用 @import 组织依赖顺序:
@layer base, components, utilities; @import url("./styles/base/reset.css") layer(base); @import url("./styles/base/variables.css") layer(base); @import url("./styles/components/button.css") layer(components); @import url("./styles/components/card.css") layer(components); @import url("./styles/utilities/spacing.css") layer(utilities);注意 @import 必须写在样式表最前面,而且要先声明 @layer 顺序,再引用具体文件。这样不管团队成员往里面加了什么文件,只要它在对应的 layer 里,浏览器就能按统一优先级处理。
注意:@import 有性能成本,连接数会变多。如果生产环境对首屏性能敏感,建议构建阶段把这些文件合并压缩,但在开发阶段这样组织是很直观的。
3. 把组件系统真正实现出来
3.1 第一步:定义基础层,也就是全局变量和 reset
基础层的职责是提供变量、reset 样式和全局 HTML 元素默认样式。这一层不写任何业务类名,只处理元素本身。
一个最小的变量文件示例:
:root { --color-primary: #2563eb; --color-text: #1f2937; --color-bg: #ffffff; --radius-md: 8px; --space-sm: 8px; --space-md: 16px; --font-base: 16px; }reset 文件用来去掉浏览器默认差异,比如 margin、padding、盒模型、列表样式。变量和 reset 放在 base 层,是为了让所有组件层都能稳定引用这些基础值,而不是每个组件自己写死一个颜色或者间距。
这里有一个容易被忽略的点:base 层里尽量不要写具体业务类名的样式。如果你在 base 层写了一个.btn,它就会被放在最低优先级,后面组件层想覆盖它会出现奇怪的行为。base 层只做“地基”,不做“零件”。
3.2 第二步:定义组件层,写真正可复用的块
组件层是业务组件的统一样式层。按钮、卡片、导航、表单控件都在这一层。组件层要遵守一套命名规范,我建议采用较严格的 BEM 或类似风格。
举个例子,一个按钮组件的 HTML:
<button class="btn btn--primary"> 提交 </button>对应样式:
.btn { display: inline-flex; align-items: center; padding: 10px 16px; border-radius: var(--radius-md); font-size: var(--font-base); border: 1px solid transparent; cursor: pointer; } .btn--primary { background-color: var(--color-primary); color: #fff; } .btn--primary:hover { background-color: #1d4ed8; }组件层的核心原则是:一个组件只负责自身的结构和默认形态,不负责页面级别的布局。比如按钮不能自带 margin,卡片不能自带页面宽度。因为这些属于页面组合逻辑,应该在页面层或用具层处理。
为什么这么定?因为组件的复用力来自“中性”——它在任何页面里长得都一样,但怎么摆是页面的事。如果一个按钮组件自带 margin-top: 20px,它放在表单里就要被覆盖,放在弹窗里又要被覆盖,这种组件就叫“带私货的组件”,很难规模复用。
3.3 第三步:定义工具层,处理间距和覆盖
工具层承载的是那些“一次性的、跟业务组件无关”的小样式。间距、隐藏、布局辅助类都可以放这里。
@layer utilities { .u-mt-sm { margin-top: var(--space-sm); } .u-mt-md { margin-top: var(--space-md); } .u-hidden { display: none !important; } .u-text-center { text-align: center; } }工具层放在组件层之后,所以工具类可以覆盖组件类。这很适合做页面级微调:组件本身不带 margin,页面里用.u-mt-md控制间距。这样组件保持干净,页面的具体排布也能灵活调整。
关于!important,工具层里少量使用是合理的,因为工具类的语义就是“强制覆盖”。但组件层和基础层不应该出现!important,一旦出现,就说明分层规则被打破了,需要回到源头找问题。
3.4 第四步:用页面层组织组件组合
实际页面写的时候,我习惯用一个轻量的“页面入口样式”作为辅助,但不在分层里单独开一层。页面入口只负责组合:
<div class="page-account"> <header class="page-account__header"> <h1 class="u-text-center">账户设置</h1> </header> <form class="page-account__form"> <button class="btn btn--primary">保存</button> </form> </div>页面入口类建议使用页面前缀,比如page-account,这样避免和组件类名撞车。页面的布局样式通过子元素类来控制,组件的内部样式仍然走组件层。
3.5 组件之间的 JavaScript 通信怎么分层
分层系统不只是 CSS 的事,JavaScript 也要有对应思路。一个 HTML 组件如果涉及交互,比如弹窗的打开关闭、导航的展开收起,我建议把 JavaScript 拆成两类:
- 组件逻辑:跟某个组件绑定的事件,写在组件对应的 JS 文件里。
- 页面编排:跨组件的联动,比如点击 A 组件后要通知 B 组件更新,写在页面脚本里。
组件 JS 的绑定方式,建议使用><div class="modal">document.querySelectorAll('[data-component="modal"]').forEach((modal) => { const closeBtn = modal.querySelector('[data-modal-close]'); closeBtn.addEventListener('click', () => { modal.classList.add('u-hidden'); }); });
这里用类名控制显隐,用>:root { --color-primary: #2563eb; --color-primary-contrast: #ffffff; } [data-theme="dark"] { --color-primary: #3b82f6; --color-primary-contrast: #000000; }
组件层只使用变量,不写死颜色。切换主题时只要在 html 或 body 上改>