Vue3 Beta 版發佈了,離正式投入生產使用又更近了一步。此外,React Hook 在社區的發 展也是如火如荼。html
在 React 社區中,Context + useReducer 的新型狀態管理模式廣受好評,那麼這種模式能 不能套用到 Vue3 之中呢?前端
這篇文章就從 Vue3 的角度出發,探索一下將來的 Vue 狀態管理模式。vue
vue-composition-api-rfc:
vue-composition-api-rfc.netlify.com/api.htmlreact
vue 官方提供的嚐鮮庫:
github.com/vuejs/compo…git
能夠在這裏先預覽一下這個圖書管理的小型網頁:github
sl1673495.gitee.io/vue-books面試
也能夠直接看源碼:vue-router
Vue3 中有一對新增的 api,provide
和inject
,熟悉 Vue2 的朋友應該明白,vue-cli
在上層組件經過 provide 提供一些變量,在子組件中能夠經過 inject 來拿到,可是必須 在組件的對象裏面聲明,使用場景的也不多,因此以前我也並無往狀態管理的方向去想。
可是 Vue3 中新增了 Hook,而 Hook 的特徵之一就是能夠在組件外去寫一些自定義 Hook, 因此咱們不光能夠在.vue 組件內部使用 Vue 的能力, 在任意的文件下(如 context.ts) 下也能夠,
若是咱們在 context.ts 中
自定義並 export 一個 hook 叫useProvide
,而且在這個 hook 中使用 provide 而且 註冊一些全局狀態,
再自定義並 export 一個 hook 叫useInject
,而且在這個 hook 中使用 inject 返回 剛剛 provide 的全局狀態,
而後在根組件的 setup 函數中調用useProvide
。
就能夠在任意的子組件去共享這些全局狀態了。
順着這個思路,先看一下這兩個 api 的介紹,而後一塊兒慢慢探索這對 api。
import {provide, inject} from 'vue' const ThemeSymbol = Symbol() const Ancestor = { setup() { provide(ThemeSymbol, 'dark') }, } const Descendent = { setup() { const theme = inject(ThemeSymbol, 'light' /* optional default value */) return { theme, } }, } 複製代碼
這個項目是一個簡單的圖書管理應用,功能很簡單:
首先使用 vue-cli 搭建一個項目,在選擇依賴的時候手動選擇,這個項目中我使用了 TypeScript,各位小夥伴能夠按需選擇。
而後引入官方提供的 vue-composition-api 庫,而且在 main.ts 裏註冊。
import VueCompositionApi from '@vue/composition-api' Vue.use(VueCompositionApi) 複製代碼
按照剛剛的思路,我創建了 src/context/books.ts
import {provide, inject, computed, ref, Ref} from '@vue/composition-api' import {Book, Books} from '@/types' type BookContext = { books: Ref<Books> setBooks: (value: Books) => void } const BookSymbol = Symbol() export const useBookListProvide = () => { // 所有圖書 const books = ref<Books>([]) const setBooks = (value: Books) => (books.value = value) provide(BookSymbol, { books, setBooks, }) } export const useBookListInject = () => { const booksContext = inject<BookContext>(BookSymbol) if (!booksContext) { throw new Error(`useBookListInject must be used after useBookListProvide`) } return booksContext } 複製代碼
全局狀態確定不止一個模塊,因此在 context/index.ts 下作統一的導出
import {useBookListProvide, useBookListInject} from './books' export {useBookListInject} export const useProvider = () => { useBookListProvide() } 複製代碼
後續若是增長模塊的話,就按照這個套路就好。
而後在 main.ts 的根組件裏使用 provide,在最上層的組件中注入全局狀態。
new Vue({ router, setup() { useProvider() return {} }, render: h => h(App), }).$mount('#app') 複製代碼
在組件 view/books.vue 中使用:
<template> <Books :books="books" :loading="loading" /> </template> <script lang="ts"> import { createComponent } from '@vue/composition-api'; import Books from '@/components/Books.vue'; import { useAsync } from '@/hooks'; import { getBooks } from '@/hacks/fetch'; import { useBookListInject } from '@/context'; export default createComponent({ name: 'books', setup() { const { books, setBooks } = useBookListInject(); const loading = useAsync(async () => { const requestBooks = await getBooks(); setBooks(requestBooks); }); return { books, loading }; }, components: { Books, }, }); </script> 複製代碼
這個頁面須要初始化 books 的數據,而且從 inject 中拿到 setBooks 的方法並調用,之 後這份 books 數據就能夠供全部組件使用了。
在 setup 裏引入了一個useAsync
函數,我編寫它的目的是爲了管理異步方法先後的 loading 狀態,看一下它的實現。
import {ref, onMounted} from '@vue/composition-api' export const useAsync = (func: () => Promise<any>) => { const loading = ref(false) onMounted(async () => { try { loading.value = true await func() } catch (error) { throw error } finally { loading.value = false } }) return loading } 複製代碼
能夠看出,這個 hook 的做用就是把外部傳入的異步方法func
在onMounted
生命週期裏 調用
而且在調用的先後改變響應式變量loading
的值,而且把 loading 返回出去,這樣 loading 就能夠在模板中自由使用,從而讓 loading 這個變量和頁面的渲染關聯起來。
Vue3 的 hooks 讓咱們能夠在組件外部調用 Vue 的全部能力,
包括 onMounted,ref, reactive 等等,
這使得自定義 hook 能夠作很是多的事情,
而且在組件的 setup 函數把多個自定義 hook 組合起來完成邏輯,
這恐怕也是起名叫 composition-api 的初衷。
在某些場景中,前端也須要對數據作分頁,配合 Vue3 的 Hook,它會是怎樣編寫的呢?
進入Books
這個 UI 組件,直接在這裏把數據切分,而且引入Pagination
組件。
<template> <section class="wrap"> <span v-if="loading">正在加載中...</span> <section v-else class="content"> <Book v-for="book in pagedBooks" :key="book.id" :book="book" /> <el-pagination class="pagination" v-if="pagedBooks.length" :page-size="pageSize" :total="books.length" :current="bindings.current" @current-change="bindings.currentChange" /> </section> <slot name="tips"></slot> </section> </template> <script lang="ts"> import { createComponent } from "@vue/composition-api"; import { usePages } from "@/hooks"; import { Books } from "@/types"; import Book from "./Book.vue"; export default createComponent({ name: "books", setup(props) { const pageSize = 10; const { bindings, data: pagedBooks } = usePages( () => props.books as Books, { pageSize } ); return { bindings, pagedBooks, pageSize }; }, props: { books: { type: Array, default: () => [] }, loading: { type: Boolean, default: false } }, components: { Book } }); </script> 複製代碼
這裏主要的邏輯就是用了usePages
這個自定義 Hook,有點奇怪的是第一項參數返回的是 一個讀取props.books
的方法。
其實這個方法在 Hook 內部會傳給 watch 方法做爲第一個參數,因爲 props 是響應式的, 因此對props.books
的讀取天然也能收集到依賴,從而在外部傳入的books
發生變化的時 候,能夠通知watch
去從新執行回調函數。
看一下usePages
的編寫:
import {watch, ref, reactive} from '@vue/composition-api'
export interface PageOption {
pageSize?: number
}
export function usePages<T>(watchCallback: () => T[], pageOption?: PageOption) {
const {pageSize = 10} = pageOption || {}
const rawData = ref<T[]>([])
const data = ref<T[]>([])
// 提供給el-pagination組件的參數
const bindings = reactive({
current: 1,
currentChange: (currnetPage: number) => {
data.value = sliceData(rawData.value, currnetPage)
},
})
// 根據頁數切分數據
const sliceData = (rawData: T[], currentPage: number) => {
return rawData.slice((currentPage - 1) * pageSize, currentPage * pageSize)
}
watch(watchCallback, values => {
// 更新原始數據
rawData.value = values
bindings.currentChange(1)
})
return {
data,
bindings,
}
}
複製代碼
Hook 內部定義好了一些響應式的數據如原始數據rawData
,分頁後的數據data
,以及提 供給el-pagination
組件的 props 對象bindings
。而且在 watch 到原始數據變化後, 也會及時同步 Hook 中的數據。
此後對於前端分頁的需求來講,就能夠經過在模板中使用 Hook 返回的值來輕鬆實現,而不 用在每一個組件都寫一些data
、pageNo
之類的重複邏輯了。
const {bindings, data: pagedBooks} = usePages(() => props.books as Books, { pageSize: 10, }) 複製代碼
如何判斷已閱後的圖書,也能夠經過在BookContext
中返回一個函數,在組件中加以判斷 :
// 是否已閱 const hasReadedBook = (book: Book) => finishedBooks.value.includes(book) provide(BookSymbol, { books, setBooks, finishedBooks, addFinishedBooks, removeFinishedBooks, hasReadedBook, booksAvaluable, }) 複製代碼
在StatusButton
組件中:
<template> <button v-if="hasReaded" @click="removeFinish">刪</button> <button v-else @click="handleFinish">閱</button> </template> <script lang="ts"> import { createComponent } from "@vue/composition-api"; import { useBookListInject } from "@/context"; import { Book } from "../types"; interface Props { book: Book; } export default createComponent({ props: { book: Object }, setup(props: Props) { const { book } = props; const { addFinishedBooks, removeFinishedBooks, hasReadedBook } = useBookListInject(); const handleFinish = () => { addFinishedBooks(book); }; const removeFinish = () => { removeFinishedBooks(book); }; return { handleFinish, removeFinish, // 這裏調用一下函數,輕鬆的判斷出狀態。 hasReaded: hasReadedBook(book) }; } }); </script> 複製代碼
import {provide, inject, computed, ref, Ref} from '@vue/composition-api' import {Book, Books} from '@/types' type BookContext = { books: Ref<Books> setBooks: (value: Books) => void finishedBooks: Ref<Books> addFinishedBooks: (book: Book) => void removeFinishedBooks: (book: Book) => void hasReadedBook: (book: Book) => boolean booksAvaluable: Ref<Books> } const BookSymbol = Symbol() export const useBookListProvide = () => { // 所有圖書 const books = ref<Books>([]) const setBooks = (value: Books) => (books.value = value) // 已完成圖書 const finishedBooks = ref<Books>([]) const addFinishedBooks = (book: Book) => { if (!finishedBooks.value.find(({id}) => id === book.id)) { finishedBooks.value.push(book) } } const removeFinishedBooks = (book: Book) => { const removeIndex = finishedBooks.value.findIndex(({id}) => id === book.id) if (removeIndex !== -1) { finishedBooks.value.splice(removeIndex, 1) } } // 可選圖書 const booksAvaluable = computed(() => { return books.value.filter( book => !finishedBooks.value.find(({id}) => id === book.id), ) }) // 是否已閱 const hasReadedBook = (book: Book) => finishedBooks.value.includes(book) provide(BookSymbol, { books, setBooks, finishedBooks, addFinishedBooks, removeFinishedBooks, hasReadedBook, booksAvaluable, }) } export const useBookListInject = () => { const booksContext = inject<BookContext>(BookSymbol) if (!booksContext) { throw new Error(`useBookListInject must be used after useBookListProvide`) } return booksContext } 複製代碼
最終的 books 模塊就是這個樣子了,能夠看到在 hooks 的模式下,
代碼再也不按照 state, mutation 和 actions 區分,而是按照邏輯關注點分隔,
這樣的好處顯而易見,咱們想要維護某一個功能的時候更加方便的能找到全部相關的邏輯, 而再也不是在選項和文件之間跳來跳去。
邏輯聚合 咱們想要維護某一個功能的時候更加方便的能找到全部相關的邏輯,而不 再是在選項 mutation,state,action 的文件之間跳來跳去(通常跳到第三個的時候我 可能就把第一個忘了)
和 Vue3 api 一致 不用像 Vuex 那樣記憶不少瑣碎的 api(mutations, actions, getters, mapMutations, mapState ....這些甚至會做爲面試題),Vue3 的 api 學完了 ,這套狀態管理機制天然就能夠運用。
跳轉清晰 在組件代碼裏看到useBookInject
,command + 點擊後利用 vscode 的 能力就能夠跳轉到代碼定義的地方,一目瞭然的看到全部的邏輯。(想一下 Vue2 中 vuex 看到 mapState,mapAction 還得去對應的文件夾本身找,簡直是...)
本文相關的全部代碼都放在
這個倉庫裏了,感興趣的同窗能夠去看,
在以前剛看到 composition-api,還有尤大對於 Vue3 的 Hook 和 React 的 Hook 的區別 對比的時候,我對於 Vue3 的 Hook 甚至有了一些盲目的崇拜,可是真正使用下來發現,雖 然不須要咱們再去手動管理依賴項,可是因爲 Vue 的響應式機制始終須要非原始的數據類 型來保持響應式,所帶來的一些心智負擔也是須要注意和適應的。
另外,vuex-next 也已經編寫了一部分,我去看了一下,也是選擇使 用provide
和inject
做爲跨模塊讀取store
的方法。vue-router-next 同理,將來這兩 個 api 真的會大有做爲。
整體來講,Vue3 雖然也有一些本身的缺點,可是帶給咱們 React Hook 幾乎全部的好處, 並且還規避了 React Hook 的一些讓人難以理解坑,在某些方面還優於它,期待 Vue3 正式 版的發佈!
若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我持續進行創做的動力,讓我知道 你喜歡看個人文章吧~
抽獎時間,關注公衆號有機會抽取「掘金小冊 5 折優惠碼」。
關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒 共同交流和進步。