登录状态、用户信息、购物车这类数据,好几个不相干的组件都要用——靠 props 一层层往下传,传到最后自己都记不清谁传给了谁。这一章用 Pinia 建一个「全局仓库」:谁需要谁自己取,改一处所有地方同步更新,再顺手把登录态持久化,让用户刷新页面也不掉登录。
<!-- App.vue:登录后拿到了 token -->
<UserLayout :token="token" />
<!-- UserLayout.vue:自己根本不用 token,只是为了往下传 -->
<script setup>
defineProps({ token: String })
</script>
<template><UserSidebar :token="token" /></template>
<!-- UserSidebar.vue:同样用不到,还得再传一次 -->
<script setup>
defineProps({ token: String })
</script>
<template><UserAvatar :token="token" /></template>
<!-- UserAvatar.vue:终于用上了 -->
<script setup>
defineProps({ token: String })
</script>
# 1. 安装(Vue 3 对应 Pinia 2.x)
npm install pinia
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia()) // 必须先装 Pinia
app.use(router) // 再装路由:守卫里要用到 store
app.mount('#app')
import { defineStore } from 'pinia'
// 第一个参数 "user" 是 store 的唯一 id,全局不能重复
export const useUserStore = defineStore('user', {
// state 写成函数并返回对象:保证每次用都是独立的一份数据
state: () => ({
token: '',
userInfo: null
}),
getters: {
// 派生状态:不存值,靠 token 算出来
isLogin: (state) => !!state.token,
nickname: (state) => state.userInfo?.nickname || '游客'
},
actions: {
// 注意:这里必须用普通函数(不能用箭头函数),this 才能指向 store
setToken(newToken) {
this.token = newToken
},
logout() {
this.token = ''
this.userInfo = null
}
}
})
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
// ---- state:用 ref ----
const token = ref('')
const userInfo = ref(null)
// ---- getters:用 computed ----
const isLogin = computed(() => !!token.value)
const nickname = computed(() => userInfo.value?.nickname || '游客')
// ---- actions:用普通函数 ----
function login(data) {
token.value = data.token
userInfo.value = data.user
}
function logout() {
token.value = ''
userInfo.value = null
}
// 必须把外部要用到的都 return 出去,没 return 的拿不到
return { token, userInfo, isLogin, nickname, login, logout }
})
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
// state 和 getters 用 storeToRefs 解构,保持响应性
const { token, nickname, isLogin } = storeToRefs(userStore)
// actions 直接从 store 上解构就行,它们是普通函数,不需要 ref
const { logout } = userStore
function onLogout() {
logout()
}
</script>
<template>
<header class="nav">
<span v-if="isLogin">你好,{{ nickname }}</span>
<span v-else>登录 / 注册</span>
<button v-if="isLogin" @click="onLogout">退出</button>
</header>
</template>
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
// 初始化时先从 localStorage 取一次(第 8 节详讲「刷新恢复」)
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const isLogin = computed(() => !!token.value)
const nickname = computed(() => userInfo.value?.nickname || '游客')
// 登录成功后调用:把后端返回的 token 和用户信息存进 store
function login(data) {
token.value = data.token
userInfo.value = data.user
localStorage.setItem('token', data.token)
localStorage.setItem('userInfo', JSON.stringify(data.user))
}
// 退出:清 store,也清本地缓存
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
return { token, userInfo, isLogin, nickname, login, logout }
})
登录页负责调 action。真正的接口请求放到第 9 章封装完 axios 之后接上,这里先把流程跑通。
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const form = ref({ username: '', password: '' })
const loading = ref(false)
async function onSubmit() {
loading.value = true
try {
// 第 9 章封装好 axios 后,这里换成:
// const data = await request.post('/user/login', form.value)
// 现在先用接口文档里约定的返回结构把流程跑通
const data = {
token: 'eyJhbGciOiJIUzI1NiJ9.demo',
user: { id: 3, username: 'zhangsan', nickname: '张三' }
}
userStore.login(data) // 1. 存进 Pinia
router.replace(route.query.redirect || '/') // 2. 回到原来想去的页面
} finally {
loading.value = false
}
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="form.username" placeholder="用户名">
<input v-model="form.password" type="password" placeholder="密码">
<button :disabled="loading">{{ loading ? '登录中…' : '登 录' }}</button>
</form>
</template>
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const isLogin = computed(() => !!token.value)
// 只要 token 变了(登录 / 退出),就同步一次 localStorage
watch(token, (val) => {
if (val) localStorage.setItem('token', val)
else localStorage.removeItem('token')
})
// 对象要 deep 才能监听到内部字段的变化,存进去前要转成 JSON 字符串
watch(userInfo, (val) => {
if (val) localStorage.setItem('userInfo', JSON.stringify(val))
else localStorage.removeItem('userInfo')
}, { deep: true })
function login(data) {
token.value = data.token
userInfo.value = data.user
}
function logout() {
token.value = ''
userInfo.value = null
}
return { token, userInfo, isLogin, login, logout }
})
# 1. 安装
npm install pinia-plugin-persistedstate
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null
}),
getters: {
isLogin: (state) => !!state.token,
nickname: (state) => state.userInfo?.nickname || '游客'
},
actions: {
login(data) {
this.token = data.token
this.userInfo = data.user
},
logout() {
this.token = ''
this.userInfo = null
}
},
// 一行配置:整个 state 自动存 localStorage,刷新自动读回来
// 想只存部分字段就写:persist: { paths: ['token'] }
persist: true
})
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref(null)
// 派生状态:不额外存变量,token 一变它就自动重算
const isLogin = computed(() => !!token.value)
// 派生状态也可以基于别的派生状态
const loginText = computed(() => (isLogin.value ? '已登录' : '未登录'))
return { token, userInfo, isLogin, loginText }
})
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from './stores/user'
const userStore = useUserStore()
const { isLogin, nickname } = storeToRefs(userStore)
</script>
<template>
<header class="top">
<!-- 同一份判断,头部只读 isLogin,自己不重复写条件 -->
<template v-if="isLogin">
<span>你好,{{ nickname }}</span>
<router-link to="/user/favorite">我的收藏</router-link>
</template>
<router-link v-else to="/login">登录 / 注册</router-link>
</header>
</template>
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
// 第 1 题:组合式 userStore,带持久化
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const isLogin = computed(() => !!token.value)
const nickname = computed(() => userInfo.value?.nickname || '游客')
function login(data) {
token.value = data.token
userInfo.value = data.user
}
function logout() {
token.value = ''
userInfo.value = null
}
watch(token, (val) => {
if (val) localStorage.setItem('token', val)
else localStorage.removeItem('token')
})
watch(userInfo, (val) => {
if (val) localStorage.setItem('userInfo', JSON.stringify(val))
else localStorage.removeItem('userInfo')
}, { deep: true })
return { token, userInfo, isLogin, nickname, login, logout }
})
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const { isLogin, nickname } = storeToRefs(userStore) // state / getters
const { logout } = userStore // actions
</script>
<template>
<template v-if="isLogin">
<span>你好,{{ nickname }}</span>
<button @click="logout">退出</button>
</template>
<router-link v-else to="/login">登录 / 注册</router-link>
</template>
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
// 第 3 题(红影赏析):和 userStore 是同一套骨架
export const useMovieStore = defineStore('movie', () => {
const watchList = ref(JSON.parse(localStorage.getItem('watchList') || '[]'))
const count = computed(() => watchList.value.length)
const isInList = computed(() => (id) => watchList.value.some((m) => m.id === id))
function addMovie(movie) {
if (!watchList.value.some((m) => m.id === movie.id)) {
watchList.value.push(movie)
}
}
function removeMovie(id) {
watchList.value = watchList.value.filter((m) => m.id !== id)
}
watch(watchList, (val) => {
localStorage.setItem('watchList', JSON.stringify(val))
}, { deep: true })
return { watchList, count, isInList, addMovie, removeMovie }
})
第 3 题的关键:换的只是「状态名和业务动作」,不变的是「state 存数据 + getters 算派生 + actions 改数据 + 持久化」这套骨架。能照着 userStore 写出 movieStore,说明你已经把 Pinia 学到手了。