iT邦幫忙

2024 iThome 鐵人賽

DAY 19
0
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 19

Day 19: 在 Pinia 中管理 Vue 3 應用的全局狀態與本地存儲

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240924/20117461RofsD5dMXS.jpg

簡介

在現代 Vue 3 應用中,有效管理全局狀態和本地存儲是構建可靠且高性能應用的關鍵。本文將深入探討如何使用 Pinia 結合多種先進技術來實現全面的狀態管理解決方案。我們將涵蓋永久存儲、離線緩存、網絡狀態管理等主題,並整合 Zod、Vee-Validate、@vueuse/core 等工具,打造一個強大且靈活的狀態管理系統。

實作步驟

步驟 1: 設置基本的 Pinia Store

首先,我們創建一個基本的 Pinia store,使用 setup store 語法:

(如果不知道什麼是 definePrivateState 請看 Day11的觀念)

(檔案:src/stores/useAuthStore.ts)

import { computed } from 'vue'
import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { Nullable, UserSchema } from '../schemas/user.schema';
import { useStorage } from '@vueuse/core';

export interface UseAuthStorePrivateState {
}

export const useAuthStore = definePrivateState('useAuthStore', (): UseAuthStorePrivateState => {
  return {

  }
}, (privateState, router) => {
  const user = useStorage<Nullable<UserSchema>>('user', null, localStorage, {
    serializer: {
      read: JSON.parse,
      write: JSON.stringify
    }
  });

  // getters::
  const isLoggedIn = computed<boolean>(() => user.value !== null);
  // methods::

  const setUser = (currentUser: Nullable<UserSchema>): void => {
    if (currentUser === null) return;
    user.value = currentUser;
  };

  return {
    // getters::
    isLoggedIn,
    // methods::
    setUser
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot));
}

型別的部分:

(檔案: src/schemas/user.schema.ts)

import * as zod from 'zod';

export const userSchema = zod.object({
  id: zod.number(),
  name: zod.string(),
  email: zod.string().email(),
});

export type UserSchema = zod.infer<typeof userSchema>;

export type Nullable<T> = T | null;

步驟 2: 加入 簡單的狀態判斷更新時間

為了實現單向數據流,我們可以創建一個私有 store 來處理內部狀態:

import { computed } from 'vue'
import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { Nullable, UserSchema } from '../schemas/user.schema';
import { useStorage } from '@vueuse/core';

export interface UseAuthStorePrivateState {
  lastUpdated: Nullable<Date>;
}

export const useAuthStore = definePrivateState('useAuthStore', (): UseAuthStorePrivateState => {
  return {
    lastUpdated: null, // 這裡增加最後時間
  }
}, (privateState) => {
  const user = useStorage<Nullable<UserSchema>>('user', null, localStorage, {
    serializer: {
      read: JSON.parse,
      write: JSON.stringify
    }
  });

  // getters::
  const isLoggedIn = computed<boolean>(() => user.value !== null);
  // methods::

  const setUser = (currentUser: Nullable<UserSchema>): void => {
    if (currentUser === null) return;
    user.value = currentUser;
    privateState.lastUpdated = new Date(); // 確保每次更新狀態可以被偵測
  };

  return {
    // getters::
    isLoggedIn,
    // methods::
    setUser
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot));
}

步驟 3: 整合 IndexedDB

使用情境,即便 localStorage 可以應用大部分的狀況,但仍有些極端狀態用 indexedDB 較為合適

  • 容量大小: 一般來說 localStoraage 5-10MB 左右的大小,如果超過的話可能要用 indexDB 的狀況
  • 需要緩存圖檔: 這個需求比較少的緣故在於,大部分的狀況,我們在打 api 去取得資源即可,不過對性能有極致要求的人會把固定出現的圖檔做一些緩存好讓資料可以再利用,去減少流量的消耗

我們這裡用集成好的 indexedDB 解決方法,安裝 idb,為了使原本的 code 更為簡潔

bun add idb

接下來,我們將實現一個使用 IndexedDB 的 store:

(檔案: src/stores/useOfflineStore.ts)

import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { openDB } from 'idb';
import { Nullable, UserOffLineDB, UserOffLineSchema, UserSchema } from '../schemas/user.schema';

export interface useOfflineStorePrivateState {
  key: 'offLineData',
  userDB: Nullable<UserOffLineDB>;
}

export const useOfflineStore = definePrivateState('useOfflineStore', (): useOfflineStorePrivateState => {
  return {
    key: 'offLineData',
    userDB: null,
  }
}, privateState => {
  // getters::

  // methods::

  const initUserDB = async (): Promise<void> => {
    privateState.userDB = await openDB<UserOffLineSchema>('user-demo-db', 1, {
      upgrade(db) {
        db.createObjectStore(privateState.key)
      }
    })
  };

  const setOffLineUser = async (key: string, value: UserSchema): Promise<void> => {
    const db = privateState.userDB;
    if (!db) await initUserDB();
    if (db === null) throw new Error('some unexpected error');
    await db.put('offLineData', value, key);
  };

  const getOffLineUser = async (key: string): Promise<UserSchema | undefined> => {
    const db = privateState.userDB;
    if (!db) await initUserDB();
    if (db === null) throw new Error('some unexpected error');
    return await db.get('offLineData', key);
  };

  return {
    // methods::
    initUserDB,
    setOffLineUser,
    getOffLineUser
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useOfflineStore, import.meta.hot));
}

