Skip to content
返回

别再手动管理异步状态了!Pinia Colada 给 Vue 开发者带来的新选择

别再手动管理异步状态了!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 之后,最大的感受是:代码确实少了,负担确实轻了

以前一个列表页要手写 loadingdataerror 三个 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 仍是 '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: () => { /* 无论成败,刷新缓存 */ },
})

两个触发方式的区别:

🗄️ 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'] })

关键概念

🔄 查询失效策略

这是从 TanStack Query 时代就很优雅的设计模式:mutation 成功后,不直接更新数据,而是让相关查询失效,由查询自己重新获取

// 新增待办后,让列表失效
onSettled: () => {
  queryCache.invalidateQueries({ key: TODO_KEYS.list() })
}

好处是:不需要知道哪些组件在用这个数据,只需要声明”这个缓存过期了”,所有依赖它的查询都会自动刷新。这就是声明式命令式优雅的地方。

✨ 乐观更新

核心思路:

  1. onMutate:请求发出前,先把缓存改成预期结果,同时记住旧值
  2. onError:如果请求失败,用旧值回滚
  3. 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 ColadaTanStack Vue Query
核心定位Pinia 的数据获取层独立的数据获取库
生态整合原生 Pinia/Vue Router独立运行,需额外桥接
体积~2kb~13kb
DevToolsPinia DevTools 扩展独立 DevTools 面板
社区规模较小,快速成长成熟,大量示例
API 风格key / queryqueryKey / 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/

📌 关注公众号《秋雨》,获取更多生态前沿技术分享。

something


Share this post on:

Previous Post
Nginx 安装步骤(2026 最新持续补充)
Next Post
MySQL 安装步骤 (2026 最新持续补充)