在 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>