Pinia Store :前端的 MVVM 解耦

Pinia Store :前端的 MVVM 解耦

在 Vue 开发的早期阶段,或者在编写简单的 .vue 文件时,我们习惯把 数据(State)业务逻辑(Methods)HTML 模板(View) 写在一起。这种“全家桶”式的写法虽然上手快,但随着业务复杂度增加,痛点也随之而来:UI 和业务逻辑紧紧捆绑在一个文件中。如果另一个页面也需要这份数据,或者想对这段复杂的逻辑进行单元测试,会发现寸步难行。

现状:耦合的代码 (The Problem)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- Component.vue -->
<script setup>
import { ref } from 'vue'

// 数据和逻辑都被锁死在 UI 文件内部
const count = ref(0)
const doubleCount = computed(() => count.value * 2)

function add() {
// 假设这里还有复杂的 API 调用或权限校验
count.value++
}
</script>

<template>
<button @click="add">{{ count }} (Double: {{ doubleCount }})</button>
</template>

痛点:这个 .vue 文件承担了太多的责任。它既要负责“长什么样”,又要负责“怎么运作”。

解决方案:引入 Pinia (The Solution)

是否有办法把数据的定义、计算和更新逻辑从 .vue 文件中彻底挪出来呢?

Vue 官方推荐的状态管理库 Pinia 正是为此而生。通过 Pinia,我们可以实现关注点分离:

  1. Store (Model/ViewModel):负责定义数据结构(state)、计算属性(getters)和业务动作(actions)。它完全不关心数据是如何展示的(是列表?是图表?还是纯文本?)。
  2. Component (.vue):回归纯粹的 View。它只负责渲染数据和触发用户事件。

类比 WPF/MVVM

此时 .vue 只是数据的“订阅者”和“命令发送者”。如果熟悉 C# WPF 开发,这就是 MVVM 模式在前端的完美复刻:

  • Store = ViewModel
    • 持有数据属性:IsLoading, ChartData
    • 持有命令/逻辑:FetchCommand, CalculatedPrice
  • Vue Component = XAML (View)
    • 通过 Binding 绑定数据
    • 通过 Event/Command 绑定行为

如何组织与定义

项目结构组织

在工程化项目中,我们通常会在 src 目录下建立独立的 stores 文件夹。建议遵循 Modular 的原则,按照业务领域划分 Store。

1
2
3
4
5
6
7
8
9
frontend/
├── src/
│ ├── components/ # UI 组件 (View)
│ │ └── UserProfile.vue
│ ├── stores/ # 状态管理 (ViewModel)
│ │ ├── index.ts # (可选) 统一导出
│ │ ├── counter.ts # 计数器相关逻辑
│ │ └── user.ts # 用户信息相关逻辑
│ └── App.vue

定义 Store (Setup Syntax)

src/stores/counter.ts 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 命名规范:use + Id + Store
export const useCounterStore = defineStore('counter', () => {
// 1. State (对应 ViewModel 的数据源)
const count = ref(0)

// 2. Getters (对应 ViewModel 的计算属性)
// 自动收集依赖,且具有缓存特性
const doubleCount = computed(() => count.value * 2)

// 3. Actions (对应 ViewModel 的命令/方法)
// 可以包含同步逻辑,也可以包含异步 API 请求
function increment() {
count.value++
}

async function asyncIncrement() {
// 模拟异步请求
await new Promise(r => setTimeout(r, 500))
count.value++
}

// 必须 return 出去,外部组件才能使用
return {
count,
doubleCount,
increment,
asyncIncrement
}
})

这一层是纯逻辑,不知 UI 为何物。 它可以被任何组件复用,甚至可以在 Node.js 环境下单独测试。

在组件中使用 (The View)

现在,.vue 文件变得异常清爽。组件只管“调用”,不管“如何实现”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- Component.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'

// 1. 实例化
const store = useCounterStore()
</script>

<template>
<!-- 2. 直接通过 store 实例访问,响应式完全正常 -->
<div>
<h1>{{ store.count }}</h1>
<button @click="store.increment">Add</button>
</div>
</template>