前言
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…
预览
可以在这里先预览一下这个图书管理的小型网页:
也可以直接看源码:
api
Vue3 中有一对新增的 api,provide
和inject
,熟悉 Vue2 的朋友应该明白,
在上层组件通过 provide 提供一些变量,在子组件中可以通过 inject 来拿到,但是必须 在组件的对象里面声明,使用场景的也很少,所以之前我也并没有往状态管理的方向去想。
但是 Vue3 中新增了 Hook,而 Hook 的特征之一就是可以在组件外去写一些自定义 Hook, 所以我们不光可以在.vue 组件内部使用 Vue 的能力, 在任意的文件下(如 context.ts) 下也可以,
如果我们在 context.ts 中
-
自定义并 export 一个 hook 叫
useProvide
,并且在这个 hook 中使用 provide 并且 注册一些全局状态, -
再自定义并 export 一个 hook 叫
useInject
,并且在这个 hook 中使用 inject 返回 刚刚 provide 的全局状态, -
然后在根组件的 setup 函数中调用
useProvide
。 -
就可以在任意的子组件去共享这些全局状态了。
顺着这个思路,先看一下这两个 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,
}
},
}
开始
项目介绍
这个项目是一个简单的图书管理应用,功能很简单:
- 查看图书
- 增加已阅图书
- 删除已阅图书
项目搭建
首先使用 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 的作用就是把外部传入的异步方法func
在onMounted
生命周期里
调用
并且在调用的前后改变响应式变量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 返回的值来轻松实现,而不
用在每个组件都写一些data
、pageNo
之类的重复逻辑了。
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 区分,而是按照逻辑关注点分隔,
这样的好处显而易见,我们想要维护某一个功能的时候更加方便的能找到所有相关的逻辑, 而不再是在选项和文件之间跳来跳去。
优点
-
逻辑聚合 我们想要维护某一个功能的时候更加方便的能找到所有相关的逻辑,而不 再是在选项 mutation,state,action 的文件之间跳来跳去(一般跳到第三个的时候我 可能就把第一个忘了)
-
和 Vue3 api 一致 不用像 Vuex 那样记忆很多琐碎的 api(mutations, actions, getters, mapMutations, mapState ....这些甚至会作为面试题),Vue3 的 api 学完了 ,这套状态管理机制自然就可以运用。
-
跳转清晰 在组件代码里看到
useBookInject
,command + 点击后利用 vscode 的 能力就可以跳转到代码定义的地方,一目了然的看到所有的逻辑。(想一下 Vue2 中 vuex 看到 mapState,mapAction 还得去对应的文件夹自己找,简直是...)
总结
本文相关的所有代码都放在
这个仓库里了,感兴趣的同学可以去看,
在之前刚看到 composition-api,还有尤大对于 Vue3 的 Hook 和 React 的 Hook 的区别 对比的时候,我对于 Vue3 的 Hook 甚至有了一些盲目的崇拜,但是真正使用下来发现,虽 然不需要我们再去手动管理依赖项,但是由于 Vue 的响应式机制始终需要非原始的数据类 型来保持响应式,所带来的一些心智负担也是需要注意和适应的。
另外,vuex-next 也已经编写了一部分,我去看了一下,也是选择使
用provide
和inject
作为跨模块读取store
的方法。vue-router-next 同理,未来这两
个 api 真的会大有作为。
总体来说,Vue3 虽然也有一些自己的缺点,但是带给我们 React Hook 几乎所有的好处, 而且还规避了 React Hook 的一些让人难以理解坑,在某些方面还优于它,期待 Vue3 正式 版的发布!
求点赞
如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力,让我知道 你喜欢看我的文章吧~
❤️ 感谢大家
抽奖时间,关注公众号有机会抽取「掘金小册 5 折优惠码」。
关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起 共同交流和进步。