码上问学问代码之理,学编程之道

第 8 章 · 状态管理 Pinia

登录状态、用户信息、购物车这类数据,好几个不相干的组件都要用——靠 props 一层层往下传,传到最后自己都记不清谁传给了谁。这一章用 Pinia 建一个「全局仓库」:谁需要谁自己取,改一处所有地方同步更新,再顺手把登录态持久化,让用户刷新页面也不掉登录。

为什么需要全局状态

props 传三层的痛苦
props 层层透传 vs Pinia 中央仓库:数据放「仓库」,谁要谁自取没有仓库:props 三层传App(持有数据)Son(只是路过)Grandson(要用)中间层被迫接力:不碰数据也要接一手有仓库:Pinia storeuserStoreAppSonGrandson任何组件谁用谁 import,不再层层接力全局状态只该放「真正全局」的东西:登录用户、token、主题;页面私有数据别进仓库
先讲为什么想一个具体场景:用户登录后拿到了 token 和昵称。头部导航要显示昵称,评论区要判断「你能发评论吗」,收藏按钮要知道「点这个要不要先登录」。这三块东西在组件树的不同分支上,彼此毫无父子关系。
第 6 章学的 props 只能「父传子」。硬要传,就得让它们共同的祖先拿着 token,然后沿着组件树一层层往下递——中间那些组件根本用不到这份数据,却被迫接收再转发。这种写法有个专名叫 prop drilling(逐层透传),是组件化开发里最典型的痛点之一。
动手做先看清「透传」到底长什么样。这就是不用状态管理时,把 token 从页面传到第 3 层头像组件要写的代码。
传统的 props 逐层透传vue
<!-- 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>
页面效果组件渲染结果
运行结果: 问题现场:token 在 App.vue 手里,但真正要用它的是第 3 层的 UserAvatar.vue App.vue 拿到 token,传 :token="token" UserLayout.vue 收 props: token —— 用不到,只负责往下传 UserSidebar.vue 收 props: token —— 也用不到,还得再传一次 UserAvatar.vue 到这里才真正使用 (这就是 prop drilling:中间两层成了「搬运工」。再加一层用户中心子页,链路上所有文件都要改)
讲透:什么数据该放进全局仓库
  • 该放:登录状态与 token、当前用户信息、全局提示(主题色、语言)、购物车、被多个页面复用的分类列表。
  • 不该放:只在一个组件里用的临时变量(比如某个弹窗开不开、某个输入框的值)。那种数据放在组件自己的 ref 里最合适,塞进全局反而更难追踪。
  • 判断标准一句话:如果这份数据「有两个互不相干的组件需要看到」,才考虑放全局。
  • 顺带一提,第 7 章的导航守卫里判断登录,用的就是这个全局仓库里的 userStore.token——守卫生效的前提,正是本章要建的这个 store。

Pinia 是什么

Vue 3 官方推荐的全局仓库
先讲为什么是它你可能会搜到 Vuex——那是 Vue 2 时代的状态管理方案,写法重(要分 mutation / action,改个值还得绕一圈)。Pinia 是 Vue 官方现在推荐的方案,也是 Vuex 5 的原型,一句话概括它的优点:没有 mutation、没有嵌套模块,写起来和写一个普通的组合式函数几乎一样。
动手做装 Pinia,并在 main.js 里注册。注意注册顺序:Pinia 要在 router 之前装,否则第 7 章的守卫里拿不到 store。
安装 + src/main.jsjs
# 1. 安装(Vue 3 对应 Pinia 2.x)
npm install pinia
页面效果浏览器运行结果
运行结果: $ npm install pinia added 1 package in 1.2s (依赖写进 package.json 的 dependencies: "pinia": "^2.2.6")
src/main.jsjs
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')
页面效果浏览器运行结果
运行结果: 启动项目 npm run dev,控制台没有任何警告: VITE v5.4.10 ready in 423 ms ➜ Local: http://localhost:5173/ 打开 Vue DevTools → Pinia 面板,能看到已注册的 store 列表 (如果先装 router 再装 pinia,守卫里调用 useUserStore() 会报: getActivePinia() was called but there was no active Pinia)
讲透:Pinia 里的三个词
  • state:仓库里的数据本体,相当于组件里的 ref / reactive。
  • getters:派生数据,相当于组件里的 computed——不存值,靠别的 state 算出来。
  • actions:改数据的方法,相当于组件里的普通函数。Pinia 里 action 可以直接是异步的(写 async 就行),不需要像 Vuex 那样区分同步异步。
  • 一个 store 就是一个模块,不存在 Vuex 那种 modules 嵌套。项目大了就多建几个 store 文件,各管各的。

