Install
openclaw skills install @libo-123/vue-spa-state按作用域为 Vue 3 SPA 选择并落地状态。仅本组件:ref/computed。父子 1~2 层:props/emit、 v-model。同一页 3+ 层:provide/inject(InjectionKey;改同一份 ref,不要重新 provide)。 跨路由、跨模块、登录态,或同一对象部分字段仅本组件、部分给其他组件:整份进 Pinia。 新 store 用 setup + 箭头函数;选项式只改存量或需要 $reset。含 storeToRefs、store 单向互调、普通函数/守卫/拦截器在 pinia 安装后取 store、按业务域拆分与导出。 在新增或修改 Vue 状态、Pi
openclaw skills install @libo-123/vue-spa-state按作用域选方案(按照状态复杂度评估)
写状态相关代码时:先定作用域,再抄对应模板。完整示范见 examples.md。
当前组件用 → ref / computed(必要时 reactive)
父子、隔 1~2 层 → props down + emit up;双向用 v-model
简单同一功能树、隔 3+ 层 → provide / inject(带 InjectionKey)
跨路由 / 跨模块 / 登录态 → Pinia
模块级 ref 再 export,效果像全局 store,但没有 devtools、没有 $reset、HMR 行为差。正式业务不要用。
storeToRefs。action 直接 store.xxx(),或解构 action(Pinia 已绑定 this)。useXxxStore(),不要在模块顶层执行。store 之间单向依赖,禁止 A↔B 互相 import。useXxxStore(),但必须发生在 app.use(pinia) 之后。不要写在模块顶层。id 全局唯一。stores/index.ts 只做 re-export。appStore 塞全部。const xxx = () => {},不要 function 声明。选项式 actions 例外(要 this)。默认 setup store(与 src/stores/counter.ts 一致)。新文件一律 setup,同一文件不要混选项式。选项式只改存量、或明确需要 $reset 时用。
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Item } from '@/types/item'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const list = ref<Item[]>([])
const loading = ref(false)
const doubleCount = computed(() => count.value * 2)
const doublePlusOne = computed(() => doubleCount.value + 1)
const itemById = (id: number) => list.value.find((i) => i.id === id)
const increment = () => {
count.value++
}
const fetchList = async () => {
loading.value = true
try {
list.value = await getList()
} finally {
loading.value = false
}
}
const reset = () => {
count.value = 0
list.value = []
loading.value = false
}
return { count, list, loading, doubleCount, doublePlusOne, itemById, increment, fetchList, reset }
})
ref = state,computed = getter,函数 = action。和组件 <script setup> 同一套。return 漏了的,外面拿不到。$reset,要还原自己写 reset()。itemById),没有 computed 缓存。只用于改已有选项式文件,或确实要 $reset / $patch。新 store 不要用。完整示例见 examples.md。
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
state 必须是工厂函数。$reset()。批量改:this.$patch({ count: 0 }) 或 $patch((s) => { s.list.push(x) })。<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
store.increment()
</script>
<template>
<button @click="store.increment">{{ count }} / {{ doubleCount }}</button>
</template>
// 丢响应
const { count } = store
// 对
const { count } = storeToRefs(store)
store.increment()
模板里可以直接 store.count,不必解构。只在 script 里要单独用响应式值时才 storeToRefs。
// cart.ts —— 可以依赖 user;user 不要再 import cart
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const user = useUserStore()
const checkout = () => {
if (!user.isLogin) return
// ...
}
return { checkout }
})
协调两个 store 的流程(下单 = 读 user + 清 cart + 写 order)放到 调用方或专门的 order store,不要让 user 去清 cart。
// utils/tracker.ts
import { useUserStore } from '@/stores/user'
export const trackEvent = (name: string) => {
const user = useUserStore()
send({ name, uid: user.id })
}
路由守卫、axios 拦截器同样:在回调里取 store,不要在 router/index.ts 顶层取。
src/stores/
index.ts # 只 re-export
user.ts # 登录、资料、权限码
cart.ts
order.ts
app.ts # 主题、侧栏折叠(壳层,不是业务垃圾桶)
// stores/index.ts
export { useUserStore } from './user'
export { useCartStore } from './cart'
export { useOrderStore } from './order'
export { useAppStore } from './app'
组件:import { useUserStore } from '@/stores/user' 或从 @/stores。不要从组件里深挖 stores/user/actions.ts。
复杂状态怎么组合:
useUserStore(),不要把 userId 再拷进 cart。order store 编排,或页面里依次调,不要循环依赖。usePagination),store 里调用;composable 不要自己再搞一份全局状态。reset()。不要为了 $reset 把新模块写成选项式。localStorage,只持久化最小字段。什么进 Pinia、什么不进:见 examples.md 划分表。
<script setup lang="ts">
const open = ref(false)
const keyword = ref('')
const list = ref<Item[]>([])
const filtered = computed(() =>
list.value.filter((i) => i.name.includes(keyword.value)),
)
const toggle = () => {
open.value = !open.value
}
</script>
ref。对象要整体替换也用 ref。只有一堆字段要一起改、且不想写 .value 时才 reactive。computed,不要 watch 里手动同步第二份。1~2 层:props
<!-- 父 -->
<UserCard :user="user" @rename="user.name = $event" />
<SearchInput v-model="keyword" />
const user = defineProps<{ user: User }>()
const emit = defineEmits<{ rename: [name: string] }>()
const keyword = defineModel<string>()
3+ 层、同一功能树:provide / inject
只在这棵树有效。页根 provide 一份 ref,深层 inject。跨路由 / 登录态用 Pinia。
export const OrderFilterKey: InjectionKey<Ref<OrderFilter>> = Symbol('order-filter')
const filter = ref<OrderFilter>({ keyword: '', status: 'all' })
provide(OrderFilterKey, filter)
const filter = inject(OrderFilterKey)
if (!filter) throw new Error('OrderFilterKey missing')
filter.value.keyword = 'vue' // 改同一份,不要再 provide
ref / reactive 本身,不要 .value;key 用 InjectionKey,不要字符串。provide(Key, { filter: readonly(filter), setFilter })。stores/index.ts 导出。选项式只动存量。storeToRefs;action 走 store.xxx。