@pinia/colada 深入解析:Server-First 的异步状态管理
引言
在 Vue 生态中,Pinia 已经成为客户端状态管理的事实标准。然而,随着前端应用越来越复杂,服务端状态(Server State)与客户端状态的边界越来越模糊,传统的 Pinia store 在处理异步数据时显得力不从心:你需要手动管理 loading、error、缓存、重试、SSR 水合……
@pinia/colada 应运而生。它是 Pinia 团队官方推出的异步状态管理库,主打 Server-First 设计哲学,深度集成 Nuxt 和 Vue SSR。本文将深入解析 colada 的设计理念、核心 API、缓存机制、SSR 协同,以及与传统异步方案的差异。
一、设计理念:为什么需要 colada?
1.1 服务端状态 ≠ 客户端状态
传统 Pinia store 管理客户端状态:
const useCartStore = defineStore('cart', () => {
const items = ref<Product[]>([])
const addToCart = (product: Product) => items.value.push(product)
return { items, addToCart }
})
但当我们需要从服务端获取数据时,代码就变成了:
const useUserStore = defineStore('user', () => {
const data = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function fetchUser(id: string) {
loading.value = true
error.value = null
try {
data.value = await api.getUser(id)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, loading, error, fetchUser }
})
这段代码的问题:
- 没有缓存——重复获取同一数据会重复请求
- 没有 SSR 水合——服务端获取的数据无法自动传递到客户端
- 没有并发去重——多个组件同时调用 fetchUser 会发起多次请求
- 没有失效策略——数据何时过期、何时刷新全靠手动
- 没有乐观更新——mutation 时的回滚逻辑需要手动实现
1.2 colada 的 Server-First 哲学
colada 的核心理念:将服务端状态视为一等公民。
const useUserQuery = defineQuery(() => {
const route = useRoute()
return useQuery({
key: () => ['user', route.params.id as string],
query: () => api.getUser(route.params.id as string),
})
})
三大设计原则:
- Server-First:默认考虑 SSR/Streaming 场景,让数据从服务端到客户端的流转是自动的
- 声明式:描述"需要什么数据"而非"如何获取数据"
- 类型安全:路由参数、key、返回值全链路类型推断
1.3 与 Pinia 的关系
colada 不是 Pinia 的替代品,而是互补:
- Pinia 管理纯客户端状态(UI 状态、本地偏好、购物车等)
- colada 管理服务端状态(API 数据、缓存、同步)
它们共享相同的底层机制(Pinia store 实例),可以无缝共存:
const useUserStore = defineStore('user', () => {
const { data: profile, asyncStatus } = useQuery({
key: ['profile'],
query: () => api.getProfile(),
})
const draftBio = ref('')
return { profile, asyncStatus, draftBio }
})
二、核心 API 详解
2.1 useQuery:查询
interface UseQueryOptions<TResult, TKey extends readonly unknown[]> {
key: () => TKey | null
query: (signal: AbortSignal) => Promise<TResult>
transform?: (data: TResult) => TTransformed
initialData?: TResult | (() => TResult)
staleTime?: number | (() => number)
gcTime?: number
retry?: number | ((attempt: number, error: unknown) => boolean)
retryDelay?: (attempt: number) => number
enabled?: MaybeRef<boolean>
}
完整示例:
import { useQuery } from '@pinia/colada'
import { computed, toValue, type MaybeRef } from 'vue'
function useProductList(search: MaybeRef<string>, page: MaybeRef<number>) {
return useQuery({
key: () => {
const s = toValue(search)
const p = toValue(page)
return s ? ['products', s, p] : null
},
query: (signal) =>
$fetch('/api/products', {
params: { q: toValue(search), page: toValue(page) },
signal,
}),
transform: (data) => data.items,
staleTime: 1000 * 60 * 5,
retry: (attempt, error) => attempt < 3 && error.status !== 404,
})
}
2.2 useMutation:变更
import { useMutation, useQueryCache } from '@pinia/colada'
function useUpdateTodo() {
const queryCache = useQueryCache()
return useMutation({
mutation: (todo: Todo) => $fetch(`/api/todos/${todo.id}`, {
method: 'PUT',
body: todo,
}),
onSettled: () => {
queryCache.invalidateQueries({ key: ['todos'] })
},
})
}
乐观更新
colada 提供了显式的 optimisticUpdate API:
const { mutate, mutateAsync } = useMutation({
mutation: (newTodo: Todo) =>
$fetch('/api/todos', { method: 'POST', body: newTodo }),
optimisticUpdate: {
todos: (todos, newTodo) => [...todos.value, newTodo],
},
onError: (error, newTodo, context) => {
console.warn('mutation failed, rolling back', error)
},
})
2.3 defineQuery / defineMutation:可复用封装
const useUserQuery = defineQuery(() => {
const route = useRoute()
const id = computed(() => route.params.id as string)
const { data, asyncStatus, refresh } = useQuery({
key: () => ['user', id.value],
query: () => api.getUser(id.value),
})
const isEditing = ref(false)
const toggleEdit = () => (isEditing.value = !isEditing.value)
return { user: data, status: asyncStatus, refresh, isEditing, toggleEdit }
})
2.4 useQueryCache:缓存操作
const cache = useQueryCache()
cache.invalidateQueries({ key: ['todos'] })
await cache.ensureQueryData({ key: ['user', '1'], query: () => api.getUser('1') })
cache.setQueryData(['user', '1'], { id: 1, name: 'Updated' })
const data = cache.getQueryData<User>(['user', '1'])
cache.removeQueries({ key: ['user', '1'] })
三、SSR 与 Streaming 深度集成
3.1 Nuxt 集成
colada 通过 Nuxt 模块自动集成,useQuery 等函数会自动处理 SSR:
- 服务端运行 query,获取数据
- 数据自动序列化到 Nuxt payload
- 客户端首次渲染时,从 payload 读取数据,不会重复请求
<script setup lang="ts">
const { data: products, status } = useQuery({
key: ['products'],
query: () => $fetch('/api/products'),
})
</script>
3.2 与 useAsyncData 的对比
| 维度 | useAsyncData | colada useQuery |
|---|---|---|
| 缓存粒度 | 单一 key | 响应式 key 矩阵 |
| 失效策略 | 手动 refresh | 自动 + 手动 |
| 跨组件共享 | 通过 key | 通过 defineQuery |
| 乐观更新 | 需手动 | 内置 API |
| Devtools | 有限 | Pinia Devtools |
| 适用范围 | Nuxt 专属 | 通用 Vue 应用 |
四、缓存机制深度剖析
4.1 缓存数据结构
colada 的缓存本质是一个 Map,key 是序列化后的数组字符串:
Map<string, CacheEntry> {
'["user","1"]' => {
state: { data, error, status },
asyncStatus: 'idle' | 'loading' | 'success' | 'error',
createdAt: number,
updatedAt: number,
gcTime: number,
refs: Set<symbol>,
}
}
4.2 staleTime vs gcTime
- staleTime:数据新鲜期,过期后视为"陈旧",下次 mount 时会重新请求
- gcTime:垃圾回收时间,无组件引用且超过 gcTime 后,缓存条目被移除
4.3 引用计数机制
colada 使用引用计数来管理缓存生命周期:
// 组件 A mount → 引用计数 +1
// 组件 B mount(同 key)→ 引用计数 +1
// 组件 A unmount → 引用计数 -1
// 组件 B unmount → 引用计数 -1 = 0
// → 等待 gcTime 后 GC
五、高级模式
5.1 依赖查询
function useUserProjects() {
const { data: user } = useQuery({
key: ['user'],
query: getCurrentUser,
})
const { data: projects } = useQuery({
key: () => user.value ? ['projects', user.value.id] : null,
query: () => getProjects(user.value!.id),
})
return { user, projects }
}
5.2 路由驱动的数据获取
const useRouteQuery = defineQuery(() => {
const route = useRoute()
const router = useRouter()
const page = computed({
get: () => Number(route.query.page) || 1,
set: (v) => router.push({ query: { ...route.query, page: String(v) } }),
})
const { data, asyncStatus } = useQuery({
key: () => ['list', page.value],
query: () => getList(page.value),
})
return { data, asyncStatus, page }
})
六、实战:完整 CRUD
// stores/todos.ts
export const useTodosQuery = defineQuery(() => {
const filter = ref<'all' | 'done' | 'todo'>('all')
const { data, asyncStatus, refresh } = useQuery({
key: () => ['todos', filter.value],
query: () => $fetch<Todo[]>('/api/todos', { params: { filter: filter.value } }),
staleTime: 1000 * 30,
})
return { todos: data, status: asyncStatus, filter, refresh }
})
export const useCreateTodo = defineMutation(() => {
const cache = useQueryCache()
return {
async create(title: string) {
const newTodo = await $fetch<Todo>('/api/todos', {
method: 'POST',
body: { title },
})
cache.invalidateQueries({ key: ['todos'] })
return newTodo
},
}
})
<script setup lang="ts">
const { todos, status, filter, refresh } = useTodosQuery()
const { create } = useCreateTodo()
const newTitle = ref('')
const handleAdd = async () => {
if (!newTitle.value.trim()) return
await create(newTitle.value)
newTitle.value = ''
}
</script>
<template>
<div>
<select v-model="filter">
<option value="all">全部</option>
<option value="done">已完成</option>
<option value="todo">未完成</option>
</select>
<input v-model="newTitle" @keyup.enter="handleAdd" placeholder="新待办..." />
<button @click="handleAdd">添加</button>
<ul v-if="status === 'success'">
<li v-for="todo in todos" :key="todo.id">
<span :class="{ done: todo.done }">{{ todo.title }}</span>
</li>
</ul>
<div v-else>Loading...</div>
</div>
</template>
七、最佳实践
key 设计规范
// ✅ 推荐:层次化 key
['users', 'list', { page, size, filter }]
['users', 'detail', userId]
['users', userId, 'posts', postId]
// ❌ 避免:随机/时间戳
['query', Date.now()]
合理使用 staleTime
| 数据类型 | 建议 staleTime |
|---|---|
| 用户信息 | 5 分钟 |
| 商品列表 | 1 分钟 |
| 实时数据 | 0 或几秒 |
| 静态配置 | 1 小时+ |
八、总结
@pinia/colada 的核心价值在于:
- Server-First:SSR/Streaming 一等公民,无需手动处理水合
- 声明式:写"需要什么数据"而非"如何获取"
- 类型安全:全链路类型推断
- 与 Pinia 协同:服务端状态与客户端状态各司其职
- Nuxt 深度集成:与 useAsyncData 互补
如果你的项目是 Nuxt 全栈应用或需要 SSR 的 Vue 应用,colada 几乎是 2026 年异步状态管理的最佳选择。
Comments | 0条评论