WSの小屋

@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 }
})

这段代码的问题:

  1. 没有缓存——重复获取同一数据会重复请求
  2. 没有 SSR 水合——服务端获取的数据无法自动传递到客户端
  3. 没有并发去重——多个组件同时调用 fetchUser 会发起多次请求
  4. 没有失效策略——数据何时过期、何时刷新全靠手动
  5. 没有乐观更新——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),
  })
})

三大设计原则:

  1. Server-First:默认考虑 SSR/Streaming 场景,让数据从服务端到客户端的流转是自动的
  2. 声明式:描述"需要什么数据"而非"如何获取数据"
  3. 类型安全:路由参数、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:

  1. 服务端运行 query,获取数据
  2. 数据自动序列化到 Nuxt payload
  3. 客户端首次渲染时,从 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 的核心价值在于:

  1. Server-First:SSR/Streaming 一等公民,无需手动处理水合
  2. 声明式:写"需要什么数据"而非"如何获取"
  3. 类型安全:全链路类型推断
  4. 与 Pinia 协同:服务端状态与客户端状态各司其职
  5. Nuxt 深度集成:与 useAsyncData 互补

如果你的项目是 Nuxt 全栈应用或需要 SSR 的 Vue 应用,colada 几乎是 2026 年异步状态管理的最佳选择。

Comments | 0条评论