Vue3 Hook + TypeScript 取代 Vuex 實現圖書管理小型應用

前言

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

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

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

vue-composition-api-rfc:
vue-composition-api-rfc.netlify.com/api.htmlvue

vue官方提供的嚐鮮庫:
github.com/vuejs/compo…java

預覽

能夠在這裏先預覽一下這個圖書管理的小型網頁:react

sl1673495.github.io/vue-bookshe…git

也能夠直接看源碼:github

github.com/sl1673495/v…面試

api

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

在上層組件經過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="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>
複製代碼

這裏主要的邏輯就是用了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 data = ref<T[]>([]);

  // 提供給el-pagination組件的參數
  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
  };
}
複製代碼

Hook內部定義好了一些響應式的數據如分頁後的數據data,以及提供給el-pagination組件的props對象elPagenationBindings,此後對於前端分頁的需求來講,就能夠經過在模板中使用Hook返回的值來輕鬆實現,而不用在每一個組件都寫一些datapageNo之類的重複邏輯了。

const { elPagenationBindings, 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折優惠碼」。

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

相關文章
相關標籤/搜索