- Update yarn.lock - Add project implementation docs in docs/ - Add personal internship experience notes in 实习讲解/
346 lines
13 KiB
Markdown
346 lines
13 KiB
Markdown
# Pinia 状态管理 — 面试版
|
||
|
||
> 简历原话:**"使用 Pinia 进行状态管理"**
|
||
>
|
||
> 这篇文档帮你理解项目中 Pinia 到底管了哪些状态、怎么管的,以及面试时怎么讲。
|
||
|
||
---
|
||
|
||
## 一、先搞清楚:Pinia 在项目里干什么?
|
||
|
||
### 什么是状态管理?
|
||
|
||
```
|
||
没有状态管理的问题:
|
||
用户登录信息 → 在 A 组件里获取
|
||
用户登录信息 → B 组件也要用 → 怎么传?父子组件可以 props,兄弟组件呢?
|
||
用户登录信息 → C 组件也要用 → 再 props 一层?代码变成意大利面...
|
||
|
||
有了 Pinia:
|
||
用户登录信息 → 存在 Pinia store 里
|
||
A、B、C 组件 → 各自直接从 store 读取,不需要互相传
|
||
```
|
||
|
||
**一句话概括**:Pinia 就是"全局数据仓库",存放多个组件都需要共享的数据(登录状态、当前仓库信息、当前组织信息等),避免组件之间层层传递。
|
||
|
||
### 项目中 Pinia 的定位
|
||
|
||
```
|
||
数据从哪来 → API 请求 / localStorage
|
||
存到哪 → Pinia store(全局仓库)
|
||
谁来用 → 32~62 个组件直接从 store 读取
|
||
```
|
||
|
||
---
|
||
|
||
## 二、项目里有几个 Store?各自管什么?
|
||
|
||
项目有 **10 个 Store**,其中 **6 个是真正活跃使用的**:
|
||
|
||
### 核心 Store(用得最多)
|
||
|
||
| Store 名 | 管什么 | 被多少文件使用 |
|
||
|----------|--------|---------------|
|
||
| `useAccountStore` | 当前登录用户的信息(ID、头像、昵称、Token) | **62 个文件** |
|
||
| `orgInfoStore` | 当前组织的信息(名称、头像、权限、成员) | **37 个文件** |
|
||
| `repoInfoStore` | 当前仓库的信息(名称、Star数、权限、是否私有) | **35 个文件** |
|
||
| `useGlobalInfoStore` | 全局 UI 状态(菜单、主题、搜索、语言列表) | **32 个文件** |
|
||
|
||
### 辅助 Store(用得较少)
|
||
|
||
| Store 名 | 管什么 | 被多少文件使用 |
|
||
|----------|--------|---------------|
|
||
| `otherAccountStore` | 当前正在查看的其他用户的信息 | 10 个文件 |
|
||
| `useMrChangeStore` | 代码合并(MR)的 diff 显示设置 | 6 个文件 |
|
||
| `entranceData` | 首页的仓库/组织列表 | 2 个文件 |
|
||
|
||
### 每个 Store 管理的状态一目了然
|
||
|
||
```
|
||
useAccountStore(用户信息)
|
||
├── isLogin: 是否已登录
|
||
└── accountInfo: { id, nickname, avatar, email, token... }
|
||
|
||
orgInfoStore(组织信息)
|
||
├── orgInfo: { name, avatar, description, visibility... }
|
||
├── isFollow: 是否关注了这个组织
|
||
├── memList / memCount: 成员列表和数量
|
||
└── computed → isAdmin / isDeveloper / isVisitor(权限判断)
|
||
|
||
repoInfoStore(仓库信息)
|
||
├── repoInfo: { name, star_count, visibility, archived... }
|
||
├── access_level: 权限等级数字
|
||
└── computed → isPrivate / isArchived / isAdmin / isDeveloper(权限判断)
|
||
|
||
useGlobalInfoStore(全局 UI)
|
||
├── globalMenuInfo: 菜单配置
|
||
├── menuType: 当前菜单类型(repo/org)
|
||
├── globalTheme: 主题(light/dark)
|
||
├── headerSearch: 搜索状态
|
||
└── languageList: 编程语言列表
|
||
```
|
||
|
||
---
|
||
|
||
## 三、怎么实现的?(面试核心)
|
||
|
||
### 3.1 Store 怎么定义的?
|
||
|
||
项目用的是 Pinia 的 **Setup 语法**(函数式),和 Vue3 Composition API 风格一致:
|
||
|
||
```typescript
|
||
// stores/user.ts — 最核心的用户 Store
|
||
import { defineStore } from 'pinia';
|
||
import { reactive, ref } from 'vue';
|
||
|
||
export const useAccountStore = defineStore('accountInfo', () => {
|
||
// ========== 状态(state)==========
|
||
const isLogin = ref(Boolean(localStorage.getItem('op_access_token')));
|
||
const accountInfo = reactive({
|
||
nickname: '',
|
||
avatar: '',
|
||
email: '',
|
||
op_access_token: '',
|
||
// ...
|
||
});
|
||
|
||
// ========== 操作(actions)==========
|
||
const saveAccountInfo = (info) => {
|
||
if (info) {
|
||
Object.assign(accountInfo, info); // 合并用户信息
|
||
} else {
|
||
Object.keys(accountInfo).forEach(k => accountInfo[k] = ''); // 清空(退出登录)
|
||
}
|
||
};
|
||
|
||
const checkIsLogin = async (token, refreshToken) => {
|
||
localStorage.setItem('op_access_token', token);
|
||
const userRes = await getUserInfo(); // 调 API 验证
|
||
if (userRes.data?.code === 200) {
|
||
saveAccountInfo(userRes.data.data); // 存到 store
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// ========== 返回(暴露给组件用)==========
|
||
return { isLogin, accountInfo, saveAccountInfo, checkIsLogin };
|
||
});
|
||
```
|
||
|
||
**为什么用 Setup 语法而不是 Options 语法?**
|
||
- 和 Vue3 Composition API 风格统一,团队学习成本低
|
||
- 可以直接用 `ref`、`computed`、`async/await`,更灵活
|
||
- TypeScript 类型推导更好
|
||
|
||
### 3.2 Store 怎么注册的?
|
||
|
||
```typescript
|
||
// main.ts — 一行代码搞定
|
||
import { createPinia } from 'pinia';
|
||
app.use(createPinia());
|
||
```
|
||
|
||
就这么简单。不需要像 Vuex 那样手动注册每个 module。
|
||
|
||
### 3.3 组件怎么用 Store?
|
||
|
||
```vue
|
||
<script setup>
|
||
import { useAccountStore } from '@/stores/user';
|
||
import { repoInfoStore } from '@/stores/Repo';
|
||
|
||
// 获取 store 实例
|
||
const accountStore = useAccountStore();
|
||
const repoStore = repoInfoStore();
|
||
|
||
// 直接读状态
|
||
console.log(accountStore.isLogin);
|
||
console.log(repoStore.repoInfo.name);
|
||
|
||
// 直接调 action
|
||
accountStore.saveAccountInfo({ nickname: '新名字' });
|
||
await repoStore.getRepoInfo();
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 模板中直接用 -->
|
||
<img :src="accountStore.accountInfo.avatar" />
|
||
<span v-if="repoStore.isAdmin">管理</span>
|
||
</template>
|
||
```
|
||
|
||
**关键点**:不需要 `$store`,不需要 `mapState`、`mapActions`,直接用就行。
|
||
|
||
### 3.4 权限判断怎么用 computed 封装?
|
||
|
||
这是项目中一个很巧妙的设计——把权限判断逻辑封装在 Store 的 computed 里:
|
||
|
||
```typescript
|
||
// stores/Repo/index.ts
|
||
const access_level = ref(0);
|
||
|
||
// 8 个 computed 属性,自动根据 access_level 判断权限
|
||
const isAdmin = computed(() => access_level.value >= admin);
|
||
const isDeveloper = computed(() => access_level.value >= developer);
|
||
const isVisitor = computed(() => access_level.value >= visitor);
|
||
const isAdminOperate = computed(() => access_level.value >= admin && !repoInfo.value?.archived);
|
||
const isArchived = computed(() => repoInfo.value?.archived);
|
||
const isPrivate = computed(() => repoInfo.value?.visibility === 'private');
|
||
```
|
||
|
||
组件中这样用:
|
||
|
||
```vue
|
||
<template>
|
||
<!-- 权限按钮:只有管理员才能看到 -->
|
||
<d-button v-if="repoStore.isAdmin">删除仓库</d-button>
|
||
<!-- 归档提示:自动根据状态显示 -->
|
||
<span v-if="repoStore.isArchived">此仓库已归档</span>
|
||
<!-- 私有标识 -->
|
||
<d-tag v-if="repoStore.isPrivate">私有</d-tag>
|
||
</template>
|
||
```
|
||
|
||
**好处**:权限判断逻辑集中在 Store 里,35 个组件共享同一套规则。改规则只改一处。
|
||
|
||
### 3.5 持久化怎么做的?
|
||
|
||
项目**没有**用 `pinia-plugin-persistedstate` 插件,而是手动操作 localStorage:
|
||
|
||
```typescript
|
||
// 登录时:Token 存 localStorage,同时存 Store
|
||
localStorage.setItem('op_access_token', token);
|
||
saveAccountInfo({ op_access_token: token });
|
||
|
||
// 页面刷新时:从 localStorage 恢复到 Store
|
||
const isLogin = ref(Boolean(localStorage.getItem('op_access_token')));
|
||
const accountInfo = reactive({
|
||
op_access_token: localStorage.getItem('op_access_token') || '',
|
||
// ...
|
||
});
|
||
|
||
// 退出时:清 localStorage,清 Store
|
||
localStorage.removeItem('op_access_token');
|
||
saveAccountInfo({}); // 传空 = 清空所有字段
|
||
```
|
||
|
||
**为什么手动而不用插件?** 因为只有用户 Token 需要持久化,其他状态(仓库信息、组织信息)每次进页面都重新从 API 获取。用插件反而多余。
|
||
|
||
---
|
||
|
||
## 四、除了 Pinia,还有哪些状态管理方式?
|
||
|
||
项目不是"只用 Pinia",而是**三种方式配合使用**:
|
||
|
||
| 方式 | 用在哪 | 为什么 |
|
||
|------|--------|--------|
|
||
| **Pinia** | 全局共享状态(用户、仓库、组织、菜单) | 多个页面都需要,跨组件共享 |
|
||
| **mitt 事件总线** | 跨组件事件通知(登录/登出、错误、微前端通信) | 一次性事件触发,不需要持久化状态 |
|
||
| **provide/inject** | 父子组件深层传递(移动端的仓库ID、权限标记) | 只在某个组件子树内共享,不需要全局 |
|
||
|
||
```typescript
|
||
// 事件总线示例 — 登录成功后通知全局
|
||
import { emitEvent } from '@/utils/eventBus';
|
||
emitEvent('login', userData); // 触发
|
||
addEventListener('login', handleLogin); // 监听
|
||
|
||
// provide/inject 示例 — 移动端深层组件获取仓库ID
|
||
const repoId = inject('repoId'); // 孙子组件直接拿,不需要层层 props
|
||
```
|
||
|
||
**面试话术**:
|
||
> "项目中状态管理不是只有 Pinia,而是三种方式配合:Pinia 管全局持久状态(用户/仓库/组织),mitt 事件总线管跨组件事件通知(登录/登出/错误),provide/inject 管组件子树内的深层数据传递。根据数据的使用范围和生命周期选择最合适的方式。"
|
||
|
||
---
|
||
|
||
## 五、Store 之间的关系
|
||
|
||
项目中所有 Store **互相独立**,没有任何 Store 引用其他 Store:
|
||
|
||
```
|
||
useAccountStore ──┐
|
||
orgInfoStore ─────┤ 互不依赖,各自独立
|
||
repoInfoStore ────┤
|
||
useGlobalInfoStore┘
|
||
```
|
||
|
||
**为什么这样设计?** 因为每个 Store 对应一个业务领域(用户/仓库/组织/全局),它们的数据来源不同(不同的 API),使用场景也不同,没有必要互相耦合。
|
||
|
||
---
|
||
|
||
## 六、整体架构总结
|
||
|
||
```
|
||
Pinia 在项目中的位置:
|
||
|
||
API 请求 ──→ Pinia Store ──→ Vue 组件(32~62 个文件消费)
|
||
│
|
||
├── useAccountStore(用户,62 个文件用)
|
||
├── orgInfoStore(组织,37 个文件用)
|
||
├── repoInfoStore(仓库,35 个文件用)
|
||
├── useGlobalInfoStore(全局UI,32 个文件用)
|
||
└── + 6 个辅助 Store
|
||
|
||
配合使用:
|
||
├── mitt 事件总线(30+ 个文件用)→ 事件通知
|
||
└── provide/inject → 组件子树数据传递
|
||
```
|
||
|
||
---
|
||
|
||
## 七、面试问答准备
|
||
|
||
### Q1:"使用 Pinia 进行状态管理"具体做了什么?
|
||
|
||
> 项目用 Pinia 管理了 10 个全局状态 Store,其中核心的 4 个分别管理用户登录信息(62 个文件使用)、仓库信息(35 个文件)、组织信息(37 个文件)、全局 UI 状态(32 个文件)。比如用户登录后,Token 和用户信息存在 Pinia 里,导航栏、个人主页、仓库页面等几十个组件都能直接读取,不需要层层传递 props。
|
||
|
||
### Q2:为什么选 Pinia 而不是 Vuex?
|
||
|
||
> 三个原因:第一,Pinia 是 Vue3 官方推荐的状态管理方案,Vue3 + Composition API 的项目用 Pinia 最自然;第二,Pinia 的 API 更简洁,不需要 mutations,actions 里可以直接 async/await;第三,Pinia 的 TypeScript 支持更好,类型推导自动完成,不需要额外写类型声明。
|
||
|
||
### Q3:Pinia 有两种语法,你用的哪种?
|
||
|
||
> 用的 Setup 语法(函数式),就是 `defineStore('id', () => { ... })` 这种。因为项目用 Vue3 Composition API 开发,Setup 语法和 Composition API 风格统一,可以直接用 ref、computed、async/await。整个项目 10 个 Store 有 9 个用 Setup 语法,只有 1 个占位 Store 用了 Options 语法。
|
||
|
||
### Q4:状态持久化怎么做的?
|
||
|
||
> 没有用 pinia-plugin-persistedstate 插件,因为只有用户 Token 需要持久化,其他状态(仓库、组织信息)每次进页面都重新从 API 获取。所以手动在 localStorage 和 Store 之间同步:登录时 Token 存 localStorage 同时存 Store,页面刷新时从 localStorage 恢复到 Store,退出时两边都清空。
|
||
|
||
### Q5:Store 里的 computed 有什么用?
|
||
|
||
> 主要用来封装权限判断逻辑。比如 repoInfoStore 里有 8 个 computed 属性:isAdmin、isDeveloper、isVisitor 等,根据 access_level 数字自动判断当前用户的角色。35 个组件用到仓库权限的地方,都直接读这些 computed,而不是每个组件自己写判断逻辑。这样权限规则改一处就全局生效。
|
||
|
||
### Q6:除了 Pinia 还用了什么状态管理方式?
|
||
|
||
> 还用了 mitt 事件总线和 provide/inject。三种方式各有分工:Pinia 管需要持久化的全局状态,mitt 管一次性事件通知(比如登录/登出事件),provide/inject 管组件子树内的深层数据传递。不是所有共享数据都适合放 Pinia,事件通知用 mitt 更轻量,局部数据用 provide/inject 更合理。
|
||
|
||
---
|
||
|
||
## 八、关键数字(面试时用)
|
||
|
||
| 数据 | 数字 |
|
||
|------|------|
|
||
| Pinia 版本 | 2.1.3 |
|
||
| Store 总数 | 10 个 |
|
||
| 活跃使用的 Store | 6 个 |
|
||
| 核心 Store | 4 个(用户/仓库/组织/全局UI) |
|
||
| 使用最多的 Store | useAccountStore(62 个文件) |
|
||
| 语法风格 | Setup 语法(9/10) |
|
||
| 持久化方式 | 手动 localStorage(无插件) |
|
||
| 配合的其他方案 | mitt 事件总线(30+ 文件)、provide/inject |
|
||
|
||
---
|
||
|
||
## 九、涉及的源码文件(需要看的时候查)
|
||
|
||
| 做什么 | 文件在哪 |
|
||
|--------|----------|
|
||
| Pinia 注册 | `src/main.ts`(app.use(createPinia())) |
|
||
| 用户 Store(最核心) | `src/stores/user.ts`(3 个 Store 共存) |
|
||
| 仓库 Store | `src/stores/Repo/index.ts` |
|
||
| 组织 Store | `src/stores/Org/index.ts` |
|
||
| 全局 UI Store | `src/stores/Global/index.ts` |
|
||
| MR diff 设置 Store | `src/stores/merge.ts` |
|
||
| 事件总线 | `src/utils/eventBus.ts`(mitt) |
|