如果你做过 React 或 Vue 的代码评审,大概率见过这种争论:
"为什么要把 HTML、CSS、JS 写在一个文件里?这不是违反了关注点分离吗?"
这个问题困扰了很多前端开发者。今天来把它彻底讲清楚。
结论先行:组件化不是在破坏分层,而是把分层的边界从"技术文件"移到了"功能单元"。
一、经典分层:看起来很整齐
Web 开发有一个延续了十几年的传统——按技术类型分文件:
项目结构(经典模型)
├── styles/ ← 所有 CSS
├── scripts/ ← 所有 JavaScript
└── templates/ ← 所有 HTML
HTML 管结构,CSS 管样式,JS 管行为。三层各司其职,教科书般的 Separation of Concerns。
这个模型在"以文档为中心"的年代确实好用——内容独立于表现,设计师写 CSS,JS 专家写脚本,大家互不干扰。
但当 Web 从"文档"变成"应用",问题就来了。
二、一个下拉菜单,改三个文件
想象你要改一个下拉菜单的交互:
| 关注点 | 文件位置 | 内容 |
|---|---|---|
| 结构 | templates/dropdown.html | DOM 结构 |
| 样式 | styles/dropdown.css | 外观 |
| 行为 | scripts/dropdown.js | 开关逻辑 |
一个功能,散布在三个文件、三个目录、三种上下文中。要理解下拉菜单怎么工作,你需要同时在脑中持有三者;要修改它,需要触碰三处。
Martin Fowler 在《重构》里给这种代码坏味道起了个名字——霰弹式修改(Shotgun Surgery) :改一个功能,像打散弹枪一样,弹片散落到代码库各处。
这就好比你住在一座"严格功能分区"的城市:买菜要去商业区,上班要去工业区,回家要去住宅区。看起来规划整齐,但人在三个区之间来回跑,通勤成本极高。
我们优化的是技术边界,但真正的复杂性在功能上。
三、把边界搬到功能上
React、Vue、Svelte 做了一件关键的事——共置(Co-location) 。一起变化的东西,放在一起:
项目结构(现代模型)
├── components/
│ ├── Dropdown/ ← 下拉菜单的一切
│ │ ├── Dropdown.tsx
│ │ ├── Dropdown.css
│ │ └── Dropdown.test.ts
│ ├── Modal/ ← 模态框的一切
│ └── UserProfile/ ← 用户资料的一切
设计师要改下拉菜单外观?打开 Dropdown 目录。PM 要求加键盘导航?还是 Dropdown。Bug 报告提到下拉菜单?依然是 Dropdown。
一个功能,一个位置。
这正是 Kent C. Dodds 总结的共置原则: "Things that change together should be located as close as reasonable." 一起变化的东西,应该尽可能地住在一起。
就像现代城市规划转向"混合用途社区"——一栋楼里同时有住宅、商铺、办公室。你不用横跨半座城去买瓶水,下楼就行。
按技术分离 vs 按功能分离
四、组件内部,依然要分层
但共置不是说"把所有东西搅成一团"。组件的外部边界是功能,内部依然需要分层。
Den Odell 在 Frontend Patterns 里定义了五对真正的逻辑边界:
| 边界 | 分什么 | 为什么分 |
|---|---|---|
| 业务逻辑 vs 展示逻辑 | Smart 组件管数据,Dumb 组件管渲染 | 展示层可复用、可独立测试 |
| 纯代码 vs 副作用 | 计算/转换 vs API 调用/定时器/埋点 | 副作用被隔离,代码更可预测 |
| 服务端状态 vs 客户端状态 | React Query 管缓存 vs useState 管 UI | 两者生命周期不同,混在一起会乱 |
| 全局状态 vs 局部状态 | 用户信息/路由 vs 模态框开关/hover | 全局状态需要集中管理,局部的不必 |
| 基础设施 vs 领域 | 虚拟滚动/分页 vs 产品列表展示 | 基础设施跨领域复用,领域逻辑专注业务 |
用生物学来打个比方:一个细胞就是一个组件。它自带 DNA(业务逻辑)、核糖体(展示层)、线粒体(副作用引擎)。你不需要跑到别的细胞去拿零件——但细胞内部的这些结构,是清晰分层的。
组件内部的分层结构
五、怎么判断你分对了?
原文给了四个检验问题,非常实用:
| # | 问题 | 没分好的信号 |
|---|---|---|
| 1 | 改一个功能,是否只碰一个位置? | 改一处要跟着改三五个文件 |
| 2 | 能一句话说清每个模块做什么? | 描述一个模块需要连续用"并且" |
| 3 | 能独立测试每个部分? | 测一件事要 mock 一堆不相关的东西 |
| 4 | 不同人能并行工作而不冲突? | 所有人的改动总是互相踩 |
这四个问题的本质是在测量两个指标:内聚性(一个模块内部的东西有多相关)和耦合度(模块之间有多少牵连)。
好的分离 = 高内聚 + 低耦合。 这不是前端的发明,这是软件工程 50 年来最重要的原则之一。
六、一个很像的管理学问题
如果你在公司待过,一定见过两种组织架构:
| 维度 | 按职能分部 | 按产品线分部 |
|---|---|---|
| 前端 | 前端部、后端部、设计部 | 搜索团队、支付团队、会员团队 |
| 优势 | 技术栈统一、专业度高 | 响应快、交付独立、全功能闭环 |
| 痛点 | 跨部门协调成本高 | 可能重复造轮子 |
| 类比 | 经典 Web 按技术分文件 | 现代前端按功能分组件 |
亚马逊的 Two-Pizza Team 就是"按产品线分部"的典型——每个小团队拥有一个完整的功能模块,从前端到后端到数据库,一个披萨盒大小的团队搞定一切。
前端架构的演进,和组织架构的演进,走的是同一条路:从"按专业分"到"按交付单元分"。
七、对你写代码意味着什么?
给你一个实操判断框架:
改三个文件才能完成一个功能 → 你在错误的轴上做了分离
一个组件超过 300 行 → 它可能积累了多个关注点,该拆
测试写不下去 → 边界没划好,副作用和纯逻辑混在一起
说不清一个模块干什么 → 它承担了太多职责
记住 Dijkstra 的那句话:关注点分离是软件设计中最重要的原则。但原则是永恒的,应用是随场景变化的。
经典 Web 按技术分,因为那时 Web 是文档。现代前端按功能分,因为 Web 变成了应用。两者都是同一个底层原则的正确应用。
如果你只想带走一句话,我建议记这个:
分离的目的不是整齐,是让变更更容易。
参考原文:
• Den Odell — Logical Separation of Concerns