作者:ssh 前端從進階到入院
前言
Vue3 Beta版發佈了,離正式投入生產使用又更近了一步。此外,React Hook在社區的發展也是如火如荼。
一時間大家都覺得Redux很low,都在研究各種各樣配合hook實現的新形狀態管理模式。在React社區中,Context + useReducer的新型狀態管理模式廣受好評,那麼這種模式能不能套用到 Vue3 之中呢?
這篇文章就從Vue3的角度出發,探索一下未來的Vue狀態管理模式。
推薦文章:
《聊聊昨晚尤雨溪現場針對Vue3.0 Beta版本新特性知識點彙總》
vue官方提供的嚐鮮庫:
https://github.com/vuejs/composition-api
預覽
直接看源碼:
https://github.com/sl1673495/vue-bookshelf
api
Vue3中有一對新增的api,provide和inject,熟悉Vue2的朋友應該明白,
在上層組件通過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。
<code>import
{ provide, inject }from
'vue'
const
ThemeSymbol =Symbol
()const
Ancestor = { setup() { provide(ThemeSymbol,'dark'
) } }const
Descendent = { setup() {const
theme = inject(ThemeSymbol,'light'
)return
{ theme } } }/<code>
開始
項目介紹
這個項目是一個簡單的圖書管理應用,功能很簡單:
- 查看圖書
- 增加已閱圖書
- 刪除已閱圖書
項目搭建
首先使用vue-cli搭建一個項目,在選擇依賴的時候手動選擇,這個項目中我使用了TypeScript,各位小夥伴可以按需選擇。
然後引入官方提供的vue-composition-api庫,並且在main.ts裡註冊。
<code>import
VueCompositionApifrom
'@vue/composition-api'
; Vue.use(VueCompositionApi);/<code>
context編寫
按照剛剛的思路,我建立了src/context/books.ts
<code>import
{ provide, inject, computed, ref, Ref }from
'@vue/composition-api'
;import
{ Book, Books }from
'@/types'
; type BookContext = {books
: Ref; setBooks:(
value: Books
) =>void
; };const
BookSymbol =Symbol
();export
const
useBookListProvide =()
=> {const
books = ref([]);const
setBooks =(
value: Books
) => (books.value = value); provide(BookSymbol, { books, setBooks, }); };export
const
useBookListInject =()
=> {const
booksContext = inject(BookSymbol);if
(!booksContext) {throw
new
Error
(`useBookListInject must be used after useBookListProvide`
); }return
booksContext; }; /<code>
全局狀態肯定不止一個模塊,所以在context/index.ts下做統一的導出
<code>import
{ useBookListProvide, useBookListInject }from
'./books'
;export
{ useBookListInject };export
const useProvider =()
=> { useBookListProvide(); };/<code>
後續如果增加模塊的話,就按照這個套路就好。
然後在main.ts的根組件裡使用provide,在最上層的組件中注入全局狀態。
<code>new
Vue({ router, setup() { useProvider();return
{}; },render
:h
=> h(App), }).$mount('#app'
);/<code>
在組件view/books.vue中使用:
<code><
template
><
Books
:books
="books"
:loading
="loading"
/>template
><
script
lang
="ts"
>import
{ createComponent }from
'@vue/composition-api'
;import
Booksfrom
'@/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
>/<code>
這個頁面需要初始化books的數據,並且從inject中拿到setBooks的方法並調用,之後這份books數據就可以供所有組件使用了。
在setup裡引入了一個useAsync函數,我編寫它的目的是為了管理異步方法前後的loading狀態,看一下它的實現。
<code>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; };/<code>
可以看出,這個hook的作用就是把外部傳入的異步方法func在onMounted生命週期裡調用並且在調用的前後改變響應式變量loading的值,並且把loading返回出去,這樣loading就可以在模板中自由使用,從而讓loading這個變量和頁面的渲染關聯起來。
Vue3的hooks讓我們可以在組件外部調用Vue的所有能力,包括onMounted,ref, reactive等等,
這使得自定義hook可以做非常多的事情,並且在組件的setup函數把多個自定義hook組合起來完成邏輯,
這恐怕也是起名叫composition-api的初衷。
增加分頁Hook
在某些場景中,前端也需要對數據做分頁,配合Vue3的Hook,它會是怎樣編寫的呢?
進入Books這個UI組件,直接在這裡把數據切分,並且引入Pagination組件。
<code><
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
="elPagenationBindings.current"
@current-change
="elPagenationBindings.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
Bookfrom
"./Book.vue"
;export
default
createComponent({name
:"books"
, setup(props) {const
pageSize =10
;const
{ elPagenationBindings,data
: pagedBooks } = usePages(()
=> props.booksas
Books, { pageSize } );return
{ elPagenationBindings, pagedBooks, pageSize }; },props
: {books
: {type
:Array
,default
:()
=> [] },loading
: {type
:Boolean
,default
:false
} },components
: { Book } });script
>/<code>
這裡主要的邏輯就是用了usePages這個自定義Hook,有點奇怪的是第一項參數返回的是一個讀取props.books的方法。
其實這個方法在Hook內部會傳給watch方法作為第一個參數,由於props是響應式的,所以對props.books的讀取自然也能收集到依賴,從而在外部傳入的books發生變化的時候,可以通知watch去重新執行回調函數。
看一下usePages的編寫:
<code>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
data = ref([]);const
elPagenationBindings = reactive({ current:1
, currentChange:(
currnetPage:
number
) => {} });const
sliceData =(
currentData: T[], currentPage:
number
) => {return
currentData.slice( (currentPage -1
) * pageSize, currentPage * pageSize ); }; watch(watchCallback,values
=> {const
currentChange =(
currnetPage:
number
) => { elPagenationBindings.current = currnetPage; data.value = sliceData(values, currnetPage); }; currentChange(1
); elPagenationBindings.currentChange = currentChange; });return
{ data, elPagenationBindings }; }/<code>
Hook內部定義好了一些響應式的數據如分頁後的數據data,以及提供給el-pagination組件的props對象elPagenationBindings,此後對於前端分頁的需求來說,就可以通過在模板中使用Hook返回的值來輕鬆實現,而不用在每個組件都寫一些data、pageNo之類的重複邏輯了。
<code>const { elPagenationBindings, data: pagedBooks } = usePages(()
=> props.booksas
Books, { pageSize:10
} );/<code>
已閱圖書
如何判斷已閱後的圖書,也可以通過在BookContext中返回一個函數,在組件中加以判斷:
<code>const
hasReadedBook =(
book: Book
) => finishedBooks.value.includes(book) provide(BookSymbol, { books, setBooks, finishedBooks, addFinishedBooks, removeFinishedBooks, hasReadedBook, booksAvaluable, }) /<code>
在StatusButton組件中:
<code><
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
>/<code>
最終的books模塊context
<code>import
{ provide, inject, computed, ref, Ref }from
"@vue/composition-api"
;import
{ Book, Books }from
"@/types"
;type
BookContext = { books: Ref; setBooks:(
value: Books
) =>void
; finishedBooks: Ref; addFinishedBooks:(
book: Book
) =>void
; removeFinishedBooks:(
book: Book
) =>void
; hasReadedBook:(
book: Book
) =>boolean
; booksAvaluable: Ref; };const
BookSymbol = Symbol();export
const
useBookListProvide =()
=> {const
books = ref([]);const
setBooks =(
value: Books
) => (books.value = value);const
finishedBooks = ref([]);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(BookSymbol);if
(!booksContext) {throw
new
Error
(`useBookListInject must be used after useBookListProvide`
); }return
booksContext; };/<code>
最終的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還得去對應的文件夾自己找,簡直是...)
總結
本文相關的所有代碼都放在
https://github.com/sl1673495/vue-bookshelf
這個倉庫裡了,感興趣的同學可以去看,
在之前剛看到composition-api,還有尤大對於Vue3的Hook和React的Hook的區別對比的時候,我對於Vue3的Hook甚至有了一些盲目的崇拜,但是真正使用下來發現,雖然不需要我們再去手動管理依賴項,但是由於Vue的響應式機制始終需要非原始的數據類型來保持響應式,所帶來的一些心智負擔也是需要注意和適應的。
另外,vuex-next也已經編寫了一部分,我去看了一下,也是選擇使用provide和inject作為跨模塊讀取store的方法。vue-router-next同理,未來這兩個api真的會大有作為。
總體來說,Vue3雖然也有一些自己的缺點,但是帶給我們React Hook幾乎所有的好處,而且還規避了React Hook的一些讓人難以理解坑,在某些方面還優於它,期待Vue3正式版的發佈!
求點贊
如果本文對你有幫助,就點個贊支持下吧,你的「贊」是我持續進行創作的動力,讓我知道你喜歡看我的文章吧~
作者:ssh 前端從進階到入院
轉發鏈接:
https://mp.weixin.qq.com/s/qD_acbCw3Vv8uP7aOHuqhQ