前言
Vue3 Beta版发布了,离正式投入生产使用又更近了一步。此外,React Hook在社区的发展也是如火如荼。
一时间大家都觉得Redux很low,都在研究各种各样配合hook实现的新形状态管理模式。
在React社区中,Context + useReducer的新型状态管理模式广受好评,那么这种模式能不能套用到 Vue3 之中呢?
这篇文章就从Vue3的角度出发,探索一下未来的Vue状态管理模式。
vue-composition-api-rfc:
https://vue-composition-api-rfc.netlify.com/api.html
vue官方提供的尝鲜库:
https://github.com/vuejs/composition-api
预览
可以在这里先预览一下这个图书管理的小型网页:
https://sl1673495.github.io/vue-bookshelf
也可以直接看源码:
https://github.com/sl1673495/vue-bookshelf
应用程序接口
Vue3中有一对新增的api,和,熟悉Vue2的朋友应该明白,provide
inject
在上层组件通过provide提供一些变量,在子组件中可以通过inject来拿到,但是必须在组件的对象里面声明,使用场景的也很少,所以之前我也并没有往状态管理的方向去想。
但是Vue3中新增了Hook,而Hook的特征之一就是可以在组件外去写一些自定义Hook,所以我们不光可以在.vue组件内部使用Vue的能力, 在任意的文件下(如context.ts)下也可以,
如果我们在context.ts中
- 1.自定义并export一个hook叫,并且在这个hook中使用provide并且注册一些全局状态,
useProvide
- 2.再自定义并export一个hook叫,并且在这个hook中使用inject返回刚刚provide的全局状态,
useInject
- 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里引入了一个函数,我编写它的目的是为了管理异步方法前后的loading状态,看一下它的实现。useAsync
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的作用就是把外部传入的异步方法在生命周期里调用
并且在调用的前后改变响应式变量的值,并且把loading返回出去,这样loading就可以在模板中自由使用,从而让loading这个变量和页面的渲染关联起来。func
onMounted
loading
Vue3的hooks让我们可以在组件外部调用Vue的所有能力,
包括onMounted,ref, reactive等等,
这使得自定义hook可以做非常多的事情,
并且在组件的setup函数把多个自定义hook组合起来完成逻辑,
这恐怕也是起名叫composition-api的初衷。
增加分页Hook
在某些场景中,前端也需要对数据做分页,配合Vue3的Hook,它会是怎样编写的呢?
进入这个UI组件,直接在这里把数据切分,并且引入组件。Books
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>
这里主要的逻辑就是用了这个自定义Hook,有点奇怪的是第一项参数返回的是一个读取的方法。usePages
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内部定义好了一些响应式的数据如分页后的数据,以及提供给组件的props对象,此后对于前端分页的需求来说,就可以通过在模板中使用Hook返回的值来轻松实现,而不用在每个组件都写一些、之类的重复逻辑了。data
el-pagination
elPagenationBindings
data
pageNo
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.逻辑聚合 我们想要维护某一个功能的时候更加方便的能找到所有相关的逻辑,而不再是在选项突变,state,action的文件之间跳来跳去(一般跳到第三个的时候我可能就把第一个忘了)
- 2.和Vue3 api一致 不用像Vuex那样记忆很多琐碎的api(mutations, actions, getters, mapMutations, mapState ....这些甚至会作为面试题),Vue3的api学完了,这套状态管理机制自然就可以运用。
- 3.跳转清晰 在组件代码里看到,command + 点击后利用vscode的能力就可以跳转到代码定义的地方,一目了然的看到所有的逻辑。(想一下Vue2中vuex看到mapState,mapAction还得去对应的文件夹自己找,简直是...)
useBookInject
总结
本文相关的所有代码都放在
https://github.com/sl1673495/vue-bookshelf
这个仓库里了,感兴趣的同学可以去看,
在之前刚看到composition-api,还有尤大对于Vue3的Hook和React的Hook的区别对比的时候,我对于Vue3的Hook甚至有一些盲目的崇拜,但是真正使用下来发现,虽然不需要我们再去手动管理依赖项,但是由于Vue的响应式机制始终需要非原始的数据类型来保持响应式,一些心智负担也是需要注意和适应的。
另外,vuex-next也已经编写了一部分,我去看了一下,也是选择使用和作为跨模块读取的方法。vue-router-next同理,未来这两个api真的会大有作为。provide
inject
store
总体来说,Vue3虽然也有一些自己的缺点,但是带给我们React Hook几乎所有的好处,而且还规避了React Hook的一些让人难以理解坑,在某些方面还优于它,期待Vue3正式版的发布!