选项式定义 store

defineStore(id, { state, getters, actions })
userStore 三件套:state 存数据、getters 算结果、actions 做动作useUserStore(defineStore)id: 'user'state 状态user: nulltoken: ''getters 派生isLogin: !!token(缓存,依赖变才算)actions 动作login() 登录logout() 清空login() 的内部流程:调 api → 存 user/token 进 state → 组件里所有用到的地方自动更新isLogin 是 getters:一处判断(导航守卫、页面按钮、请求拦截器),处处使用组件里用 storeToRefs 解构才保得住响应性——直接解构会「断线」
动手做新建 src/stores/user.js,用「选项式」写法定义第一个 store。三个部分对应 state / getters / actions。
src/stores/user.js(选项式)js
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
    }
  }
})
页面效果浏览器运行结果
运行结果: (在任意组件或浏览器控制台里调用后) const userStore = useUserStore() userStore.token → "" ← state userStore.userInfo → null ← state userStore.isLogin → false ← getters,由 token 算出来 userStore.nickname → "游客" ← getters userStore.setToken("abc123") ← action userStore.token → "abc123" userStore.isLogin → true ← getters 自动跟着重算,你不用管
讲透
  • defineStore 返回的是一个「函数」而不是数据。它是 useUserStore,不是 userStore。要拿数据必须调用它:const userStore = useUserStore()。
  • state 为什么是函数:和组件的 data() 一个道理——每次调用都返回一份新对象,避免多个地方共享同一个引用导致数据互相污染。
  • getters 里的 state 参数是只读的,别想在里面改数据。要改数据就写 action。
  • 选项式适合「结构简单、一眼看得完」的 store,就像我们这个 user store 一样。
避坑actions 里千万别写箭头函数:logout: () => { this.token = '' }。箭头函数没有自己的 this,它不会指向 store,运行时要么报 Cannot set property of undefined,要么悄悄改错东西。actions 一律用 ES6 方法简写(logout() { ... })。

组合式定义 store

和写普通组合式函数一模一样
先讲为什么会有第二种写法选项式里要记「state / getters / actions」往哪放,还要小心 this。
但你在第 5 章已经学会用 ref 存数据、computed 算派生值、普通函数做操作了——那为什么定义 store 还要换一套语法? Pinia 的第二种写法(setup store)就是:第二参数直接传一个函数,里面用组合式 API 写,最后 return 出去。学过的知识原样搬过来,不用记新规则。
动手做把上面的 user store 用组合式重写一遍,功能完全等价。
src/stores/user.js(组合式)js
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 }
})
页面效果浏览器运行结果
运行结果: 组件里使用的方式与选项式完全一致——外层不用关心 store 是用哪种风格写的: const userStore = useUserStore() userStore.token → "" userStore.isLogin → false ← 这是 computed,被 Pinia 自动解包成布尔值 userStore.nickname → "游客" userStore.login({ token: "abc123", user: { nickname: "张三" } }) userStore.token → "abc123" userStore.isLogin → true userStore.nickname → "张三"
讲透:两种写法怎么选
  • 组合式里 computed 会自动解包:在组件里写 userStore.isLogin 就是布尔值,不用写 .value。这是 Pinia 帮你做的。
  • 但在 store 自己内部,token.value 的 .value 不能省——那里它就是普通的 ref。这一条最容易搞混。
  • 两条路殊途同归:不管哪种风格,组件里都是 useUserStore() 拿到同一个实例。本项目后面的示例统一用组合式,因为它和 <script setup> 完全同源。
  • 一个小纪律:在 store 外部不要直接改 userStore.token = 'xxx'。虽然 Pinia 允许,但统一走 action,将来要在改的时候顺便写日志、清缓存,只改一个地方就好。

在组件里使用 store

