Skip to content

持久化:讓狀態活過重新整理

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

重新整理為什麼會遺失狀態

Pinia 的 state 預設存在 JavaScript 記憶體裡。使用者登入後按重新整理,應用程式會重新建立 Pinia、store 和 router,原本的 isLoggedIn 又回到初始值;購物車也會遇到同樣問題。這正是第六章導航守衛結尾留下的問題:守衛規則正確,但它沒有記住上一次的登入結果。

持久化的責任可以拆成兩步:建立 store 時先從 localStorage 還原,state 改變時再寫回。「寫回」用的正是第二章的 watch:狀態一變就同步到外部系統,這是 watch 最典型的副作用場景;而副作用要有清楚的擁有者和邊界,則延續第五章的副作用心智,這裡的擁有者就是 store 自己。localStorage 只能保存字串,所以讀取要 JSON.parse(),寫入要 JSON.stringify();瀏覽器可能因為無痕模式、容量限制或資料損壞而拋錯,教學範例用 try/catch 把這個邊界包起來。

手寫 localStorage 持久化

auth 和 cart 使用不同的 key,避免登出時清掉不屬於登入狀態的購物車。核心規格可以先濃縮成下面這段:

js
watch([isLoggedIn, user], () => {
  if (isLoggedIn.value === false) {
    localStorage.removeItem(authKey)
    return
  }

  localStorage.setItem(authKey, JSON.stringify({
    isLoggedIn: isLoggedIn.value,
    user: user.value,
  }))
}, { deep: true })

這裡採用 deep: true,因為 user 是物件,物件內的欄位改變也屬於要同步的 state;沒有指定 flush,使用 Vue watcher 的預設排程。登入時 watcher 寫入 auth key,isLoggedIn === false 時則執行 localStorage.removeItem(authKey),不把 { isLoggedIn: false } 之類的未登入物件寫回。logout() 只翻轉 store 狀態,不直接碰 localStorage,清除工作統一由 watcher 承擔。

cart 的 watcher 只管理 cart key。它不讀取 auth 狀態,也不會在 logout 時清除,因此訪客在登入前加入的商品仍然留在購物車裡。每次狀態變更後,測試或需要立刻讀取 storage 的程式都要先 await nextTick(),等預設 watcher 完成排程。

選擇該保存的資料

不要把整個 store 無條件序列化。登入狀態只保存重新載入畫面所需要的最小資料,購物車保存商品 id、名稱、價格和數量;暫時性錯誤訊息、載入中的旗標和可重新取得的 API 結果通常不值得保存。真正的 token 仍應依照後端的安全策略處理,放在可被 JavaScript 讀取的 storage 會增加 XSS 風險,較完整的 cookie、CSRF 與登入安全議題留到第十二章。

如果專案需要跨分頁同步、版本遷移或更完整的序列化策略,可以考慮 pinia-plugin-persistedstate 這類 plugin;本章先用手寫 watcher 讓 key、保存範圍和登出行為都清楚可見。

常見錯誤

錯誤寫法是把整個 store 直接 JSON.stringify(store),再在登出時清掉所有 storage。這會把不該保存的暫時資料一起寫入,也會誤刪訪客購物車。正確寫法是為 auth 和 cart 各自指定 key,只保存明確列出的欄位;登出只讓 auth watcher 移除 auth key,cart key 保持不變。

完整書店範例

下面是本章唯一一份完整可跑的書店 canonical。路由、兩個 store、入口、導覽列和所有 view 都從這組檔案組裝;routes.js 的 view 全部使用動態 import,方便直接放進 Vite + Vue 專案。後續的 fixture build 和 Pinia smoke test 也以這一組標籤為唯一來源。

完整可跑範例:Pinia、Vue Router 與 localStorage

src/stores/auth.js

js
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'

const authKey = 'bookstore-auth'

function readStorage(key, fallback) {
  try {
    const raw = localStorage.getItem(key)
    return raw ? JSON.parse(raw) : fallback
  } catch {
    return fallback
  }
}

function writeStorage(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value))
  } catch {
    // storage 不可用時,記憶體內的 state 仍然可以使用
  }
}

export const useAuthStore = defineStore('auth', () => {
  const saved = readStorage(authKey, {})
  const isLoggedIn = ref(saved.isLoggedIn === true)
  const user = ref(isLoggedIn.value ? saved.user ?? null : null)

  watch([isLoggedIn, user], () => {
    if (isLoggedIn.value === false) {
      localStorage.removeItem(authKey)
      return
    }

    writeStorage(authKey, {
      isLoggedIn: true,
      user: user.value,
    })
  }, { deep: true })

  async function login() {
    await Promise.resolve()
    user.value = { id: 1, name: '示範讀者' }
    isLoggedIn.value = true
  }

  function logout() {
    user.value = null
    isLoggedIn.value = false
  }

  async function canAccess() {
    await Promise.resolve()
    return true
  }

  return { isLoggedIn, user, login, logout, canAccess }
})

