在 Day 21: 來發 API 吧!Async Composition API setup() 中,是在元件準備渲染時,才在 setup 內發 API 取得渲染需要的資料,通常會有兩種作法,但無論選擇哪個,使用者都需要經過「從『沒有資料可以渲染』到『渲染完成』」這個或長或短的過渡期。
而在 Day 23: 來發 API 吧!Lifecycle Hooks and Navigation Guards 你要哪一個? 中提到,可以在進入路由頁面前,先發 API 取得渲染需要的資料,再進入元件的生命週期,但這個方式要搭配 Pinia 一起使用。
今天會寫講解 Pinia 的基礎語法,最後用 f2e 旅館預約頁面示範:使用 Pinia + Router Guard,在進入路由前先發 API,將頁面需要的狀態準備好,如果已經熟悉 Pinia 的人可以直接跳到最後一段。
如果是透過 npm init vue@latest 指令安裝,在 main.js 引用 Pinia 的部份已經都寫好了。
自行安裝
npm install pinia
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
因為 Pinia 在使用上實在很直觀(相較於他的哥哥 Vuex),所以這裡直接先上範例,讓大家大致理解他的用法,再來ㄧㄧ介紹 store、state、getters、actions。
import { defineStore } from "pinia";
export const useCounterStore = defineStore('counter', {
  //定義狀態初始值
  state: () => ({ count: 0, name: 'Eduardo' }),
  //對狀態加工的 getters,如同 computed
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  //定義使用到的函式,可以為同步和非同步,如同 method
  actions: {
    increment() {
      this.count++
    },
  },
})
在元件引入並呼叫 useCounterStore(),就可以在 script 和 template 內取得 store 裡的狀態跟方法等等。
//inside <script setup>
import { useCounterStore } from "@/stores/counter.js";
const counterStore = useCounterStore();
<template>
  <div>
    <h2>counter</h2>
    <!-- 取得的 state 會響應式更新 -->
    <p>{{ counterStore.count }}</p>
    
    <!-- 可以呼叫 store 內定義的方法來修改 state -->
    <button @click="counterStore.increment">+</button>
    <!-- 也可以直接修改 state -->
    <button @click="counterStore.count++">+</button>
  </div>
