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)只需要改视图层
  • 写单元测试特别轻松,因为每个模块的依赖都很明确

避坑指南:我曾经在一个项目里过度追求「原则」,把每个小函数都拆成了单独的文件,结果文件数量爆炸,找代码比写代码还累。记住,原则是工具,不是教条。如果一个原则让你的代码变得更难维护,那说明你用错了地方。

好,这一章就到这里。下一章我会讲具体的分层策略——到底分几层?每层放什么?怎么落地?到时候我会拿一个真实项目的目录结构给你看,保证你一看就懂。