Vue3 Composition-Api + TypeScript + 新型狀態管理模式探索。

前言

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

github.com/sl1673495/v…vuex

api

Vue3 中有一對新增的 api,provideinject,熟悉 Vue2 的朋友應該明白,vue-cli

在上層組件經過 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。

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,
    }
  },
}
複製代碼

開始

項目介紹

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

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

項目搭建

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

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

import VueCompositionApi from '@vue/composition-api'
Vue.use(VueCompositionApi)
複製代碼

context 編寫

按照剛剛的思路,我創建了 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 的做用就是把外部傳入的異步方法funconMounted生命週期裏 調用
而且在調用的先後改變響應式變量loading的值,而且把 loading 返回出去,這樣 loading 就能夠在模板中自由使用,從而讓 loading 這個變量和頁面的渲染關聯起來。

Vue3 的 hooks 讓咱們能夠在組件外部調用 Vue 的全部能力,
包括 onMounted,ref, reactive 等等,

這使得自定義 hook 能夠作很是多的事情,
而且在組件的 setup 函數把多個自定義 hook 組合起來完成邏輯,

這恐怕也是起名叫 composition-api 的初衷。

增長分頁 Hook

在某些場景中,前端也須要對數據作分頁,配合 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 返回的值來輕鬆實現,而不 用在每一個組件都寫一些datapageNo之類的重複邏輯了。

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>
複製代碼

最終的 books 模塊 context

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

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

優勢

  1. 邏輯聚合 咱們想要維護某一個功能的時候更加方便的能找到全部相關的邏輯,而不 再是在選項 mutation,state,action 的文件之間跳來跳去(通常跳到第三個的時候我 可能就把第一個忘了)

  2. 和 Vue3 api 一致 不用像 Vuex 那樣記憶不少瑣碎的 api(mutations, actions, getters, mapMutations, mapState ....這些甚至會做爲面試題),Vue3 的 api 學完了 ,這套狀態管理機制天然就能夠運用。

  3. 跳轉清晰 在組件代碼裏看到useBookInject,command + 點擊後利用 vscode 的 能力就能夠跳轉到代碼定義的地方,一目瞭然的看到全部的邏輯。(想一下 Vue2 中 vuex 看到 mapState,mapAction 還得去對應的文件夾本身找,簡直是...)

總結

本文相關的全部代碼都放在

github.com/sl1673495/v…

這個倉庫裏了,感興趣的同窗能夠去看,

在以前剛看到 composition-api,還有尤大對於 Vue3 的 Hook 和 React 的 Hook 的區別 對比的時候,我對於 Vue3 的 Hook 甚至有了一些盲目的崇拜,可是真正使用下來發現,雖 然不須要咱們再去手動管理依賴項,可是因爲 Vue 的響應式機制始終須要非原始的數據類 型來保持響應式,所帶來的一些心智負擔也是須要注意和適應的。

另外,vuex-next 也已經編寫了一部分,我去看了一下,也是選擇使 用provideinject做爲跨模塊讀取store的方法。vue-router-next 同理,將來這兩 個 api 真的會大有做爲。

整體來講,Vue3 雖然也有一些本身的缺點,可是帶給咱們 React Hook 幾乎全部的好處, 並且還規避了 React Hook 的一些讓人難以理解坑,在某些方面還優於它,期待 Vue3 正式 版的發佈!

求點贊

若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我持續進行創做的動力,讓我知道 你喜歡看個人文章吧~

❤️ 感謝你們

抽獎時間,關注公衆號有機會抽取「掘金小冊 5 折優惠碼」。

關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒 共同交流和進步。

相關文章
相關標籤/搜索