<template>
在 Pinia 可以將狀態分成不同的 store 進行管理,例如:飯店有住宿預約 API 和餐飲預約 API,就可以分成兩個 store 管理,store 會個別定義在不同 .js 檔中;不過,store 之間還是可以取得彼此的 state、getter 或 action。
Store 有分成 Option Stores 和 Setup Stores 兩種寫法,前者是 Option API 的風格,後者是 Composition API 的風格,Option Stores 比較好上手,這篇的範例都是使用 Option Stores。
Syntax
defineStore('name/id', {
	//other options
})
使用範例
文件上建議 defineStore 所回傳的 function,用 use... 作為開頭,例如下面範例:
export const useHotelStore = defineStore("hotel", {
  state: () => ({
      //定義 state 初始值
  }),
  getters: {
      //定義 getters
  },
  actions: {
      //定義 actions
  },
});
store 的實體要到 useStore() 被呼叫的時候才會建立,實體化之後,就可以存取定義在 state、getters 和 actions 中的屬性。
store 是一個由 reactive 返回的響應式物件(即 Proxy),所以不可以直接解構,會失去響應性。
如果想解構使用,不想每次都用 store.stateName 去取 state,需要將 store 實例傳入 storeToRefs(storeInstance) 再來解構。
import { useCounterStore } from "@/stores/counter.js";
import { storeToRefs } from "pinia";
const counterStore = useCounterStore();
// state 和 getters 直接解構會失去響應性,所以需要從 storeToRefs(store) 解構
const { count, name, doubleCount } = storeToRefs(counterStore);
// action 可以直接解構,從 storeToRefs(store) 解構反而會失去響應性
const { increment } = counterStore;
補充
為什麼丟進 storeToRefs 後就可以解構,這其實是 Vue 3 toRef() 和 toRefs() API 做的事情,好奇的話可以再看這兩個 API。
每個 Store 主要分成三個部份:state、getters、actions。
import { defineStore } from "pinia";
export const useHotelStore = defineStore("hotel", {
  state: () => ({
    rooms: [],
    room: {},
  }),
});
修改單一 state:直接修改
const counterStore = useCounterStore();
counterStore.count++;
一次修改單個或多個 state:storeInstance.$patch()
根據傳進去的參數不同,分成兩個寫法。
a. 傳入物件,key 為 state 名稱、value 為更新的值
store.$patch({stateName1: newValue}, {stateName2: newValue})
const counterStore = useCounterStore();
counterStore.$patch({ count: 100 })
counterStore.$patch({ count: 100, name: 'Angela' })
b. 傳入回呼函式,函式可以取得 store 內的 state 進行操作
store.$patch((state) => {
    //直接操作 state
}
傳入回呼函式的好處在於,當 state 裡面為陣列,想直接對陣列做順序的操作,而不是重新賦值一個新的陣列時。
store.$patch((state) => {
    state.arr.reverse();
}
是否一定要用 $patch? btw 這位是 Pinia 的主要開發者~
將 state 初始化:storeInstance.$reset
會將 store 內的狀態全部初始化,回到預設的初始值
const counterStore = useCounterStore();
counterStore.$reset();
Syntax
storeInstance.$subscribe((mutation, state) => { ... })
.$patch 一次修改多個 state,只會觸發一次$subscribe),這個監聽器會綁定元件實例,在元件銷毀時一起銷毀裡面的 callback function 可以拿到兩個參數,分別是
store.count++
$patch() 傳入物件來改動$patch() 傳入函式來改動$patch() 內的參數,如果是直接改動 state 則為 undefined
文件範例:
counterStore.$subscribe((mutation, state) => {
  mutation.type;  
  mutation.storeId;
  mutation.payload; 
  //可以在這裡將整個 state 存到 localStorage
  localStorage.setItem("cart", JSON.stringify(state));
});
基本上,getter 可以想成是 Vue 的 computed,特性十分相似
this 取到整個 Store 的實例,但記得不可以使用箭頭函式
export const useStore = defineStore('hotel', {
  state: () => ({
    rooms: [],
  }),
  getters: {
    doubleCount: (state) => state.counter * 2,
    singleRooms: (state) =>
      state.rooms.filter((room) => room.name.includes("Single")),
    //也可以取得其他 getter 的結果來加工
    //記得用到 this 就不能使用箭頭函式
    deluxeSingleRooms() {
      return this.singleRooms.filter((room) => room.name.includes("Deluxe"));
    },
    
})
在模板中取用
<template>
  <div>
    <p v-for="room in hotelStore.singleRooms" :key="room.id">{{ room.name }}</p>
  </div>
</template>
export const useStore = defineStore('hotel', {
  state: () => ({
    rooms: [],
  }),
  getters: {
    getRoomById: (state) => {
      return (roomId) => state.rooms.find((room) => room.id === roomId);
    },
  },
})
基本上,actions 可以想成是 Vue 的 method
this 取得整個 store 實例,但就不得使用箭頭函式await
以下是根據之前封裝好的方法,來接六角 F2E 的旅館 API:
這裡只示範 GET 所有房型&取得單一房型資訊的 actions,在呼叫後會將 response 存到 state 中,這樣所有元件都可以取到最新的共用狀態
export const useHotelStore = defineStore("hotel", {
  state: () => ({
    rooms: [],
    room: {},
  }),
  actions: {
    async getRooms() {
      try {
        const { success, items: rooms } = await API.GET("rooms");
        if (success) {
          this.rooms = rooms;
        } else {
          console.warn("getRooms fail");
        }
      } catch (error) {
        console.log(error);
      }
    },
    async getRoom(roomID) {
      try {
        const { room } = await API.GET(`rooms/${roomID}`);
        this.room = room[0];
      } catch (error) {
        console.log(error);
      }
    },
  },
});
要在 store 的 getter 或 action 取得其他的 store 做處理很簡單,只需要引入 store,並在函式內呼叫建立實例,就可以取得其他 store 定義的內容。
import { useOtherStore } from './other-store';
import { useAuthStore } from './auth-store';
export const useStore = defineStore('main', {
  state: () => ({
    // ...
  }),
  getters: {
    otherGetter(state) {
      const otherStore = useOtherStore()
      return state.localData + otherStore.data
    },
  },
  actions: {
    async fetchUserPreferences() {
      const auth = useAuthStore()
      if (auth.isAuthenticated) {
        this.preferences = await fetchPreferences()
      } else {
        throw new Error('User must be authenticated')
      }
    },
  },
})
接下來會透過 Pinia + Router Guard,在進入路由前,準備好頁面需要的 state。
比起準備開始渲染頁面/元件(進入元件的生命週期)時才發 API,這個方式最大的優點是:可以避免使用者看到準備資料的過渡期。
先在 route 內,針對 Rooms 頁面定義 Route Guard
/Rooms 之前,所以選擇 beforeEnter 這個 guard,可以針對個別 route 定義,需要寫在 /Rooms 的 route 下。await,等到非同步處理完成後再跳轉。import { createRouter, createWebHistory } from "vue-router";
import hotelAPI from "@/api/service";
import { useHotelStore } from "@/stores/hotel.js";
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    //略
    {
      path: "/rooms",
      name: "rooms",
      component: () => import("../views/RoomsView.vue"),
      async beforeEnter(to, from) {
        const hotelStore = useHotelStore();
        await hotelStore.getRooms();
      },
    },
  ],
});
export default router;
在頁面檔案內,可以直接從 store 取得更新的狀態
import { useHotelStore } from "@/stores/hotel.js";
const hotelStore = useHotelStore();
可以在 template 內直接取用 hotelStore 下的狀態,不需要特別在 script 取出並重新命名。
<template>
  <div class="wrapper">
    <h2>Rooms View</h2>
    <router-link
      :to="`/room/${room.id}`"
      v-for="room in hotelStore.rooms"
      :key="room.id"
    >
      <RoomCard :room="room" class="room" />
    </router-link>
  </div>
</template>