作者:树上的男爵
链接:https://zhuanlan.zhihu.com/p/345963989
来源:知乎
vue3已经出来一段时间了,而我最近也在公司的一个项目中充分使用了vue3的特性。相比vue2,vue3的整个的编码方式有不小变化,如果要写出简洁优雅的代码,可能还是需要一定的时间去摸索。在摸索的过程中,需要面对的其中一个问题就是:vue3我怎么去更好的进行状态管理?
在vue2时代,官方给我们提供了现成的状态管理工具vuex,它的使用方式借鉴了react生态的redux,定义一个状态变量,然后再定义它的getter, setter,以及异步变更action方法。总的来说,这套方案满足日常业务开发是完全没有问题的——只不过写起来稍显繁琐。
然而,现在已经进入vue3时代,vuex的弊端就更加明显。首当其冲的第一点:不能很好地贴合typescript。vue3已经用ts重写,充分发挥了ts类型系统的作用;vuex目前整个设计来看,我在使用状态变更方法的时候,传入的居然都是字符串名?!看看官方文档中的示例:
actions: {
async actionA ({ commit }) {
commit('gotData', await getData())
},
async actionB ({ dispatch, commit }) {
await dispatch('actionA') // 等待 actionA 完成
commit('gotOtherData', await getOtherData())
}
}
这样完全没法做到智能化的类型提示,对重度依赖ts的本人来说,真的无法接受。
vuex还有第2点弊端:啰嗦的语法,以及和vue3函数式风格api的割裂——虽然相比第一点倒还不算太大的问题,但用起来还是觉得膈应。总之对我而言,有了这两大弊端基本就宣告了vuex这套方案的死刑。
于是,我在实际项目中开始摸索一套较为合理的状态管理方案。我将目光转移到了vue3自有的一套api:provide/inject。
文档中的解释是,在父组件中调用provide函数,第一个参数传入字符串token,第二个参数传入子组件需要访问的对象。接着在子组件在调用inject函数,传入同样一个token,就能拿到该对象值了。代码如下:
// Parent.vue
import { provide } from 'vue';
provide('person', {name: 'bob', age: 20});
//Child.vue
import { inject } from 'vue';
const person = inject('person');
这是最简单的示例,告诉我们怎么通过provide/inect在父子组件中传值。初看之下好像并没多大卵用,因为传下去的只是个普通对象啊,并不具备响应式更新的能力。但是,请记住,我们现在使用的是vue3,想要有响应式能力,我们传ref/reactive对象就可以了嘛!于是把代码稍微改改:
(父组件)
// Parent.vue
<script lang="ts" setup>
import { provide, reactive } from "vue";
const person = reactive({name: 'bob', age:32});
provide('person', person);
</script>
<template>
<div>
<child></child>
</div>
</template>
(子组件)
// Child.vue
<script lang="ts" setup>
import { inject, onMounted } from 'vue';
const person = inject('person');
onMounted(() => {
person.age = 25;
})
</script>
<template>
<div>
我叫{{person.name}} 我的年龄:{{person.age}}
</div>
</template>
在父组件,通过provide提供了一个reactive响应式对象;然后在子组件通过inject注入该对象。在子组件修改对象的age属性,视图就会响应式更新!同样的,如果child组件也有自己的子组件,调用inject同样有效。这点我就不多讲了,毕竟这对古老的api在vue2时代就已经存在,只不过在vue3,他俩终于不再鸡肋,反而是可堪大用!
现在,问题似乎已经得到解决。有了provide/inject和ref/reactive配合,父子组件/兄弟组件共享状态的问题已经迎刃而解。但随着业务的深入,我陷入了沉思,这个方案还是有严重问题,需要抢救。主要体现在两点:
第一点,我们的provide方法,要传的参数仍然是个字符串啊!
provide/inject目前的设计是,他们之间是靠一个字符串(或symbol)来建立暗号的,暗号对上了,我就把相应的值给你。可要是业务复杂了,暗号多了,一不留神其中一个拼写错误,那是不是得找花眼?而且,这种字符串传参大法仍然没有很好的类型提示,看看我在vscode 编辑器中写的代码:
person下的age和name属性,直接有一条红线提示:类型“unkown”上不存在age属性。为啥,因为inject函数本身是需要传泛型的,如果不传,系统就会认为inject返回的对象类型位unkown。
也就是讲,我每次调用inject方法,还得手动写个类型声明?像下面这样:
const person = inject<{name: string; age: string}>('person');
不好意思,typescript不是这样用的。
第二点:直接在provide中传一个响应式对象,缺少封装性和逻辑复用能力。
现在回头看看vuex,虽然写法挺啰嗦,类型提示不友好,但是它提供的是整套状态管理方案,它使得组件之间不仅共享状态,还能复用一系列更改状态的逻辑方法。mutation action干的就是这个事。
再看看我们先前的做法:直接往下传reactive对象,至于状态更改的逻辑,还是写在组件里了,这就很不科学。这样一想,我们是不是得在单独一个文件,建一个大点的对象,把状态和状态变更逻辑都包含进去?比如:
//person.ts
const personStore = {
state: reactive({
name: bob,
age: 20
}),
setAge(n: number) {
this.state.name = n;
}
}
看上去有点vuex那味了,而且还用上了vue3致力于抛弃掉的this。怎么看都极其不优雅。甚至还做不到class面向对象的初始化能力和封装性。毕竟有些方法可能只是内部调用,并不希望全部暴露出去。而且在编码过程中我发现自己封装的通用hook函数不能很方便地在这种状态对象中使用。总而言之,在vue3整个框架内内显得水土不服。
提了这么多问题,那我们该怎么办?就这样妥协下去,早点完成业务功能然后下班?
欸。。等等,我们从头捋一遍,回到最初的问题——尤雨溪为什么要发布变化如此之大的vue3? 我们为什么又非得从vue2切换到vue3?
官方的解释是,使用composition api,能得到更强的逻辑复用能力。意思就是,以前相当多的业务逻辑写在组件里,现在可以很方便的抽象出去,单独放在一个函数内了。官方文档还给了个示例:
// src/composables/useRepositoryNameSearch.js
import { ref, computed } from 'vue'
export default function useRepositoryNameSearch(repositories) {
const searchQuery = ref('')
const repositoriesMatchingSearchQuery = computed(() => {
return repositories.value.filter(repository => {
return repository.name.includes(searchQuery.value)
})
})
return {
searchQuery,
repositoriesMatchingSearchQuery
}
}
上面是将一个搜索功能抽象为组合式函数的示例,当然我更愿意和react统一称为hook函数,在hook函数内部,我们可以使用组件生命周期钩子函数如onMounted,也就是可以通过hook函数执行一些初始化逻辑而不是非得在组件内。与此同时,在hook中定义的响应式变量可以return出去给组件访问。如果hook内部做出了变更操作,组件视图也会进行相应更新。
这就是vue3在逻辑复用方面的强大能力。但是,这跟状态管理有啥关系呢?
关系太大了。
思考到现在,结论已经很明显。而且我觉得这很可能是vue3官方的一个疏漏,只着重宣传hook函数(官方文档叫组合式函数)的逻辑复用能力,却不提这套方案用来做状态管理也是极为自然的。大概由于vuex/redux这类方案对开发者的影响太深,所以一提到状态管理,就总是以他们为起点开始思考,最终搞出来的也只是个变种版的redux。其实,有了hook这样的编码方式,状态管理的问题就注定被其染指。现在,是时候抛弃vuex这类跟函数式风格及其不搭的思想包袱了。
想一想,每个组件使用一次hook函数,函数都会调用一次从而形成全新的执行上下文和闭包。假设有hook函数f,返回响应式变量 x,若在组件A, B分别使用hook函数f,他们得到的只是一个专属于自己的变量x。可是,在某些业务场景下,我想让A和B共享同一个变量x怎么办?
provide/inject这对cp又重新登场了。
思考如下业务场景:我写了一个hook函数,里面全是与用户相关的逻辑:比如用户信息的修改以及登录与注销。一般情况下,跟用户相关的状态和逻辑有可能在各处组件都能用到,显而易见,它应该是全局唯一的,如果是每个组件单独去使用这个hook函数,就没法共享用户相关的状态变量。
export function useUserInfo() {
const userInfo = reactive({ });
const login = (data) => {...};
const logout = () => {...};
const editUserInfo => (data) => {};
return {
userInfo,
login,
logout,
editUserInfo
}
}
那么直接在根组件调用provide,将userHook函数传入会怎样?
//app.vue
<script>
import {provide} from 'vue';
import {useUserInfo} from '@/hooks/userUserInfo';
provide('user', useUserInfo())
</script>
恭喜你发现了华点!现在一切都豁然开朗了:有了provide之后,可以在下面的login组件使用inject访问useUserInfo函数返回的对象,调用该对象的login方法;在logOut组件调用该对象返回的logOut方法,在userInfo组件使用返回的userInfo变量渲染用户信息……一切都是那么自然,只要任意一处执行了态变更的逻辑,所有相关组件都能响应更新。
视图以外,皆是HOOK。
vue3时代,这就是我的编程理念。组件只负责访问hook返回的响应式变量丢给模板,其余的事情比如业务逻辑,状态管理,全是hook的事情。这就是视图与逻辑与状态的彻底分离。所以说,react hook 和vue3推出之后,前端时代真的变了(当然,作为angular的铁粉,我认为angular 3年前已经走到这一步,可惜来得太早成为了先烈)。
总之,讲了这么久,状态管理的最终方案是有了,那就是回归hook。如果希望hook内部的状态与逻辑在多个组件内共享,那只需要在hook的基础上加上一个provide/inject;如果你希望hook函数在每个组件都生成全新的状态,像以前那样组件内照常使用就行。这样一想,我们的解决办法好像并没有做什么新的动作,相比vuex那一套简单明了多了。所以说,hook出现之后,状态管理的问题已经从根本上被消解了。
但是等等……ts类型提示的问题好像还是没解决啊。其实解决这个问题只需要对provide/inject进行一层封装就好了。废话不多说,直接上我在项目实践中写的代码:
//定义一个用于状态共享的hook函数的标准接口
export interface FunctionalStore<T extends object> {
(...args: any[]): T;
token?: symbol;
root?: T;
}
//对原生provide进行封装
//由于inject函数只会从父组件开始查找,所以useProvider默认返回hook函数的调用结果,以防同组件层级需要使用
export function useProvider<T extends object>(func: FunctionalStore<T>): T {
!func.token && (func.token = Symbol('functional store'));
const depends = func();
provide(func.token, depends);
return depends;
}
// 可以一次传入多个hook函数, 统一管理
export function useProviders(...funcs: FunctionalStore<any>[]) {
funcs.forEach( func => {
!func.token && (func.token = Symbol('functional store'));
provide(func.token, func());
});
}
//对原生inject进行封装
type InjectType = 'root' | 'optional';
//接收第二个参数,'root'表示直接全局使用;optional表示可选注入,防止父组件的provide并未传入相关hook
export function useInjector<T extends object>(func: FunctionalStore<T>, type?: InjectType) {
const token = func.token;
const root = func.root;
switch(type) {
case 'optional':
return inject<T>(token) || func.root || null;
case 'root':
if(!func.root) func.root = func();
return func.root;
default:
if(inject(token)) {
return inject<T>(token)
};
if(root) return func.root;
throw new Error(`状态钩子函数${func.name}未在上层组件通过调用useProvider提供`);
}
}
以上,就是我基于vue3 provide/inject 实现的状态管理方案,只有2个基本api,useProvider和useInjector,具有完全的hook函数能力,以及完备的自动类型提示。请注意这两函数传入的都是hook函数本身而不是字符串token。token我是直接挂在函数的属性里了,省去了编码时手动填token的动作。
下面是我在公司项目实际使用的案例:
app.vue根组件一次性传入5个hook函数
[图片上传失败...(image-ce1bba-1629858474421)]
其中一个hook函数的简单实现(useState是对vue ref的封装,为了看起来像react ...)
在子组件注入对应的hook函数,可以看到编辑器的类型提示(完全不用手动申明类型)
因为这个项目业务不算复杂,所以看上去并没有很好体现这套方案的强大之处。当然可伸缩性也是它的一个优点,项目不复杂也随便用。随着业务扩展,我认为这套方案也是可以一直hold住整个项目的。