src/stores/cart.js

js
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'

const cartKey = 'bookstore-cart'

function readStorage(key, fallback) {
  try {
    const raw = localStorage.getItem(key)
    return raw ? JSON.parse(raw) : fallback
  } catch {
    return fallback
  }
}

function writeStorage(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value))
  } catch {
    // storage 不可用時,記憶體內的 state 仍然可以使用
  }
}

export const useCartStore = defineStore('cart', () => {
  const items = ref(readStorage(cartKey, []))

  watch(items, (value) => {
    writeStorage(cartKey, value)
  }, { deep: true })

  const itemCount = computed(() => {
    return items.value.reduce((count, item) => count + item.quantity, 0)
  })

  const total = computed(() => {
    return items.value.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    )
  })

  function addItem(book) {
    const existing = items.value.find((item) => item.id === book.id)

    if (existing) {
      existing.quantity++
      return
    }

    items.value.push({ ...book, quantity: 1 })
  }

  function removeItem(bookId) {
    const index = items.value.findIndex((item) => item.id === bookId)

    if (index !== -1) items.value.splice(index, 1)
  }

  return { items, itemCount, total, addItem, removeItem }
})

src/router/routes.js

js
export const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('../views/BookListView.vue'),
  },
  {
    path: '/books/:id',
    name: 'book',
    component: () => import('../views/BookDetailView.vue'),
    props: true,
    children: [
      {
        path: '',
        name: 'book-intro',
        component: () => import('../views/BookIntroView.vue'),
      },
      {
        path: 'reviews',
        name: 'book-reviews',
        component: () => import('../views/BookReviewsView.vue'),
      },
      {
        path: 'edit',
        name: 'book-edit',
        component: () => import('../views/BookEditView.vue'),
        props: true,
        meta: { requiresAuth: true },
      },
    ],
  },
  {
    path: '/cart',
    name: 'cart',
    component: () => import('../views/CartView.vue'),
  },
  {
    path: '/orders',
    name: 'orders',
    component: () => import('../views/OrdersView.vue'),
    meta: { requiresAuth: true },
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('../views/LoginView.vue'),
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('../views/NotFoundView.vue'),
    props: (route) => ({
      missingPath: route.params.pathMatch.join('/'),
    }),
  },
]

src/router/guards.js

js
import { useAuthStore } from '../stores/auth.js'

export function installGuards(router) {
  router.beforeEach(async (to) => {
    if (to.name === 'login' || !to.meta.requiresAuth) return

    const auth = useAuthStore()

    if (!auth.isLoggedIn) {
      return {
        name: 'login',
        query: { redirect: to.fullPath },
      }
    }

    const allowed = await auth.canAccess(to)

    if (!allowed) return false
  })
}

src/router/index.js

js
import { createRouter, createWebHistory } from 'vue-router'
import { installGuards } from './guards.js'
import { routes } from './routes.js'

const router = createRouter({
  history: createWebHistory(),
  routes,
})

installGuards(router)

export default router

src/main.js

js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.js'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(router)
app.mount('#app')

src/App.vue

vue
<script setup>
import { storeToRefs } from 'pinia'
import { useAuthStore } from './stores/auth.js'
import { useCartStore } from './stores/cart.js'

const auth = useAuthStore()
const cart = useCartStore()
const { isLoggedIn, user } = storeToRefs(auth)
const { itemCount } = storeToRefs(cart)
const { logout } = auth
</script>

<template>
  <div class="app-shell">
    <header class="site-header">
      <nav class="site-nav" aria-label="主要導覽">
        <RouterLink :to="{ name: 'home' }">書籍</RouterLink>
        <RouterLink :to="{ name: 'cart' }">
          購物車({{ itemCount }})
        </RouterLink>
        <RouterLink :to="{ name: 'orders' }">我的訂單</RouterLink>
        <RouterLink v-if="!isLoggedIn" :to="{ name: 'login' }">
          登入
        </RouterLink>
        <button v-else type="button" @click="logout">
          登出 {{ user?.name }}
        </button>
      </nav>
    </header>

    <main class="page">
      <RouterView />
    </main>
  </div>
</template>

