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 折优惠码」。

关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿 共同交流和进步。

相关文章
相关标签/搜索