[转]Vue3你还在用Vuex?一个“函数式”状态管理的新思路 provide/inject

作者:树上的男爵
链接: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 编辑器中写的代码:

image

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的动作。

下面是我在公司项目实际使用的案例:

image

app.vue根组件一次性传入5个hook函数

[图片上传失败...(image-ce1bba-1629858474421)]

其中一个hook函数的简单实现(useState是对vue ref的封装,为了看起来像react ...)

image

在子组件注入对应的hook函数,可以看到编辑器的类型提示(完全不用手动申明类型)

因为这个项目业务不算复杂,所以看上去并没有很好体现这套方案的强大之处。当然可伸缩性也是它的一个优点,项目不复杂也随便用。随着业务扩展,我认为这套方案也是可以一直hold住整个项目的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容