Quản lý trạng thái trong Vue 3: So sánh Vuex 4 và Pinia với TypeScript

Trong môi trường Vue 3 sử dụng Composition API, việc quản lý trạng thái toàn cục cần sự kết hợp giữa tính linh hoạt, khả năng mở rộng và hỗ trợ mạnh mẽ cho TypeScript. Mặc dù Vuex 4 vẫn tương thích, cộng đồng và đội ngũ Vue chính thức đã chuyển trọng tâm sang Pina — một thư viện trạng thái được thiết kế từ đầu cho Composition API.

1. Cấu trúc store cơ bản với Vuex 4

Để duy trì tính rõ ràng và an toàn kiểu trong dự án TypeScript, cấu trúc store nên phân tách thành các module có khai báo type rõ ràng:

// stores/modules/cart.ts
import { Module } from 'vuex';

interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

interface CartState {
  items: CartItem[];
  total: number;
}

const cartModule: Module<CartState, any> = {
  namespaced: true,
  state: (): CartState => ({
    items: [],
    total: 0
  }),
  mutations: {
    ADD_ITEM(state, item: CartItem) {
      const existing = state.items.find(i => i.id === item.id);
      if (existing) {
        existing.quantity += item.quantity;
      } else {
        state.items.push({ ...item });
      }
      state.total = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    }
  },
  getters: {
    itemCount: (state) => state.items.length,
    isEmpty: (state) => state.items.length === 0
  }
};

export default cartModule;

2. Khởi tạo store gốc với kiểm soát kiểu đầy đủ

// stores/index.ts
import { createStore, Store, ModuleTree } from 'vuex';
import cartModule from './modules/cart';

interface RootState {
  cart: ReturnType<typeof cartModule.state>;
}

const modules: ModuleTree<RootState> = {
  cart: cartModule
};

const store = createStore<RootState>({
  modules,
  strict: import.meta.env.DEV
});

export default store;

// Hàm tiện ích để đảm bảo kiểu khi dùng trong component
export function useTypedStore(): Store<RootState> {
  return store;
}

3. Sử dụng trong component với Composition API

Thay vì gọi trực tiếp useStore(), ta khai báo hàm hook chuyên biệt để tận dụng hệ thống kiểu:

// composables/useCart.ts
import { computed } from 'vue';
import { useTypedStore } from '@/stores';

export function useCart() {
  const store = useTypedStore();

  return {
    items: computed(() => store.state.cart.items),
    total: computed(() => store.state.cart.total),
    itemCount: computed(() => store.getters['cart/itemCount']),
    isEmpty: computed(() => store.getters['cart/isEmpty']),
    addItem: (item: Parameters<typeof store.commit>[1]) => 
      store.commit('cart/ADD_ITEM', item)
  };
}

Sử dụng trong component:

<script setup lang="ts">
import { useCart } from '@/composables/useCart';

const { items, total, addItem } = useCart();

const handleAdd = () => {
  addItem({ id: 'prod-001', quantity: 2, price: 29.99 });
};
</script>

4. Triển khai tương đương bằng Pinia

Pinia loại bỏ hoàn toàn khái niệm mutations và yêu cầu namespace thủ công. Mỗi store là một hàm độc lập với đầy đủ hỗ trợ kiểu:

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

interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

export const useCartStore = defineStore('cart', {
  state: (): { items: CartItem[]; total: number } => ({
    items: [],
    total: 0
  }),
  getters: {
    itemCount(): number {
      return this.items.length;
    },
    isEmpty(): boolean {
      return this.items.length === 0;
    }
  },
  actions: {
    addItem(item: CartItem): void {
      const found = this.items.find(i => i.id === item.id);
      if (found) {
        found.quantity += item.quantity;
      } else {
        this.items.push({ ...item });
      }
      this.total = this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    }
  }
});

Tích hợp trong component:

<script setup lang="ts">
import { useCartStore } from '@/stores/useCartStore';
import { storeToRefs } from 'pinia';

const cart = useCartStore();
const { items, total, itemCount } = storeToRefs(cart);

const addItem = () => cart.addItem({ id: 'prod-001', quantity: 2, price: 29.99 });
</script>

5. So sánh thực tế

  • Khai báo kiểu: Pinia suy luận tự động từ stategetters; Vuex đòi hỏi khai báo tường minh Module<T, R> và xử lý rootState qua type assertion.
  • Truy cập module: Pinia không cần prefix namespace như 'cart/ADD_ITEM'; mọi thao tác đều thông qua instance store.
  • Hỗ trợ Composition API: Pinia cung cấp storeToRefs() để giữ tính phản ứng mà không cần computed() bọc thủ công.
  • Mở rộng: Pinia hỗ trợ plugin, devtools tích hợp sẵn, và khả năng persist dễ dàng hơn nhờ kiến trúc đơn giản.

6. Khuyến nghị triển khai

Với dự án mới, nên chọn Pinia làm mặc định. Nếu đang nâng cấp từ Vuex 3, có thể áp dụng mô hình "dual store" tạm thời để di chuyển từng module — ví dụ: khởi tạo cả Vuex và Pinia song song, sau đó dần chuyển logic sang store mới.

Đối với các dự án hiện hữu yêu cầu Vuex, hãy áp dụng pattern wrapper như useCart() ở trên để giảm thiểu lặp lại kiểu và tăng khả năng bảo trì.

Thẻ: Vue3 pinia vuex4 typescript composition-api

Đăng vào ngày 10 tháng 7 lúc 04:01