阅读 8016

Vue3 Composition-Api + TypeScript + 新型状态管理模式探索。

前言

Vue3 Beta 版发布了,离正式投入生产使用又更近了一步。此外,React Hook 在社区的发 展也是如火如荼。

在 React 社区中,Context + useReducer 的新型状态管理模式广受好评,那么这种模式能 不能套用到 Vue3 之中呢?

这篇文章就从 Vue3 的角度出发,探索一下未来的 Vue 状态管理模式。

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

vue 官方提供的尝鲜库:
github.com/vuejs/compo…

预览

可以在这里先预览一下这个图书管理的小型网页:

sl1673495.gitee.io/vue-books

也可以直接看源码:

github.com/sl1673495/v…

api

Vue3 中有一对新增的 api,provideinject,熟悉 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。

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

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