<style scoped>
.app-shell {
  max-width: 48rem;
  margin-inline: auto;
  padding: 1.5rem;
  font-family: system-ui, sans-serif;
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

.page {
  padding-block: 1.5rem;
}
</style>

src/views/BookListView.vue

vue
<script setup>
const books = [
  { id: 1, title: '重新認識 Vue.js', price: 680 },
  { id: 2, title: '0 陷阱 JavaScript', price: 620 },
]
</script>

<template>
  <section>
    <h1>書籍列表</h1>
    <ul>
      <li v-for="book in books" :key="book.id">
        <RouterLink :to="{ name: 'book', params: { id: book.id } }">
          {{ book.title }}
        </RouterLink>
      </li>
    </ul>
  </section>
</template>

src/views/BookDetailView.vue

vue
<script setup>
import { computed } from 'vue'
import { useCartStore } from '../stores/cart.js'

const props = defineProps({
  id: { type: String, required: true },
})

const cart = useCartStore()
const book = computed(() => ({
  id: Number(props.id),
  title: `書籍 ${props.id}`,
  price: 680,
}))

function addToCart() {
  cart.addItem(book.value)
}
</script>

<template>
  <article>
    <h1>{{ book.title }}</h1>
    <p>價格:{{ book.price }} 元</p>
    <button type="button" @click="addToCart">加入購物車</button>
    <nav aria-label="書籍詳情分頁">
      <RouterLink :to="{ name: 'book-intro', params: { id } }">簡介</RouterLink>
      <RouterLink :to="{ name: 'book-reviews', params: { id } }">評論</RouterLink>
      <RouterLink :to="{ name: 'book-edit', params: { id } }">編輯</RouterLink>
    </nav>
    <RouterView />
  </article>
</template>

src/views/BookIntroView.vue

vue
<template>
  <section>
    <h2>內容簡介</h2>
    <p>從元件心智走到完整的現代 Vue 應用。</p>
  </section>
</template>

src/views/BookReviewsView.vue

vue
<template>
  <section>
    <h2>讀者評論</h2>
    <p>這裡還沒有評論。</p>
  </section>
</template>

src/views/BookEditView.vue

vue
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

defineProps({
  id: { type: String, required: true },
})

const draft = ref('')
const isDirty = ref(false)

function save() {
  isDirty.value = false
}

onBeforeRouteLeave(() => {
  if (!isDirty.value) return
  return window.confirm('內容尚未儲存,確定要離開嗎?')
})
</script>

<template>
  <section>
    <h2>編輯書籍 {{ id }}</h2>
    <textarea v-model="draft" rows="5" @input="isDirty = true"></textarea>
    <button type="button" @click="save">儲存</button>
  </section>
</template>

src/views/LoginView.vue

vue
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'

const route = useRoute()
const router = useRouter()
const auth = useAuthStore()

async function login() {
  await auth.login()

  const requested = route.query.redirect
  const target = typeof requested === 'string' && requested.startsWith('/')
    ? requested
    : '/orders'

  await router.replace(target)
}
</script>

<template>
  <section>
    <h1>會員登入</h1>
    <p>教學範例省略帳密欄位,按下按鈕即可切換登入狀態。</p>
    <button type="button" @click="login">模擬登入</button>
  </section>
</template>

src/views/OrdersView.vue

vue
<template>
  <section>
    <h1>我的訂單</h1>
    <p>這是受登入狀態保護的會員頁。</p>
  </section>
</template>

src/views/CartView.vue

vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart.js'

const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { removeItem } = cart
</script>

<template>
  <section>
    <h1>購物車</h1>
    <p v-if="items.length === 0">目前沒有商品。</p>
    <ul v-else>
      <li v-for="item in items" :key="item.id">
        {{ item.title }},{{ item.quantity }} 本
        <button type="button" @click="removeItem(item.id)">移除</button>
      </li>
    </ul>
    <p>總計:{{ total }} 元</p>
  </section>
</template>

src/views/NotFoundView.vue

vue
<script setup>
defineProps({
  missingPath: { type: String, default: '' },
})
</script>

<template>
  <section>
    <h1>找不到頁面</h1>
    <p v-if="missingPath">沒有符合 /{{ missingPath }} 的內容。</p>
    <RouterLink :to="{ name: 'home' }">回書籍列表</RouterLink>
  </section>
</template>

把這組檔案放進 Vite + Vue 專案後,可以依序操作:加入商品並重新整理,購物車仍在;未登入進入 /orders 會導向 /login?redirect=/orders;按下登入後回到原目標;登出後重新整理維持未登入,但 cart key 仍然保留。

本章小結

Pinia 的主線到這裡完成。簡單記:

  • 狀態先判斷範圍:元件自己的邏輯用 composable,跨頁面共享的資料才交給 store。
  • Setup Store 是被 Pinia 接管的單例 composable:ref 是 state、computed 是 getter、函式是 action,公開的內容要 return。
  • 元件解構 state 和 getter 要用 storeToRefs();action 可以直接解構,修改規則集中在 action。
  • 守衛裡的 useAuthStore() 要在 beforeEach 回呼內呼叫;redirect 契約與第六章逐項相同。
  • auth 與 cart 各用一個 localStorage key;清除由 auth watcher 承擔,登出不影響訪客購物車。

下一節是本章的可選單元:把建 store 這件事交給 AI 動手,我們負責把關。跳過它不影響主線;想看看實際流程,就翻到AI 協作:讓 AI 建 store