Vue3 尝鲜 Hook + TypeScript 取代 Vuex 实现图书管理小型应用
前言
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的朋友应该明白,provideinject
在上层组件通过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: RefBooks>
;
setBooks: (value: Books) =>
void;
}
;
const BookSymbol = Symbol();
export const useBookListProvide = () =>
{
// 全部图书
const books = refBooks>
([]);
const setBooks = (value: Books) =>
(books.value = value);
provide(BookSymbol, {
books,
setBooks,
}
);
}
;
export const useBookListInject = () =>
{
const booksContext = injectBookContext>
(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: () =>
Promiseany>
) =>
{
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这个变量和页面的渲染关联起来。funconMountedloading
Vue3的hooks让我们可以在组件外部调用Vue的所有能力,
包括onMounted,ref, reactive等等,
这使得自定义hook可以做非常多的事情,
并且在组件的setup函数把多个自定义hook组合起来完成逻辑,
这恐怕也是起名叫composition-api的初衷。
增加分页Hook
在某些场景中,前端也需要对数据做分页,配合Vue3的Hook,它会是怎样编写的呢?
进入这个UI组件,直接在这里把数据切分,并且引入组件。BooksPagination
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,有点奇怪的是第一项参数返回的是一个读取的方法。usePagesprops.books
其实这个方法在Hook内部会传给watch方法作为第一个参数,由于props是响应式的,所以对的读取自然也能收集到依赖,从而在外部传入的发生变化的时候,可以通知去重新执行回调函数。props.booksbookswatch
看一下的编写:usePages
import {
watch, ref, reactive }
from "@vue/composition-api";
export interface PageOption {
pageSize?: number;
}
export function usePagesT>
(watchCallback: () =>
T[], pageOption?: PageOption) {
const {
pageSize = 10 }
= pageOption || {
}
;
const data = refT[]>
([]);
// 提供给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返回的值来轻松实现,而不用在每个组件都写一些、之类的重复逻辑了。datael-paginationelPagenationBindingsdatapageNo
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: RefBooks>
;
setBooks: (value: Books) =>
void;
finishedBooks: RefBooks>
;
addFinishedBooks: (book: Book) =>
void;
removeFinishedBooks: (book: Book) =>
void;
hasReadedBook: (book: Book) =>
boolean;
booksAvaluable: RefBooks>
;
}
;
const BookSymbol = Symbol();
export const useBookListProvide = () =>
{
// 全部图书
const books = refBooks>
([]);
const setBooks = (value: Books) =>
(books.value = value);
// 已完成图书
const finishedBooks = refBooks>
([]);
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 = injectBookContext>
(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真的会大有作为。provideinjectstore
总体来说,Vue3虽然也有一些自己的缺点,但是带给我们React Hook几乎所有的好处,而且还规避了React Hook的一些让人难以理解坑,在某些方面还优于它,期待Vue3正式版的发布!
声明:本文内容由网友自发贡献,本站不承担相应法律责任。对本内容有异议或投诉,请联系2913721942#qq.com核实处理,我们将尽快回复您,谢谢合作!
若转载请注明出处: Vue3 尝鲜 Hook + TypeScript 取代 Vuex 实现图书管理小型应用
本文地址: https://pptw.com/jishu/292982.html