storeToRefs 保住响应性
动手做组件里调用 useUserStore() 拿到 store,然后用 storeToRefs 把 state / getters 取出来。
src/components/AppHeader.vuevue
<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>
页面效果组件渲染结果
运行结果: 【正确】用 storeToRefs 解构之后: userStore.login({ token: "abc123", user: { nickname: "张三" } }) → isLogin 变成 true,页面上「你好,张三」和「退出」按钮立刻出现 → 头部渲染:你好,张三 [退出] 【错误】如果直接写成 const { isLogin } = userStore: → 解构出来的是当前这一刻的布尔值 false,和 store 断了联系 → 之后 store 里的 token 再怎么变,页面上的「未登录」都不会更新
讲透:为什么 state 不能直接解构
  • 直接解构「解掉」的是响应性:const { token } = userStore 拿到的是一个普通的字符串,它不会跟着 store 变。这和 const { count } = reactive({ count: 0 }) 丢响应性是同一个原理。
  • storeToRefs 把每个属性包成 ref,ref 是「活」的,读它的 .value 会永远指向最新的值。在模板里 Vue 会自动解包,所以你直接写 {{ nickname }} 就行。
  • actions 不需要 storeToRefs:函数解构出来还是同一个函数,调用它照样能改 store。用 storeToRefs 包它反而多余。
  • 如果你嫌麻烦,也可以完全不结构,全程写 userStore.token、userStore.isLogin——在模板里这样写响应性天然就是好的。
避坑两种写法别混用:state / getters 走 storeToRefs,actions 走普通解构。storeToRefs(userStore) 只会挑出 state 和 getters,action 根本不在它的返回结果里——如果你写成 const { logout } = storeToRefs(userStore),拿到的会是 undefined,点了按钮就报 logout is not a function。

实战:userStore 的 login / logout

把登录状态真正管起来
动手做写出「古典文学网」真正要用的 user store:带上启动回填、登录、退出三件事。
src/stores/user.jsjs
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 }
})
页面效果浏览器运行结果
运行结果: 【登录成功后调用 userStore.login(data)】 userStore.token → "eyJhbGciOiJIUzI1NiJ9.demo" userStore.userInfo → { id: 3, username: "zhangsan", nickname: "张三" } userStore.isLogin → true userStore.nickname → "张三" 头部显示:你好,张三 [退出] 【点击「退出」调用 userStore.logout()】 userStore.token → "" userStore.userInfo → null userStore.isLogin → false 头部显示:登录 / 注册

登录页负责调 action。真正的接口请求放到第 9 章封装完 axios 之后接上,这里先把流程跑通。

src/views/Login.vuevue
<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>
页面效果组件渲染结果
运行结果: 提交表单后: userStore.token → "eyJhbGciOiJIUzI1NiJ9.demo" userStore.userInfo → { id: 3, username: "zhangsan", nickname: "张三" } userStore.isLogin → true localStorage 里同步出现: token → "eyJhbGciOiJIUzI1NiJ9.demo" userInfo → {"id":3,"username":"zhangsan","nickname":"张三"} 随后 router.replace 把用户送回登录前想去的页面
讲透:action 该不该负责「请求」
  • 两种做法都成立:① 组件里请求,成功拿到 data 后调 userStore.login(data);② 把请求写进 action 里,组件只调 userStore.login(username, password)。
  • 本项目选 ①:这样 store 只管数据,不管网络,方便调试也方便替换(上一段代码里那个假 data 就是想说明这一点——把假数据换成真接口,store 一行都不用改)。
  • 退出登录除了清本地,还应该调一次后端 POST /api/user/logout。但 JWT 是无状态的,用户能不能继续操作完全取决于前端有没有带 token,所以清本地这一步才是关键。
  • 顺序很重要:先 login(data) 再 router.replace。反过来的话,跳转瞬间守卫读到的 token 还是空的,又会被踢回登录页。

持久化:把 token 存进 localStorage

