2、分层核心原则:单一职责原则、依赖倒置原则、接口隔离原则在前端分层中的具体应用
好,咱们接着聊。上一章我讲了分层到底分什么,这一章咱们深入一点,聊聊支撑分层的三个核心原则。说实话,这三个原则在教科书里讲得挺玄乎的,什么「高内聚低耦合」之类的。但在我实际做项目的时候,它们其实就是三把尺子——用来量你的代码到底该不该拆、该怎么拆。
我个人习惯把这三个原则记成一句话:「各管各的,别越级,别包办」。下面我一个一个拆开讲。
2.1 单一职责原则:一个模块只做一件事
这个原则听起来最简单,做起来最难。什么叫「一件事」?我见过很多同学把「获取用户数据」和「渲染用户头像」写在一个函数里,然后跟我说:「这不就是一件事吗?获取数据然后展示啊。」
嗯,这里要注意。在分层架构里,「一件事」指的是一个变化原因。如果数据获取方式变了(比如从 REST 改成 GraphQL),和头像渲染样式变了(比如从圆形改成方形),这是两个完全不同的变化原因。它们不应该被绑在一起。
核心判断标准:如果一个模块需要因为两个不同的原因去修改它,那它就违反了单一职责。
我在项目中遇到过这样一个例子:一个同事把 API 调用、数据格式化、DOM 操作全部塞进了一个 Vue 组件的 mounted 钩子里。结果后来后端改了接口返回格式,他改了一整天,因为格式化逻辑和渲染逻辑全混在一起,牵一发动全身。
正确的做法是什么?看下面这个对比:
// ❌ 违反单一职责:一个函数干了三件事
function loadUserProfile(userId) {
const rawData = fetch(`/api/users/${userId}`); // 数据获取
const formatted = { name: rawData.name, age: rawData.age }; // 数据格式化
document.getElementById('profile').innerHTML = `<h2>${formatted.name}</h2>`; // DOM 渲染
}
// ✅ 符合单一职责:各层各司其职
// 数据层:只负责获取
function fetchUser(userId) {
return fetch(`/api/users/${userId}`);
}
// 业务层:只负责转换
function formatUser(raw) {
return { name: raw.name, age: raw.age };
}
// 视图层:只负责展示
function renderUser(user) {
return `<h2>${user.name}</h2>`;
}
你想想看,这样拆完之后,如果后端改了字段名,我只需要改 formatUser 这一个函数。如果 UI 设计师说头像要放左边,我只需要改 renderUser。互不干扰,多清爽。
我的小技巧:写代码之前先问自己三个问题——这个函数/组件/模块,如果需求变了,它需要改吗?如果另一个需求变了,它还需要改吗?如果两个答案都是「是」,那就拆。
2.2 依赖倒置原则:高层不依赖低层,大家都依赖抽象
这个原则的名字有点绕,说白了就是一句话:别让业务逻辑直接依赖具体的技术实现。
举个例子。你写了一个订单结算模块,里面直接用了 axios 发请求。看起来没问题对吧?但你想过没有,如果有一天你们公司决定把 axios 换成 fetch,或者换成 GraphQL 客户端,你的结算逻辑要改多少地方?
我曾经在一个老项目里接手过这种代码:业务逻辑里到处都是 $.ajax(),后来要升级成 axios,整个业务层几乎重写了一遍。那叫一个酸爽。
正确的做法是:让业务层依赖一个「接口」(在 JavaScript 里就是抽象类或者约定好的方法签名),而不是依赖具体的实现。
// ❌ 违反依赖倒置:业务层直接依赖 axios
class OrderService {
submit(order) {
return axios.post('/api/orders', order);
}
}
// ✅ 符合依赖倒置:业务层依赖抽象接口
// 先定义接口(抽象)
class IOrderRepository {
save(order) { throw new Error('未实现'); }
}
// 具体实现
class AxiosOrderRepository extends IOrderRepository {
save(order) {
return axios.post('/api/orders', order);
}
}
// 业务层只依赖接口
class OrderService {
constructor(repository) {
this.repository = repository; // 注入依赖
}
submit(order) {
// 业务逻辑在这里,不关心具体怎么存的
return this.repository.save(order);
}
}
说白了,依赖倒置就是让业务层「高高在上」,不接地气。它只关心「我要存一个订单」,至于你是存到数据库、存到 localStorage、还是存到云端,那是实现层的事,业务层不管。
注意:在 JavaScript/TypeScript 里,接口往往是隐式的。你不需要像 Java 那样写 interface 关键字,但你要在脑子里明确这个边界。我习惯用 TypeScript 的 interface 来显式声明,这样团队里其他人一看就知道「哦,这里是抽象层」。
2.3 接口隔离原则:不要强迫调用方依赖它不需要的东西
这个原则在分层架构里特别实用。什么意思呢?就是说你设计一个模块的对外接口时,别搞一个大而全的对象传进去,只给调用方它真正需要的东西。
我见过最典型的反面教材是这样的:一个组件接收一个完整的「用户对象」作为 props,里面包含 name、age、email、address、paymentInfo、creditCard 等等。但组件实际上只用到了 name 和 avatar。这就出问题了——
- 组件对不需要的数据产生了隐式依赖
- 测试的时候你得 mock 一整个大对象
- 如果 paymentInfo 的结构变了,这个组件也得跟着变(虽然它根本没用 paymentInfo)
为什么会这样?因为接口设计得太「胖」了。
// ❌ 违反接口隔离:传入了太多不需要的东西
function UserCard(user) {
// 实际上只用到了 name 和 avatar
return `<div>${user.name}<img src="${user.avatar}" /></div>`;
}
// ✅ 符合接口隔离:只传入需要的字段
function UserCard({ name, avatar }) {
return `<div>${name}<img src="${avatar}" /></div>`;
}
// 或者用 TypeScript 显式声明
interface UserCardProps {
name: string;
avatar: string;
}
在分层架构里,接口隔离原则还有一个更重要的应用场景:层与层之间的通信。比如你的视图层调用业务层的方法,不要传整个 DOM 事件对象进去,只传你需要的数据。
我的经验:接口隔离原则做得好不好,看单元测试的复杂度就知道了。如果写测试时需要 mock 一大堆无关数据,那八成是接口设计得太胖了。
2.4 三个原则如何协同工作
这三个原则不是孤立的,它们是一个整体。我画个简单的对应关系给你看:
| 原则 | 解决的问题 | 在分层中的体现 |
|---|---|---|
| 单一职责 | 一个模块该不该拆 | 每层只做自己该做的事,视图层不写业务逻辑 |
| 依赖倒置 | 层与层之间怎么依赖 | 业务层依赖抽象接口,不依赖具体实现 |
| 接口隔离 | 对外暴露多少信息 | 层间通信只传递必要数据,不传大对象 |
你想想看,如果这三个原则都做到了,你的项目会是什么样子?
- 每一层的职责清晰,改视图不会影响业务逻辑
- 替换技术栈(比如从 Vue 换到 React)只需要改视图层
- 写单元测试特别轻松,因为每个模块的依赖都很明确
避坑指南:我曾经在一个项目里过度追求「原则」,把每个小函数都拆成了单独的文件,结果文件数量爆炸,找代码比写代码还累。记住,原则是工具,不是教条。如果一个原则让你的代码变得更难维护,那说明你用错了地方。
好,这一章就到这里。下一章我会讲具体的分层策略——到底分几层?每层放什么?怎么落地?到时候我会拿一个真实项目的目录结构给你看,保证你一看就懂。