Vue3.0 嚐鮮 Hook TypeScript 取代 Vuex【項目實踐】

Vue3.0 嚐鮮 Hook TypeScript 取代 Vuex【項目實踐】

作者:ssh 前端從進階到入院

前言

Vue3 Beta版發佈了,離正式投入生產使用又更近了一步。此外,React Hook在社區的發展也是如火如荼。

一時間大家都覺得Redux很low,都在研究各種各樣配合hook實現的新形狀態管理模式。在React社區中,Context + useReducer的新型狀態管理模式廣受好評,那麼這種模式能不能套用到 Vue3 之中呢?

這篇文章就從Vue3的角度出發,探索一下未來的Vue狀態管理模式。

推薦文章:

聊聊昨晚尤雨溪現場針對Vue3.0 Beta版本新特性知識點彙總

vue官方提供的嚐鮮庫:
https://github.com/vuejs/composition-api

預覽


Vue3.0 嚐鮮 Hook TypeScript 取代 Vuex【項目實踐】

直接看源碼:

https://github.com/sl1673495/vue-bookshelf

api

Vue3中有一對新增的api,provide和inject,熟悉Vue2的朋友應該明白,

在上層組件通過provide提供一些變量,在子組件中可以通過inject來拿到,但是必須在組件的對象裡面聲明,使用場景的也很少,所以之前我也並沒有往狀態管理的方向去想。

但是Vue3中新增了Hook,而Hook的特徵之一就是可以在組件外去寫一些自定義Hook,所以我們不光可以在.vue組件內部使用Vue的能力, 在任意的文件下(如context.ts)下也可以,

如果我們在context.ts中

  1. 自定義並export一個hook叫useProvide,並且在這個hook中使用provide並且註冊一些全局狀態,
  2. 再自定義並export一個hook叫useInject,並且在這個hook中使用inject返回剛剛provide的全局狀態,
  3. 然後在根組件的setup函數中調用useProvide。
  4. 就可以在任意的子組件去共享這些全局狀態了。

順著這個思路,先看一下這兩個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>

開始

項目介紹

這個項目是一個簡單的圖書管理應用,功能很簡單:

  1. 查看圖書
  2. 增加已閱圖書
  3. 刪除已閱圖書

項目搭建

首先使用vue-cli搭建一個項目,在選擇依賴的時候手動選擇,這個項目中我使用了TypeScript,各位小夥伴可以按需選擇。

然後引入官方提供的vue-composition-api庫,並且在main.ts裡註冊。

<code>

import

 VueCompositionApi 

from

 

'@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

 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

>

/<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組件。

Vue3.0 嚐鮮 Hook TypeScript 取代 Vuex【項目實踐】

<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

 Book 

from

 

"./Book.vue"

;

export

 

default

 createComponent({   

name

"books"

,   setup(props) {     

const

 pageSize = 

10

;     

const

 { elPagenationBindings, 

data

: pagedBooks } = usePages(       

()

 =>

 props.books 

as

 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.books 

as

 Books,   { pageSize: 

10

 } );/<code>

已閱圖書

如何判斷已閱後的圖書,也可以通過在BookContext中返回一個函數,在組件中加以判斷:

Vue3.0 嚐鮮 Hook TypeScript 取代 Vuex【項目實踐】

<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區分,而是按照邏輯關注點分隔,

這樣的好處顯而易見,我們想要維護某一個功能的時候更加方便的能找到所有相關的邏輯,而不再是在選項和文件之間跳來跳去。

優點

  1. 邏輯聚合 我們想要維護某一個功能的時候更加方便的能找到所有相關的邏輯,而不再是在選項mutation,state,action的文件之間跳來跳去(一般跳到第三個的時候我可能就把第一個忘了)
  2. 和Vue3 api一致 不用像Vuex那樣記憶很多瑣碎的api(mutations, actions, getters, mapMutations, mapState ....這些甚至會作為面試題),Vue3的api學完了,這套狀態管理機制自然就可以運用。
  3. 跳轉清晰 在組件代碼裡看到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


分享到:


相關文章: