主题
07 · 状态与数据层边界
目标:分清 UI 状态 与 服务端状态;知道什么该进 store、什么该进 query/cache;能画出一层「页面 → 数据层 → API」并改一处解耦。
1. 背景:为什么总把 store 写满
Journal 这类项目里,常见痛点是:笔记列表、用户信息、弹窗开关、筛选条件全塞进 Pinia。短期能跑,长期会出现:
- 列表刷新逻辑散落在多个 action 里,改 API 要改三处组件;
- 「服务端已有权威数据」和「纯 UI 临时态」混在一起,难以判断该缓存还是该丢弃;
- 页面卸载后仍持有过期列表,或 conversely 每次进页都重复请求。
根因往往不是「Pinia 不好」,而是没划清边界。
2. 核心概念:两类状态
| 类型 | 例子 | 权威来源 | 典型归宿 |
|---|---|---|---|
| UI 状态 | 侧边栏开闭、当前 tab、表单草稿、loading 动画 | 浏览器 / 组件 | 组件本地 ref、小范围 store |
| 服务端状态 | 笔记列表、用户资料、权限、分页游标 | 后端 API | query/cache 层 + 薄封装 API |
服务端状态的特征:别人也可能改、有过期时间、需要失效策略。UI 状态的特征:只影响当前界面、丢了可重建、不必和 HTTP 缓存纠缠。
2.1 Store vs Query / Cache
| 全局 Store(Pinia / Redux) | Query / Cache(TanStack Query、SWR、自研) | |
|---|---|---|
| 擅长 | 跨页 UI、客户端派生、会话级偏好 | 请求去重、stale 标记、后台刷新、乐观更新 |
| 不擅长 | 替 HTTP 做缓存失效、并发去重 | 存纯 UI 开关 |
不必上库也能有「query 层」:把 fetch + 缓存键 + 失效 从组件里抽成模块,store 只消费结果。
2.2 一层架构图
text
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Page / │────▶│ Store (UI) │ │ │
│ Component │ │ 或 Query Cache │────▶│ API Module │
└─────────────┘ │ (server state) │ │ (request) │
│ └──────────────────┘ └──────┬──────┘
│ ▲ │
└──── 本地 UI ref ─────┘ ▼
Koa / 后端1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
规则:组件不直接拼 URL;API 模块不知道弹窗开没开;store/query 不 import 具体 .vue 文件。
3. 最小实践:解耦一处(框架无关)
下面用伪代码表达「改一处」——Vue / React 同样适用。
Before(组件扛一切)
javascript
// note-list.vue — 反例
async function load() {
loading.value = true
const res = await uni.request({ url: '/notes', header: { Authorization: token } })
notes.value = res.data
loading.value = false
}1
2
3
4
5
6
7
2
3
4
5
6
7
After(三层)
javascript
// api/notes.js — 只关心 HTTP
export function fetchNotes(params) {
return get('/notes', params) // Journal: request.js
}
// composables/useNotes.js — 服务端状态 + 缓存键
const cache = new Map()
export function useNotes(notebookId) {
const key = `notes:${notebookId}`
async function refresh() {
const data = await fetchNotes({ notebookId })
cache.set(key, { data, at: Date.now() })
return data
}
return { refresh, getCached: () => cache.get(key)?.data }
}
// note-list.vue — UI 状态留本地
const expandedId = ref(null) // 纯 UI
const { refresh, getCached } = useNotes(props.notebookId)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
改 API 字段时,只动 api/notes.js;改「进页是否强制刷新」只动 composable;组件只管 expandedId。
对照 Journal:Pinia 里 useNoteBookStore 更适合放「当前选中本子、排序偏好」;列表实体若长期放 store,建议配 lastFetchedAt 或迁到独立 notes cache,避免和 UI 字段同文件膨胀。
4. 踩坑与取舍
| 坑 | 表现 | 默认做法 |
|---|---|---|
| 服务端状态全进 store | 退出登录清不干净、测试要 mock 整个 store | 按域拆分;登出时只 invalidate 服务端 slice |
| 没有缓存键 | 同页多组件各请求一次 | 以 resource + id + query 为键,请求层去重 |
| 过度解耦 | 三个文件才能看懂一次加载 | 小页面允许 composable 内联 fetch;重复第二次再抽 |
| SSR / 首屏 | 客户端 onMounted 才拉列表 | 能 Server 取的先 Server(见 react进阶 07);uni-app 则考虑 prefetch |
何时仍用全局 store 存服务端数据:离线包、强一致跨 Tab 广播、或团队尚未引入 query 库且已有成熟 Pinia 模式——但要写清 失效时机(创建/删除/编辑后 invalidate)。
5. 验收清单
- [ ] 能口头区分 UI 状态 vs 服务端状态,各举 Journal 里 2 个例子
- [ ] 画出「页面 → store/query → API → 后端」四层图
- [ ] 在仓库或 scratch 项目里,把一处「组件内 fetch」改成 API + composable + 组件
- [ ] 说清:笔记列表刷新应在哪一层触发 invalidate
6. 下一步
参考链接
- Pinia 官方 — 核心概念
- TanStack Query — Important Defaults(概念可迁移到 Vue)
- Journal:
client/src/store/、client/src/api/、client/src/utils/request.js
