在 Vue 3 中管理共享狀態
已發表: 2022-03-10狀態可能很難。 當我們開始一個簡單的 Vue 項目時,將我們的工作狀態保持在特定組件上可能很簡單:
setup() { let books: Work[] = reactive([]); onMounted(async () => { // Call the API const response = await bookService.getScienceBooks(); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } }); return { books }; },
當您的項目是顯示數據的單頁時(可能要對其進行排序或過濾),這可能會很有吸引力。 但在這種情況下,該組件將獲取每個請求的數據。 如果你想保留它怎麼辦? 這就是狀態管理髮揮作用的地方。 由於網絡連接通常很昂貴並且有時不可靠,因此在瀏覽應用程序時最好保持這種狀態。
另一個問題是組件之間的通信。 雖然您可以使用事件和道具與直接的孩子父母溝通,但當您的每個視圖/頁面都是獨立的時,處理錯誤處理和繁忙標誌等簡單情況可能會很困難。 例如,假設您連接了一個頂級控件以顯示錯誤和加載動畫:
// App.vue <template> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert" v-if="error">{{ error }}</div> <div class="alert bg-gray-200 text-gray-900" v-if="isBusy"> Loading... </div> <router-view :key="$route.fullPath"></router-view> </div> </template>
如果沒有有效的方法來處理這種狀態,它可能會建議使用發布/訂閱系統,但實際上在許多情況下共享數據更直接。 如果想要共享狀態,你怎麼做? 讓我們看看一些常見的方法來做到這一點。
注意:您可以在 GitHub 上示例項目的“主”分支中找到本節的代碼。
Vue 3 中的共享狀態
自從遷移到 Vue 3 後,我已經完全遷移到使用 Composition API。 對於這篇文章,我也在使用 TypeScript,儘管對於我向您展示的示例來說這不是必需的。 雖然您可以以任何您想要的方式共享狀態,但我將向您展示幾種我發現最常用模式的技術。 每個都有自己的優點和缺點,所以不要把我在這裡談論的任何東西當作教條。
這些技術包括:
- 工廠,
- 共享單例,
- Vuex 4,
- Vuex 5。
注意:在撰寫本文時,Vuex 5 處於 RFC(徵求意見)階段,所以我想讓您為 Vuex 的發展方向做好準備,但目前還沒有此選項的工作版本。
讓我們深入研究……
工廠
注意:本節的代碼位於 GitHub 上示例項目的“工廠”分支中。
工廠模式只是創建您關心的狀態的實例。 在此模式中,您返回的函數與 Composition API 中的start函數非常相似。 您將創建一個範圍並構建您正在尋找的組件。 例如:
export default function () { const books: Work[] = reactive([]); async function loadBooks(val: string) { const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books }; }
您可以只要求您需要的工廠創建對象的部分,如下所示:
// In Home.vue const { books, loadBooks } = BookFactory();
如果我們添加一個isBusy
標誌來顯示網絡請求何時發生,上面的代碼不會改變,但您可以決定在哪裡顯示isBusy
:
export default function () { const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books, isBusy }; }
在另一個視圖(vue?)中,您可以只要求 isBusy 標誌,而不必知道工廠的其餘部分是如何工作的:
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
但是您可能已經註意到一個問題; 每次調用工廠時,我們都會得到所有對象的新實例。 有時您希望工廠返回新實例,但在我們的例子中,我們談論的是共享狀態,因此我們需要將創建移到工廠之外:
const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } export default function () { return { loadBooks, books, isBusy }; }
現在工廠為我們提供了一個共享實例,或者如果您願意,可以提供一個單例。 雖然這種模式有效,但返回一個並非每次都創建新實例的函數可能會令人困惑。
因為底層對像被標記為const
你不應該能夠替換它們(並打破單例性質)。 所以這段代碼應該抱怨:
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
因此,確保可以更新可變狀態(例如,使用books.splice()
而不是分配書籍)可能很重要。
處理此問題的另一種方法是使用共享實例。
共享實例
本節的代碼位於 GitHub 上示例項目的“SharedState”分支中。
如果您要共享狀態,不妨清楚狀態是單例的事實。 在這種情況下,它可以作為靜態對象導入。 例如,我喜歡創建一個可以作為反應對象導入的對象:
export default reactive({ books: new Array<Work>(), isBusy: false, async loadBooks() { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books.splice(0, this.books.length, ...response.data.works); } this.isBusy = false; } });
在這種情況下,您只需導入對象(在此示例中我將其稱為商店):
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
然後很容易綁定到狀態:
<!-- Home.vue --> <div class="grid grid-cols-4"> <div v-for="book in state.books" :key="book.key" class="border bg-white border-grey-500 m-1 p-1" > <router-link :to="{ name: 'book', params: { id: book.key } }"> <BookInfo :book="book" /> </router-link> </div>
與其他模式一樣,您可以獲得可以在視圖之間共享此實例的好處:
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
然後這可以綁定到同一個對象(無論是Home.vue
的父級還是路由器中的另一個頁面):
<!-- App.vue --> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert bg-gray-200 text-gray-900" v-if="state.isBusy">Loading...</div> <router-view :key="$route.fullPath"></router-view> </div>
無論您使用工廠模式還是共享實例,它們都有一個共同的問題:可變狀態。 當您不希望它們發生綁定或代碼更改狀態時,您可能會遇到意外的副作用。 在像我在這裡使用的一個簡單的例子中,它還不夠複雜,不需要擔心。 但是隨著您構建越來越大的應用程序,您將需要更仔細地考慮狀態突變。 這就是 Vuex 可以提供幫助的地方。
Vuex 4
本節的代碼位於 GitHub 上示例項目的“Vuex4”分支中。
Vuex 是 Vue 的狀態管理器。 它是由核心團隊構建的,儘管它作為一個單獨的項目進行管理。 Vuex 的目的是將狀態和你想要對狀態做的動作分開。 所有狀態的更改都必須通過 Vuex,這意味著它更複雜,但是您可以防止意外狀態更改。
Vuex 的想法是提供可預測的狀態管理流程。 Views 流向 Actions,Actions 反過來使用 Mutations 來改變 State,State 反過來又更新 View。 通過限制狀態更改的流程,您應該減少更改應用程序狀態的副作用; 因此更容易構建更大的應用程序。 Vuex 有一個學習曲線,但是有了這種複雜性,你就可以得到可預測性。
此外,Vuex 確實支持開發時工具(通過 Vue 工具)來處理狀態管理,包括一個稱為時間旅行的功能。 這使您可以查看狀態的歷史記錄並前後移動以查看它如何影響應用程序。
有時,Vuex 也很重要。
要將其添加到您的 Vue 3 項目中,您可以將包添加到項目中:
> npm i vuex
或者,您也可以使用 Vue CLI 添加它:
> vue add vuex
通過使用 CLI,它將為您的 Vuex 商店創建一個起點,否則您需要手動將其連接到項目中。 讓我們來看看它是如何工作的。
首先,您需要一個使用 Vuex 的 createStore 函數創建的狀態對象:
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
如您所見,商店需要定義幾個屬性。 狀態只是您要授予應用程序訪問權限的數據列表:
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
請注意,狀態不應使用ref或響應式包裝器。 此數據與我們用於共享實例或工廠的共享數據相同。 該存儲將是您的應用程序中的單例,因此狀態中的數據也將被共享。
接下來,我們來看看動作。 操作是您要啟用的涉及狀態的操作。 例如:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
動作被傳遞一個存儲的實例,以便您可以獲取狀態和其他操作。 通常,我們只會解構我們需要的部分:
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
最後一部分是突變。 突變是可以改變狀態的函數。 只有突變可以影響狀態。 所以,對於這個例子,我們需要改變狀態的突變:
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
變異函數總是傳入狀態對象,以便您可以改變該狀態。 在前兩個示例中,您可以看到我們明確設置了狀態。 但是在第三個示例中,我們傳入要設置的狀態。 突變總是有兩個參數:狀態和調用突變時的參數。
要調用突變,您可以使用 store 上的commit函數。 在我們的例子中,我只是將它添加到解構中:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
您將在這裡看到的是commit如何需要操作的名稱。 有一些技巧可以讓這不僅僅是使用魔術字符串,但我現在要跳過它。 魔術字符串的這種使用是使用 Vuex 的限制之一。
雖然使用 commit 看起來像是一個不必要的包裝器,但請記住,Vuex 不會讓你改變狀態,除非在突變內部,因此只有通過commit調用才會。
您還可以看到對setBooks的調用採用了第二個參數。 這是調用突變的第二個參數。 如果您需要更多信息,則需要將其打包到單個參數中(當前 Vuex 的另一個限制)。 假設您需要將一本書插入圖書列表,您可以這樣稱呼它:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
然後你可以分解成你需要的部分:
mutations: { insertBook(state, { book, place }) => // ... }
這是優雅的嗎? 不是真的,但它有效。
現在我們的動作處理了突變,我們需要能夠在我們的代碼中使用 Vuex 存儲。 進店的方式其實有兩種。 首先,通過向應用程序(例如 main.ts/js)註冊商店,您將可以訪問一個集中式商店,您可以在應用程序的任何地方訪問該商店:
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
請注意,這不是添加 Vuex,而是您正在創建的實際商店。 添加後,您只需調用useStore
即可獲取商店對象:
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
這很好用,但我更喜歡直接導入商店:
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
既然您可以訪問 store 對象,那麼您如何使用它呢? 對於狀態,您需要用計算函數包裝它們,以便將更改傳播到您的綁定:
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
要調用操作,您需要調用dispatch方法:
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
操作可以具有您在方法名稱之後添加的參數。 最後,要更改狀態,您需要像我們在 Actions 中所做的那樣調用 commit。 例如,我在 store 中有一個 paging 屬性,然後我可以使用commit更改狀態:
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
請注意,這樣調用它會引發錯誤(因為您無法手動更改狀態):
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
這是這裡的真正力量,我們希望控制狀態更改的位置,並且不會產生在開發過程中進一步產生錯誤的副作用。
你可能會被 Vuex 中的大量移動部件弄得不知所措,但它確實可以幫助管理更大、更複雜的項目中的狀態。 我不會說你在任何情況下都需要它,但會有一些大型項目對你有幫助。
Vuex 4 的最大問題是在 TypeScript 項目中使用它還有很多不足之處。 你當然可以製作 TypeScript 類型來幫助開發和構建,但它需要很多移動部件。
這就是 Vuex 5 旨在簡化 Vuex 在 TypeScript(以及一般的 JavaScript 項目)中的工作方式的地方。 讓我們看看它在下一次發布後將如何工作。
Vuex 5
注意:本節的代碼位於 GitHub 上示例項目的“Vuex5”分支中。
在撰寫本文時,Vuex 5 還不是真的。 這是一個 RFC(徵求意見)。 這是一個計劃。 這是討論的起點。 所以我在這裡解釋的很多內容可能會有所改變。 但是為了讓您為 Vuex 的變化做好準備,我想讓您了解它的發展方向。 因此,與此示例關聯的代碼不會生成。
Vuex 工作方式的基本概念從一開始就沒有改變。 隨著 Vue 3 的引入,創建 Vuex 4 主要是為了讓 Vuex 在新項目中工作。 但該團隊正試圖用 Vuex 來研究真正的痛點並解決它們。 為此,他們正在計劃一些重要的改變:
- 不再有突變:動作可以改變狀態(可能是任何人)。
- 更好的 TypeScript 支持。
- 更好的多商店功能。
那麼這將如何工作呢? 讓我們從創建商店開始:
export default createStore({ key: 'bookStore', state: () => ({ isBusy: false, books: new Array<Work>() }), actions: { async loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } }, getters: { findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } } });
首先要看到的變化是每個商店現在都需要自己的密鑰。 這是為了允許您檢索多個商店。 接下來您會注意到狀態對象現在是一個工廠(例如從函數返回,而不是在解析時創建)。 並且不再有突變部分。 最後,在動作內部,您可以看到我們正在訪問狀態,這只是this
指針上的屬性。 不再需要傳遞狀態並承諾採取行動。 這不僅有助於簡化開發,還可以更輕鬆地推斷 TypeScript 的類型。
要將 Vuex 註冊到您的應用程序中,您將註冊 Vuex 而不是全局存儲:
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
最後,要使用商店,您將導入商店然後創建它的實例:
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
請注意,無論您調用工廠多少次,從存儲返回的是一個工廠對象,該對象返回存儲的該實例。 返回的對像只是一個對象,其動作、狀態和 getter 是一等公民(帶有類型信息):
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
您將在此處看到狀態(例如currentPage
)只是簡單的屬性。 並且動作(例如loadBooks
)只是函數。 您在這裡使用商店的事實是一個副作用。 您可以將 Vuex 對象視為一個對象並繼續您的工作。 這是 API 的重大改進。
另一個需要指出的重要變化是,您還可以使用類似 Composition API 的語法生成商店:
export default defineStore("another", () => { // State const isBusy = ref(false); const books = reactive(new Array≷Work>()); // Actions async function loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } // Getters const bookCount = computed(() => this.books.length); return { isBusy, books, loadBooks, findBook, bookCount } });
這使您可以像使用 Composition API 構建視圖一樣構建 Vuex 對象,並且可以說它更簡單。
這種新設計的一個主要缺點是您失去了狀態的不可變性。 圍繞啟用此功能的討論正在進行(僅用於開發,就像 Vuex 4 一樣),但沒有達成共識這有多重要。 我個人認為這對 Vuex 來說是一個關鍵的好處,但我們必須看看它是如何發揮作用的。
我們在哪裡?
管理單頁應用程序中的共享狀態是大多數應用程序開發的關鍵部分。 制定一個關於如何在 Vue 中進行的遊戲計劃是設計解決方案的重要一步。 在本文中,我向您展示了管理共享狀態的幾種模式,包括 Vuex 5 的未來。希望您現在擁有為自己的項目做出正確決策的知識。