跳至主要內容
Skip to content

Pinia 狀態管理 + TypeScript

Pinia 是 Vue 3 官方推薦的狀態管理工具,原生支援 TypeScript。本篇將介紹完整的類型定義方式。


一、 基本設定

1.1 安裝

bash
npm install pinia

1.2 初始化

typescript
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
app.use(createPinia());
app.mount('#app');

二、 Option Store 類型

2.1 基本定義

typescript
// stores/counter.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter',
  }),

  getters: {
    double: (state) => state.count * 2,
    formatted(): string {
      return `${this.name}: ${this.count}`;
    },
  },

  actions: {
    increment() {
      this.count++;
    },
    async fetchCount() {
      const response = await fetch('/api/count');
      this.count = await response.json();
    },
  },
});

2.2 明確類型

typescript
import { defineStore } from 'pinia';

interface CounterState {
  count: number;
  name: string;
}

export const useCounterStore = defineStore('counter', {
  state: (): CounterState => ({
    count: 0,
    name: 'Counter',
  }),

  getters: {
    double(): number {
      return this.count * 2;
    },
  },

  actions: {
    increment(): void {
      this.count++;
    },
  },
});

三、 Setup Store 類型(推薦)

3.1 基本定義

typescript
// stores/counter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0);
  const name = ref('Counter');

  // getters
  const double = computed(() => count.value * 2);
  const formatted = computed(() => `${name.value}: ${count.value}`);

  // actions
  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  async function fetchCount() {
    const response = await fetch('/api/count');
    count.value = await response.json();
  }

  return {
    count,
    name,
    double,
    formatted,
    increment,
    decrement,
    fetchCount,
  };
});

3.2 複雜類型

typescript
import { defineStore } from 'pinia';
import { ref, computed, type Ref } from 'vue';

interface User {
  id: number;
  name: string;
  email: string;
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null);
  const loading = ref(false);
  const error = ref<Error | null>(null);

  const isLoggedIn = computed(() => user.value !== null);
  const userName = computed(() => user.value?.name ?? 'Guest');

  async function login(email: string, password: string): Promise<boolean> {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      user.value = await response.json();
      return true;
    } catch (e) {
      error.value = e as Error;
      return false;
    } finally {
      loading.value = false;
    }
  }

  function logout() {
    user.value = null;
  }

  return {
    user,
    loading,
    error,
    isLoggedIn,
    userName,
    login,
    logout,
  };
});

四、 使用 Store

4.1 在元件中使用

vue
<script setup lang="ts">
import { useCounterStore } from "@/stores/counter";

const counterStore = useCounterStore();

// 直接存取
console.log(counterStore.count);
console.log(counterStore.double);

// 呼叫 action
counterStore.increment();
</script>

<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.double }}</p>
    <button @click="counterStore.increment">+1</button>
  </div>
</template>

4.2 解構並保持響應性

vue
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCounterStore } from "@/stores/counter";

const counterStore = useCounterStore();

// 使用 storeToRefs 解構 state 和 getters
const { count, double } = storeToRefs(counterStore);

// actions 可以直接解構
const { increment, decrement } = counterStore;
</script>

五、 Store 之間的互動

5.1 在 Store 中使用其他 Store

typescript
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useUserStore } from './user';

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([]);

  const userStore = useUserStore();

  const total = computed(() => {
    const subtotal = items.value.reduce((sum, item) => sum + item.price, 0);
    // 根據使用者等級計算折扣
    const discount = userStore.isVip ? 0.1 : 0;
    return subtotal * (1 - discount);
  });

  return { items, total };
});

六、 Plugin 類型

6.1 定義 Plugin

typescript
import { type PiniaPluginContext } from 'pinia';

function myPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation, state) => {
    console.log(`[${mutation.storeId}] ${mutation.type}`);
  });

  // 添加屬性
  store.$state.pluginData = 'added by plugin';
}

// 使用
const pinia = createPinia();
pinia.use(myPlugin);

6.2 擴展 Store 類型

typescript
import 'pinia';

declare module 'pinia' {
  export interface PiniaCustomProperties {
    customProperty: string;
    customMethod(): void;
  }

  export interface PiniaCustomStateProperties<S> {
    pluginData: string;
  }
}

七、 持久化

7.1 使用 pinia-plugin-persistedstate

bash
npm install pinia-plugin-persistedstate
typescript
// main.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
typescript
// stores/user.ts
export const useUserStore = defineStore(
  'user',
  () => {
    const token = ref<string | null>(null);
    const user = ref<User | null>(null);

    return { token, user };
  },
  {
    persist: true,
  }
);

7.2 自訂持久化

typescript
export const useUserStore = defineStore(
  'user',
  () => {
    // ...
  },
  {
    persist: {
      key: 'user-store',
      storage: localStorage,
      pick: ['token'], // 只持久化 token
    },
  }
);

八、 實用範例

8.1 購物車 Store

typescript
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface Product {
  id: number;
  name: string;
  price: number;
}

interface CartItem extends Product {
  quantity: number;
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([]);

  const count = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  );

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

  function addItem(product: Product) {
    const existing = items.value.find((item) => item.id === product.id);
    if (existing) {
      existing.quantity++;
    } else {
      items.value.push({ ...product, quantity: 1 });
    }
  }

  function removeItem(productId: number) {
    const index = items.value.findIndex((item) => item.id === productId);
    if (index > -1) {
      items.value.splice(index, 1);
    }
  }

  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find((item) => item.id === productId);
    if (item) {
      item.quantity = Math.max(0, quantity);
      if (item.quantity === 0) {
        removeItem(productId);
      }
    }
  }

  function clear() {
    items.value = [];
  }

  return {
    items,
    count,
    total,
    addItem,
    removeItem,
    updateQuantity,
    clear,
  };
});

總結

方式特點推薦
Option Store類似 Options API熟悉 Vuex
Setup Store類似 Composition API推薦使用
API用途
defineStore()定義 Store
storeToRefs()解構並保持響應性
store.$subscribe()監聽狀態變化
store.$reset()重置狀態

> **推薦做法**:

  • 使用 Setup Store 語法
  • 複雜類型使用 interface
  • 善用 storeToRefs 解構

進階挑戰

  1. 實作一個 TODO Store,支援增刪改查
  2. 建立一個身份驗證 Store,處理 JWT Token

延伸閱讀與資源