更新的型別

(檔案: src/schemas/user.schema.ts)

import { DBSchema, IDBPDatabase } from 'idb';

// 前面的部分一樣...

export interface OffLineDBSchema<T> extends DBSchema {
  offLineData: {
    key: string;
    value: T,
  }
}

export type UserOffLineSchema = OffLineDBSchema<UserSchema>;
export type UserOffLineDB = IDBPDatabase<UserOffLineSchema>;

步驟 4: 管理網絡狀態和離線數據

創建一個 store 來管理網絡狀態和離線數據:

備註: 如果對 loadingStatus 的狀態不了解的話,可以參考 Day 15的實作

(檔案: src/stores/useUserStore.ts)

import { watch } from 'vue';
import { acceptHMRUpdate } from 'pinia';
import { definePrivateState } from './privateState';
import { useOfflineStore } from './useOfflineStore';
import { useUserApi } from '../composables/useUserApi';
import { useApiFetch } from '../composables/useApiFetch';
import { useNetwork } from '@vueuse/core';
import { useAuthStore } from './useAuthStore';
import { useLoadingStore } from './useLoadingStore';
import { LoadingStatus } from '../schemas/user.schema';

export const useUserStore = definePrivateState('useUserStore', () => {
  return {
  }
}, () => {

  // composables::
  const { getUserApi } = useUserApi(useApiFetch);
  const { isOnline } = useNetwork();
  // stores::
  const { setOffLineUser, getOffLineUser } = useOfflineStore();
  const { isLoadingStatusExist, addLoadingStatus, removeLoadingStatus } = useLoadingStore();
  const { setUser } = useAuthStore();

  // methods::
  const getUserWhenOnline = async (): Promise<boolean> => {
    if (isLoadingStatusExist(LoadingStatus.GetUser)) return false;
    addLoadingStatus(LoadingStatus.GetUser);
    try {
      const { data, error } = await getUserApi();
      if (error.value) {
        throw new Error('failed to get user');
      }

      setUser(data.value);
      if (data.value) {
        // 保存數據到 IndexedDB 以供離線使用
        await setOffLineUser('demo-user', data.value);
      }
      return data.value !== null;
    } catch (error) {
      if (error instanceof Error) {
        console.error(error);
      }
      return false;
    } finally {
      removeLoadingStatus(LoadingStatus.GetUser);
    }
  };

  const getUserWhenOffLine = async (): Promise<void> => {
    const user = await getOffLineUser('demo-user');
    if (!user) return;
    setUser(user);
  };

  const triggerSetUser = async (): Promise<void> => {
    isOnline.value && await getUserWhenOnline();
    !isOnline.value && await getUserWhenOffLine(); // 如果離線,從 IndexedDB 獲取數據
  };

  watch(isOnline, (newValue) => {
    console.log(newValue ? 'Back online' : 'Gone offline')
    // 這裡可以添加在線狀態變化時的邏輯
  })

  return {
    // getters::
    isOnline,
    // methods::
    triggerSetUser
  }
});

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot));
}

步驟 5: 創建一個使用所有這些功能的組件

<script lang="ts" setup>
import { storeToRefs } from 'pinia';
import { useUserStore } from '../stores/useUserStore';
import { useAuthStore } from '../stores/useAuthStore';

const userStore = useUserStore();
const { triggerSetUser } = userStore;
const { isOnline } = storeToRefs(userStore);

const { user } = storeToRefs(useAuthStore());
</script>

<template>
  <div>
    <p>Network status: {{ isOnline ? 'Online' : 'Offline' }}</p>
    <button aria-label="fetch user data" @click="triggerSetUser" border-none px-3 py-2 rounded-md cursor-pointer box-border text="hover:white" bg="blue-400 hover:blue-800">Fetch User Data</button>
    <div v-if="user">
      <p>Name: {{ user.name }}</p>
      <p>Email: {{ user.email }}</p>
    </div>
  </div>
</template>

結論

我們探討了如何在 Pinia 中管理 Vue 3 應用的全局狀態和本地存儲。我們實現了:

  1. 使用 Pinia 的 setup store 和 @vueuse/core 的 useStorage 實現永久存儲。
  2. 通過私有 store 實現單向數據流。
  3. 使用 IndexedDB 進行本地數據存儲。
  4. 管理網絡狀態並根據連接狀態決定數據獲取策略。
  5. 整合 Zod 進行數據驗證。

這種全面的方法不僅提供了強大的狀態管理能力,還確保了應用在離線環境下的可用性。通過結合 Pinia、@vueuse/core、IndexedDB,我們創建了一個健壯的系統,能夠處理各種網絡情況和數據持久化需求。

在實際應用中,這種方法可以進一步擴展以處理更複雜的場景,如數據同步、衝突解決等。持續優化和改進這個系統將幫助您構建更可靠、更高效的 Vue 應用。


上一篇
Day 18: 使用 Vue Router 實現多級嵌套路由與導航守衛
下一篇
Day 20: 使用 TypeScript 與 UnoCSS 打造可重用的 UI 元件庫
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言