Ui层
组件层(components): 页面展示,用户交互(按钮表单弹窗)
业务逻辑层
逻辑层(Composables/Stores/Servers):处理业务规则, 状态管理,API调用协调。
Composables - Vue3的组合式函数
- 作用:封装可复用的响应式逻辑。
- 场景:管理响应式状态/处理生命周期逻辑/跨组件共享状态/处理副作用(API 事件监听等)
- 通常不依赖全局状态(无法单元测试,避免在组合式函数依赖全局store数据)通过局部或传参驱动
Stores - 全局状态管理
- 作用:管理跨组件共享的状态(如用户登录信息,购物车,全局配置)
- 工具:Vue3的Pinia,Vue2的Vuex
- 通常支持Actions发异步请求,getters 计算器属性
Servers(服务层)- 业务协调与API封装
- 作用:封装业务规则+协调多个API调用,避免组件直接调用API
- 特点:通常为纯函数不依赖UI,易于单元测试,可包含复杂的业务逻辑。(无响应式数据)
为什么需要逻辑层?
| 无逻辑层的问题 | 逻辑层如何解决 |
|---|---|
| 组件代码臃肿(API+状态+逻辑混在一起) | 逻辑抽离,单一职责,组件只负责渲染 |
| 相同逻辑重复写(如多个页面都需要搜索用户) | Composable复用 |
| 状态在各个组件,传值繁琐 | Store集中管理,跨多层组件传值 |
| 业务规则写在组件里,无法测试 | Service是纯函数,可单元测试 |
| 修改一个业务组件要改多个文件 | 只需要改Composables或Service |
项目最佳实践
- 组件:调用Composable
- Compsable:调用Service+更新局部状态
- Store:管理全局状态,也可以调用Service
- Service:只处理数据和业务,不碰UI和响应式
分层后的效果
- Service只关心做什么(What)
- Composables/Store关心 怎么做(How)+状态怎么变(State)
- Component只关心怎么展示(View)
案例 - 用户搜索功能
1️⃣Service 层(无响应式)
// services/userService.ts
export async function searchUsers(keyword: string): Promise<User[]> {
if (!keyword.trim()) return []
const res = await api.get('/users', { params: { q: keyword } })
return res.data
}
✅ 只负责:调用 API + 返回数据,不碰 ref、不管理状态。
2 Composable 层(定义响应式变量)
// composables/useUserSearch.ts
import { ref, computed } from 'vue'
import { searchUsers } from '@/services/userService'
export function useUserSearch() {
// 👇 响应式变量定义在这里!
const keyword = ref('')
const loading = ref(false)
const results = ref<User[]>([])
const isEmpty = computed(() => results.value.length === 0)
const search = async () => {
loading.value = true
try {
// 调用 service,拿到普通数据
results.value = await searchUsers(keyword.value)
} finally {
loading.value = false
}
}
return {
keyword, // ref<string>
loading, // ref<boolean>
results, // ref<User[]>
isEmpty, // ComputedRef<boolean>
search // function
}
}
✅ 响应式状态(keyword/loading/results)定义在 Composable 中,因为它:
- 与 UI 交互强相关(输入框、加载动画、列表渲染)
- 可能被多个组件复用
- 生命周期通常与组件绑定(组件销毁,状态自动清理)
3️⃣ Store 层(如果是全局状态
// stores/userStore.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
// 👇 响应式状态定义在 state 中
state: () => ({
profile: null as User | null,
isLoggedIn: false
}),
actions: {
async fetchProfile() {
// 调用 service
this.profile = await getUserProfileService()
this.isLoggedIn = !!this.profile
}
}
})
✅ 全局共享的响应式状态放在 Store。
4️⃣ 组件层(尽量少定义)
<script setup>
import { useUserSearch } from '@/composables/useUserSearch'
// 从 composable 拿到响应式变量,直接在模板中使用
const { keyword, loading, results, search } = useUserSearch()
</script>
<template>
<input v-model="keyword" @input="search" />
<div v-if="loading">搜索中...</div>
<ul v-else>
<li v-for="user in results" :key="user.id">{{ user.name }}</li>
</ul>
</template>
✅ 组件不定义 keyword、loading 等状态,只消费 Composable 提供的响应式数据。
数据访问层
API层/数据请求层(Axios/fetch的封装):与后端接口通信,只负责怎么发请求,怎么响应。
上下层之间的关系
上层模块依赖下层模块,下层模块不反向依赖上层模块。
概念
“上层”和“下层”是相对于依赖方向和抽象层次而言的,用来描述不同模块或组件之间的关系。这种分层思想的核心目标是解耦、提高可维护性和可测试性。
1️⃣ 什么是“上层”和“下层”?
- 上层(Higher Layer) :更靠近用户、更具体、更面向业务场景的模块。
例如:UI 层是“最上层”,因为它直接与用户交互。 - 下层(Lower Layer) :更靠近基础设施、更通用、更偏向技术实现的模块。
例如:数据访问层是“最下层”,因为它负责与数据库等底层系统打交道。
2️ 依赖方向
- 上层可以依赖下层:
UI 层 → 调用 → 业务逻辑层 → 调用 → 数据访问层
这是允许的,也是推荐的。 - 下层不能依赖上层:
数据访问层 不能 调用 UI 层的代码,业务逻辑层 不能 直接引用 UI 控件。
否则就会造成循环依赖或架构混乱。
3️⃣ 举个例子
假设你做一个“用户注册”功能:
- UI 层:用户在网页上填写用户名和密码,点击“注册”按钮。
- 业务逻辑层:验证用户名是否合法、密码强度是否足够、是否已存在等规则。
- 数据访问层:把验证通过的用户信息存入数据库。
✅ 正确依赖:
- UI 层调用业务逻辑层的
registerUser()方法。 - 业务逻辑层调用数据访问层的
saveUser()方法。
❌ 错误依赖(违反分层):
- 数据访问层直接调用 UI 层的某个弹窗函数来提示“保存失败”——这会让底层代码耦合到界面,难以复用和测试。
4️⃣ 为什么这样设计?
- 避免循环依赖:如果下层依赖上层,而上层又依赖下层,就形成了“鸡生蛋、蛋生鸡”的死循环。
- 提高可测试性:你可以单独测试业务逻辑,而不需要启动整个 UI。
- 便于替换:比如未来把 Web UI 换成移动端 App,只要业务逻辑和数据层不变,改动就很小。
- 职责清晰:每层只关心自己的事,UI 不处理业务规则,数据库不决定界面怎么显示。
业务逻辑层注意事项
- 概念:业务逻辑层应该专注于处理业务规则和数据逻辑不应直接操作DOM或引用 UI 组件(如 ElMessage 除非通过抽象方式) 。需要符合关注点分离和单一职责原则
例子
假设正在开发一个用户注册功能:
❌ 错误做法(业务逻辑层直接操作 UI):
// userService.ts(业务逻辑层)
import { ElMessage } from 'element-plus';
export function registerUser(username: string, password: string) {
if (!username || !password) {
ElMessage.error('用户名或密码不能为空'); // ❌ 直接调用了 UI 组件!
return;
}
// 调用 API 注册...
api.register({ username, password }).then(() => {
ElMessage.success('注册成功!'); // ❌ 又直接操作 UI!
});
}
问题:
userService耦合了 UI 框架(Element Plus)。- 如果以后换 UI 框架(比如从 Element Plus 换成 Ant Design Vue),就得改业务代码。
- 单元测试困难(因为 ElMessage 是 UI 依赖,难以 mock)。
- 业务逻辑和 UI 行为混在一起,职责不清。
✅ 正确做法(通过抽象方式解耦)
方式一:通过回调或返回状态
// userService.ts(纯业务逻辑)
export function registerUser(username: string, password: string) {
if (!username || !password) {
return { success: false, message: '用户名或密码不能为空' };
}
try {
await api.register({ username, password });
return { success: true, message: '注册成功' };
} catch (error) {
return { success: false, message: '注册失败' };
}
}
在 UI 层(如 Vue 组件)中处理消息:
<!-- Register.vue -->
<script setup>
import { ElMessage } from 'element-plus';
import { registerUser } from '@/services/userService';
const handleRegister = async () => {
const result = await registerUser(username.value, password.value);
if (result.success) {
ElMessage.success(result.message);
} else {
ElMessage.error(result.message);
}
};
</script>
方式二:通过依赖注入或通知机制(更高级)
可以定义一个抽象的 NotificationService 接口:
// types.ts
interface INotificationService {
success(msg: string): void;
error(msg: string): void;
}
// userService.ts
export function registerUser(
username: string,
password: string,
notifier: INotificationService
) {
if (!username || !password) {
notifier.error('用户名或密码不能为空');
return;
}
// ...
}
然后在 UI 层传入具体实现:
// 在 Vue 组件中
const notifier = {
success: (msg) => ElMessage.success(msg),
error: (msg) => ElMessage.error(msg)
};
registerUser(username, password, notifier);
总结
- 业务逻辑应该独立于 UI。
- 如果需要通知用户(如弹出消息),应通过返回状态、抛出异常、回调函数或依赖抽象接口等方式,让 UI 层决定如何展示。
- 这样做能提高代码的可维护性、可测试性、可复用性。