手写 watch 监听变化
持久化:state 是内存数据,一刷新就清空 → 同步一份到 localStoragePinia state(内存)token: 'abc123'watch 监听变化 → 写入启动时读回 → 填充localStorage(磁盘)key: 'user' → JSON 字符串效果:登录 → 刷新页面 → 还是登录态(token 从 localStorage 回填进 state)手写方案 = watch 同步写 + 启动时读;pinia-plugin-persistedstate = 一行配置全包
先讲为什么用户登录完,顺手按了个 F5——结果变回未登录状态了。
原因是:Pinia 的数据存在内存(JavaScript 变量)里,一刷新页面整个应用重新加载,内存被清空。而 localStorage 是浏览器的持久存储,关掉标签页也还在。所以要把 token 这种「刷新后还得留着」的东西写进去。
但注意:「存」和「恢复」是两件事,存靠下面这段 watch,恢复靠第 8 节。
动手做在 store 里加两个 watch,盯着 token 和 userInfo,一变就同步到 localStorage。
src/stores/user.js(在 store 里追加)js
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 }
})
页面效果浏览器运行结果
运行结果: F12 → Application(应用)→ Local Storage → http://localhost:5173 Key Value token eyJhbGciOiJIUzI1NiJ9.demo userInfo {"id":3,"username":"zhangsan","nickname":"张三"} 点「退出」之后,两条记录同时被删掉: Key Value (localStorage 为空) 注意:login / logout 里已经不需要再手动写 localStorage 了,watch 会把这两步自动做掉
讲透:手动 watch vs 用插件
  • 手动 watch 的优点:清楚知道存了什么、key 叫什么,出问题一查就明白。三个字段以内的 store,手写完全够用。
  • 缺点:每加一个要持久化的字段就得再加一个 watch,字段多了很容易漏。
  • 这是第 3 章 watch 知识的直接落地:watch 用来做「数据变化后顺便干点别的事」,写 localStorage 就是典型的副作用。
  • 存进去的都是字符串:对象必须 JSON.stringify 后再存,取出来要 JSON.parse。这也解释了 store 初始化时为什么要写 || 'null':键不存在时 getItem 返回 null,直接 JSON.parse 会抛异常把整个应用搞崩。
避坑两个必须知道的事实:① localStorage 只能存字符串,直接 setItem('userInfo', {id: 3}) 存进去会变成 "[object Object]",取出来再 JSON.parse 直接报错;② localStorage 是明文的,任何能在这页跑起来的脚本都能读到它。所以真上线的项目里,token 要设短过期时间,或者改用 httpOnly Cookie。现在这样存是为了让你能亲眼看到登录态怎么活下来。

持久化插件与刷新恢复

一行配置搞定 + 启动回填
先讲为什么上一节我们手写了两个 watch。如果 store 里有十个字段要持久化呢?那就是十段几乎一样的代码。这种「模式固定、只是重复」的活,最适合交给插件——pinia-plugin-persistedstate 就是干这个的:给它配一行,它自动把整个 state 存进 localStorage,刷新时自动填回来。
动手做先装插件、在 main.js 里注册,再给 store 加上 persist。
安装 + src/main.jsjs
# 1. 安装
npm install pinia-plugin-persistedstate
页面效果浏览器运行结果
运行结果: $ npm install pinia-plugin-persistedstate added 1 package in 1.4s (写入 package.json: "pinia-plugin-persistedstate": "^4.0.1")
src/main.jsjs
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')
页面效果浏览器运行结果
运行结果: 启动项目,控制台无报错: VITE v5.4.10 ready in 423 ms ➜ Local: http://localhost:5173/ (插件已挂到 Pinia 上,下面给 store 加上 persist 配置就会自动生效)
src/stores/user.js(选项式 + persist)js
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
})
页面效果浏览器运行结果
运行结果: F12 → Application → Local Storage → http://localhost:5173 Key Value user {"token":"eyJhbGciOiJIUzI1NiJ9.demo","userInfo":{"id":3,"username":"zhangsan","nickname":"张三"}} 说明: · key 默认就是 store 的 id,也就是 "user",不用自己起名 · 整个 state 被 JSON 序列化后存成一个字符串 · 按 F5 刷新 → 插件自动把值填回 store,不需要你写任何读取代码
讲透:插件帮你做了「存 + 取」两件事
  • 存:内部就是用 watch 盯着整个 state(也就是你上一节手写的那段逻辑,只是更通用)。
  • 取:store 被创建时先读 localStorage,把值灌回 state。所以刷新后 token 是有的,页面看起来就「没掉登录」。
  • 两种方案怎么选:字段少、只想存 token → 手写 watch,清楚可控;store 里字段多、整块都想留 → 用插件,一行搞定。不要两个都用,否则同一份数据被两个机制来回写,容易出怪问题。
  • 用插件时要把它写进 package.json 的依赖,否则换台电脑 npm install 后就找不到这个包了。

getters 派生状态 isLogin

