别再手动管理异步状态了!Pinia Colada 给 Vue 开发者带来的新选择
有没有被这样的代码折磨过:请求开始 → 设 loading=true → 请求成功 → 设 data → 请求失败 → 设 error → finally 里设 loading=false?每个接口都要来一遍?
如果答案是”有”,那今天推荐的这个库,可能会让重新审视 Vue 项目的异步状态管理方式。
介绍
Pinia Colada 是 Pinia 官方团队推出的数据获取层(Data Fetching Layer),专门解决 Vue 应用中的异步状态管理问题。它把缓存、去重、失效、重试这些每次都要手写的逻辑,变成了开箱即用的能力。
作者就是 Vue Router 和 Pinia 的核心维护者 Eduardo San Martin Morite(posva),所以生态整合度可以放心。
核心功能一览
| 功能 | 一句话描述 |
|---|---|
| Query 查询 | 声明式获取异步数据,自动处理 loading/error/data |
| Mutation 变更 | 处理写操作,支持乐观更新 |
| 自动缓存 | 相同 key 的请求自动去重,避免重复请求 |
| 查询失效 | mutation 成功后精准刷新相关查询 |
| 无限查询 | 加载更多、分页滚动场景开箱即用 |
| 乐观更新 | 请求发出前先更新 UI,出错自动回滚 |
| 查询暂停 | 条件不满足时暂停请求,条件变化自动触发 |
| SSR 支持 | 开箱即用的服务端渲染 |
| 插件系统 | 自动刷新、重试、延迟加载、缓存持久化等 |
| TypeScript | 完整类型推导,泛型友好 |
| 体积 | ~2kb,零外部依赖 |
测试 Demo
花了半天时间搭了个小项目,模拟一个「待办事项」应用的场景,把 Pinia Colada 的核心能力跑了一遍。
以下代码基于 Vue 3 + Pinia + Pinia Colada,可直接复制到项目中运行。
1. 项目安装
pnpm i pinia @pinia/colada
# 可选:开发调试工具
pnpm i -D @pinia/colada-devtools
2. 入口配置
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(PiniaColada, {
queryOptions: {
staleTime: 5_000, // 全局默认:5秒内不重复请求
},
})
app.mount('#app')
3. 模拟 API 层
// api/todos.ts
export interface Todo {
id: number
title: string
completed: boolean
}
// 模拟数据
let todos: Todo[] = [
{ id: 1, title: '学习 Pinia Colada', completed: false },
{ id: 2, title: '写技术分享文章', completed: false },
{ id: 3, title: '给文章点赞转发', completed: false },
]
// 模拟网络延迟
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
export async function fetchTodos(): Promise<Todo[]> {
await delay(800)
return [...todos]
}
export async function createTodo(title: string): Promise<Todo> {
await delay(500)
const newTodo: Todo = { id: Date.now(), title, completed: false }
todos = [...todos, newTodo]
return newTodo
}
export async function toggleTodo(id: number): Promise<Todo> {
await delay(300)
todos = todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
const updated = todos.find(t => t.id === id)!
return updated
}
export async function deleteTodo(id: number): Promise<void> {
await delay(300)
todos = todos.filter(t => t.id !== id)
}
4. 查询键工厂
// queries/todos.ts
import { defineQueryOptions } from '@pinia/colada'
import { fetchTodos } from '@/api/todos'
// 键工厂:统一管理查询 key,方便批量失效
export const TODO_KEYS = {
all: ['todos'] as const,
list: () => [...TODO_KEYS.all, 'list'] as const,
detail: (id: number) => [...TODO_KEYS.all, 'detail', id] as const,
}
// 可复用的查询选项
export const todosQuery = defineQueryOptions(() => ({
key: TODO_KEYS.list(),
query: fetchTodos,
staleTime: 10_000, // 10秒内不重新请求
}))
5. 完整组件
<!-- App.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { useQuery, useMutation, useQueryCache } from "@pinia/colada";
import { todosQuery, TODO_KEYS } from "./queries/todos";
import { createTodo, toggleTodo, deleteTodo } from "./api/todos";
import type { Todo } from "./api/todos";
const queryCache = useQueryCache();
const newTodoTitle = ref("");
// ==================== Query:获取待办列表 ====================
const {
state, // { data, error, status }
asyncStatus, // 'idle' | 'loading'
refresh, // 条件刷新(考虑 staleTime)
refetch, // 强制刷新
} = useQuery(() => todosQuery());
// ==================== Mutation:新增待办 ====================
const { mutate: addTodo } = useMutation({
mutation: (title: string) => createTodo(title),
onSettled: () => {
// 新增完成后,让待办列表缓存失效 → 自动重新查询
queryCache.invalidateQueries({ key: TODO_KEYS.list() });
},
});
// ==================== Mutation:切换完成状态(乐观更新) ====================
const { mutate: toggle } = useMutation({
mutation: (id: number) => toggleTodo(id),
// 乐观更新:请求发出前先改 UI
onMutate: id => {
const oldTodos = queryCache.getQueryData<Todo[]>(TODO_KEYS.list());
if (oldTodos) {
queryCache.setQueryData(
TODO_KEYS.list(),
oldTodos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
// 取消进行中的查询,避免覆盖乐观更新
queryCache.cancelQueries({ key: TODO_KEYS.list() });
return { oldTodos };
},
// 出错时回滚
onError: (_err, _id, context) => {
if (context?.oldTodos) {
queryCache.setQueryData(TODO_KEYS.list(), context.oldTodos);
}
},
// 最终都刷新一次
onSettled: () => {
queryCache.invalidateQueries({ key: TODO_KEYS.list() });
},
});
// ==================== Mutation:删除待办 ====================
const { mutate: remove } = useMutation({
mutation: (id: number) => deleteTodo(id),
onSettled: () => {
queryCache.invalidateQueries({ key: TODO_KEYS.list() });
},
});
// ==================== 辅助方法 ====================
function handleAddTodo() {
const title = newTodoTitle.value.trim();
if (!title) return;
addTodo(title);
newTodoTitle.value = "";
}
</script>
<template>
<div class="todo-app">
<h1>🍍 Pinia Colada 待办测试</h1>
<!-- 新增输入框 -->
<div class="add-todo">
<input
v-model="newTodoTitle"
@keyup.enter="handleAddTodo"
placeholder="输入新待办..."
/>
<button @click="handleAddTodo">添加</button>
</div>
<!-- 加载状态 -->
<div v-if="asyncStatus === 'loading' && !state.data" class="loading">
⏳ 加载中...
</div>
<!-- 错误状态 -->
<div v-else-if="state.status === 'error'" class="error">
❌ {{ state.error?.message || "加载失败" }}
</div>
<!-- 数据列表 -->
<div v-else class="todo-list">
<div
v-for="todo in state.data"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed }"
>
<span @click="toggle(todo.id)" class="todo-text">
{{ todo.completed ? "✅" : "⬜" }} {{ todo.title }}
</span>
<button @click="remove(todo.id)" class="delete-btn">删除</button>
</div>
<div v-if="!state.data?.length" class="empty">暂无待办</div>
</div>
<!-- 底部操作 -->
<div class="actions">
<button @click="refresh()">条件刷新</button>
<button @click="refetch()">强制刷新</button>
<span v-if="asyncStatus === 'loading'" class="refresh-hint">
正在刷新...
</span>
</div>
</div>
</template>
<style scoped>
.todo-app {
max-width: 500px;
margin: 40px auto;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 {
text-align: center;
}
.add-todo {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.add-todo input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.add-todo button,
.actions button {
padding: 8px 16px;
background: #42b883;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #f0f0f0;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
cursor: pointer;
}
.delete-btn {
background: none;
border: none;
color: #ff6b6b;
cursor: pointer;
font-size: 13px;
}
.loading,
.error,
.empty {
text-align: center;
padding: 20px;
color: #666;
}
.error {
color: #ff6b6b;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 16px;
}
.refresh-hint {
color: #999;
font-size: 13px;
}
</style>
跑完这个 Demo 之后,最大的感受是:代码确实少了,负担确实轻了。
以前一个列表页要手写 loading、data、error 三个 ref,再写 fetchData 函数包 try/catch,再在 onMounted 里调用。现在一个 useQuery 就搞定了。
各功能模块详解
🔍 Query — 查询
查询是 Pinia Colada 的核心。只需要告诉它两件事:唯一标识(key) 和 怎么获取数据(query 函数),剩下的它全管。
const { state, asyncStatus, refresh, refetch } = useQuery({
key: ['users', userId], // 唯一标识,支持响应式
query: () => fetchUser(userId), // 数据获取函数
staleTime: 10_000, // 缓存新鲜期(毫秒)
gcTime: 300_000, // 缓存保留期(毫秒)
enabled: () => !!userId, // 条件启用
})
状态双层设计:
state.status:数据状态('pending'→'success'/'error')asyncStatus:请求状态('idle'→'loading')
这个双层设计很实用——比如数据已加载成功后再次刷新时,state.status 仍是 'success',但 asyncStatus 变成 'loading',可以据此在列表顶部显示一个小刷新条,而不是把整个列表替换成 loading 骨架屏。
✏️ Mutation — 变更
处理所有”写”操作:新增、修改、删除。它的核心价值在于 onMutate / onSuccess / onError / onSettled 四个生命周期钩子,精准控制变更前后的副作用。
const { mutate, mutateAsync } = useMutation({
mutation: (data) => api.createItem(data),
onMutate: (data) => { /* 乐观更新:先改 UI */ },
onSuccess: (result, data) => { /* 成功后处理 */ },
onError: (err, data, context) => { /* 出错回滚 */ },
onSettled: () => { /* 无论成败,刷新缓存 */ },
})
两个触发方式的区别:
mutate():不返回 Promise,错误被吞掉,适合”发了就行”的场景mutateAsync():返回 Promise,会抛错,适合需要 await 并处理结果的场景
🗄️ Query Cache — 查询缓存
缓存是 Pinia Colada 的灵魂。它不只是存数据,更提供了精细的缓存操作 API:
const queryCache = useQueryCache()
// 读缓存
const data = queryCache.getQueryData(['todos'])
// 写缓存(乐观更新必备)
queryCache.setQueryData(['todos'], newTodos)
// 失效缓存(标记为过期,下次访问自动刷新)
queryCache.invalidateQueries({ key: ['todos'] })
// 精确失效(只匹配完全相同的 key)
queryCache.invalidateQueries({ key: ['todos'], exact: true })
// 取消进行中的查询
queryCache.cancelQueries({ key: ['todos'] })
关键概念:
staleTime:新鲜期。在这段时间内,refresh()不会触发新请求gcTime:垃圾回收期。没有观察者的查询,超过这个时间会被清除
🔄 查询失效策略
这是从 TanStack Query 时代就很优雅的设计模式:mutation 成功后,不直接更新数据,而是让相关查询失效,由查询自己重新获取。
// 新增待办后,让列表失效
onSettled: () => {
queryCache.invalidateQueries({ key: TODO_KEYS.list() })
}
好处是:不需要知道哪些组件在用这个数据,只需要声明”这个缓存过期了”,所有依赖它的查询都会自动刷新。这就是声明式比命令式优雅的地方。
✨ 乐观更新
核心思路:
- onMutate:请求发出前,先把缓存改成预期结果,同时记住旧值
- onError:如果请求失败,用旧值回滚
- onSettled:最终无论如何都刷新一次,保证数据一致
用户体感是:操作后 UI 立刻响应,没有等待感。如果出错了,UI 会闪回原状,但这种情况很少见。
📜 无限查询
滚动加载场景的救星:
const { data, hasNextPage, loadNextPage } = useInfiniteQuery({
key: ['feed'],
initialPageParam: 1,
maxPages: 5, // 最多保留 5 页数据,自动回收旧页
query: ({ pageParam }) => fetchFeed(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage ?? null,
})
maxPages 是个很好的设计:无限滚动如果一直追加数据,内存迟早爆炸。有了它,超过限制会自动丢弃最旧的页面。
🔌 插件生态
官方提供了 4 个实用插件:
| 插件 | 功能 | 安装包 |
|---|---|---|
| Auto Refetch | 自动定时刷新 | @pinia/colada-plugin-auto-refetch |
| Retry | 请求失败自动重试 | @pinia/colada-plugin-retry |
| Delay | 延迟显示 loading(防闪烁) | @pinia/colada-plugin-delay |
| Cache Persister | 缓存持久化到 Storage | @pinia/colada-plugin-cache-persister |
实际测试中,Delay 插件解决了一个很常见的问题:刷新列表时数据已经在缓存里了,但因为网络请求的存在,UI 会短暂闪一下 loading 状态再恢复。加个 200ms 延迟,只有真正慢的请求才会显示 loading。
插件用法统一:
app.use(PiniaColada, {
plugins: [
PiniaColadaAutoRefetch({ autoRefetch: true }),
PiniaColadaRetry({ retry: 3 }),
PiniaColadaDelay({ delay: 200 }),
],
})
🏭 defineQueryOptions & defineQuery
两个复用机制,解决不同的场景:
defineQueryOptions:复用查询配置,不含状态
export const productQuery = defineQueryOptions((id: string) => ({
key: ['products', id],
query: () => fetchProduct(id),
staleTime: 60_000,
}))
// 使用时
useQuery(() => productQuery(props.id))
defineQuery:复用整个查询(含响应式状态)
export const useSearchTodos = defineQuery(() => {
const keyword = ref('')
const { state, ...rest } = useQuery({
key: () => ['todos', keyword.value],
query: () => searchTodos(keyword.value),
})
return { keyword, state, ...rest }
})
后者适合需要暴露额外响应式变量的场景,比如搜索关键词。
优缺点分析
✅ 优点
1. 心智模型简洁
只有 Query(读)和 Mutation(写)两个核心概念。学会这两个,就掌握了 80% 的能力。不像有些库,概念满天飞,光看文档就头大。
2. Pinia 生态原生整合
不是”能用”,是”原生的”。它直接基于 Pinia 的 store 机制构建,DevTools 里能看到查询状态,缓存数据可以和 Pinia store 互通。这点比 TanStack Vue Query 强——后者是 React 生态移植过来的,和 Pinia 是平行关系。
3. 缓存策略实用
staleTime + gcTime + 失效机制,三者组合覆盖了遇到的所有缓存场景。尤其是”失效后自动刷新”这个模式,比手动管理缓存更新安全得多。
4. 体积小
~2kb,零外部依赖(Pinia 本身不算)。对包体积敏感的项目很友好。
5. SSR 开箱即用
不需要额外配置,useQuery 在 SSR 环境下自动通过 onServerPrefetch 处理。这一点对于 Nuxt 项目非常方便。
6. TypeScript 体验优秀
defineQueryOptions 配合泛型推导,查询返回的数据类型自动推断,不需要手动标注。
❌ 缺点
1. 生态还不够成熟
这是 1.0 正式版之前的库(截至 2026 年 4 月),插件生态和社区方案相比 TanStack Query 还是少。如果需要某些特殊能力(如分页查询的游标模式),可能需要自己实现。
2. 学习曲线对新手不友好
如果没接触过 TanStack Query / SWR 这类库,“缓存失效”、“乐观更新”、“staleTime vs gcTime”这些概念需要时间消化。不是库本身复杂,是异步状态管理这个领域天然有门槛。
3. 和 TanStack Vue Query 的选择困难
两者解决的问题几乎一样,API 设计也很相似。TanStack Query 生态更大、社区更活跃、文档更丰富;Pinia Colada 生态整合更好、体积更小、Vue 风格更地道。如何选择,取决于是”生态优先”还是”整合优先”。
和 TanStack Vue Query 的关键差异
| 维度 | Pinia Colada | TanStack Vue Query |
|---|---|---|
| 核心定位 | Pinia 的数据获取层 | 独立的数据获取库 |
| 生态整合 | 原生 Pinia/Vue Router | 独立运行,需额外桥接 |
| 体积 | ~2kb | ~13kb |
| DevTools | Pinia DevTools 扩展 | 独立 DevTools 面板 |
| 社区规模 | 较小,快速成长 | 成熟,大量示例 |
| API 风格 | key / query | queryKey / queryFn |
| SSR | 开箱即用 | 需配置插件 |
| 维护者 | Vue 核心团队 | TanStack 团队 |
建议:新项目如果已经深度使用 Pinia,选 Pinia Colada 整合更丝滑;如果团队已经有 TanStack Query 经验,或者需要更成熟的插件生态,继续用 TanStack Query 也完全没问题。
总结
Pinia Colada 解决的核心问题是:让 Vue 开发者不再手写异步状态管理模板代码。
它不是要取代 Pinia,而是 Pinia 的自然延伸——Pinia 管同步状态,Pinia Colada 管异步状态。两者配合,Vue 的状态管理就补全了最后一块拼图。
从测试体验来看,对于中大型 Vue 项目,它确实能显著减少样板代码,提升开发体验。但考虑到它的成熟度,建议先在非核心模块试水,等团队熟悉后再推广到全局。
一句话总结:如果用 Pinia,而且厌倦了手写 loading/error/data 三件套,Pinia Colada 值得试试。
🔗 Pinia Colada 官方文档:https://pinia-colada.esm.dev/
📌 关注公众号《秋雨》,获取更多生态前沿技术分享。