一处判断,处处使用
先讲为什么「用户登录了吗」这个判断,头部导航要用、评论区要用、收藏按钮要用,可能散落在十几个文件里。如果每个地方都自己写 if (userStore.token),将来判断逻辑一变(比如还要检查 token 有没有过期),就得满项目找着改。
正确做法是把判断本身也当成一份「数据」,起个名字叫 isLogin,只写一次,谁要用谁读。这种「由别的状态算出来的状态」就是 getters。
动手做在 store 里定义 isLogin(用 computed),在组件里用它做条件渲染。
src/stores/user.js(getters 片段)js
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 }
})
页面效果浏览器运行结果
运行结果: store 初始状态(未登录): token → "" isLogin → false loginText → "未登录" 执行 token.value = "abc123" 之后(在组件里就是 userStore.login(...)): token → "abc123" isLogin → true ← 自动重算,没人手动通知它 loginText → "已登录"
src/App.vue(在组件里用 getters)vue
<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>
页面效果组件渲染结果
运行结果: 场景一:第一次打开网站(localStorage 是空的) store 初始化:token = ref(localStorage.getItem('token') || '') → "" isLogin → false 头部渲染:登录 / 注册 场景二:登录成功后按 F5 刷新页面 store 初始化:token = "eyJhbGciOiJIUzI1NiJ9.demo"(从 localStorage 回填) userInfo = { id: 3, username: "zhangsan", nickname: "张三" } isLogin → true 头部渲染:你好,张三 我的收藏 (登录态在刷新后自动恢复,用户完全无感)
讲透:刷新后恢复登录态的完整链路
  • 第一步(存):登录成功 → login() 改 token → watch / 插件把它写进 localStorage。
  • 第二步(读):页面刷新 → main.js 重新执行 → 创建 store 时读 localStorage.getItem('token') 作为 ref 的初始值。
  • 第三步(用):isLogin 立刻算成 true,头部渲染成已登录的样子,第 7 章的守卫也因为 userStore.token 有值而放行。
  • 为什么 getters 比手写判断好:判断逻辑只存在于 store 一处,新页面要用直接读 isLogin,永远不会出现「A 页面的判断和 B 页面不一致」这种问题。
  • 顺带一个心智模型:state 是「存了什么」,getters 是「从里面能推出什么」,actions 是「怎么改它」。三个词想清楚,Pinia 就学完了。
动手练习
1用组合式写法定义一个 userStore:state 有 token、userInfo;getters 有 isLogin;actions 有 login(data) 和 logout()。要求刷新页面后登录态不丢。
2在头部组件里用这个 store:登录时显示「你好,昵称」和「退出」按钮,未登录时显示「登录 / 注册」。注意 state 和 getters 要用 storeToRefs 解构,并说明为什么不能直接解构。
3迁移练习:「红影赏析」要做一个「我的观影清单」,需要跨页面共享。请设计一个 movieStore:state 存电影列表 watchList;getters 给出 count(清单里有几部)和 isInList(id);actions 提供 addMovie(movie) 和 removeMovie(id);数据也要持久化。想一想:这和 userStore 是不是同一套骨架?
参考答案
练习答案(src/stores/user.js)js
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 }
})
页面效果浏览器运行结果
运行结果: 未登录:token = "",isLogin = false,nickname = "游客" 登录后:token = "eyJ...",isLogin = true,nickname = "张三" 刷新页面:token 从 localStorage 回填,isLogin 仍然是 true
练习答案(第 2 题:AppHeader.vue)vue
<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>
页面效果组件渲染结果
运行结果: 未登录 → 页面显示:登录 / 注册 登录后 → 页面显示:你好,张三 [退出] 点「退出」→ 立刻回到「登录 / 注册」,无需刷新页面 为什么不能直接 const { isLogin } = userStore: 那只是把此刻的布尔值复制了一份,之后 token 变了它也不会跟着变, 页面永远停在下一次渲染前的旧值——响应性丢了。
练习答案(第 3 题:src/stores/movie.js)js
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 }
})
页面效果浏览器运行结果
运行结果: 初始状态:watchList = [],count = 0,isInList(8) = false 调用 addMovie({ id: 8, title: "流浪地球" }): watchList → [ { id: 8, title: "流浪地球" } ] count → 1 isInList(8) → true localStorage 里出现 watchList 这条记录 调用 removeMovie(8): watchList → [] count → 0

第 3 题的关键:换的只是「状态名和业务动作」,不变的是「state 存数据 + getters 算派生 + actions 改数据 + 持久化」这套骨架。能照着 userStore 写出 movieStore,说明你已经把 Pinia 学到手了。

本章小结