阅读 1297

Vue3你还在用Vuex?一个“函数式”状态管理的新思路

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函数

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

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

因为这个项目业务不算复杂,所以看上去并没有很好体现这套方案的强大之处。当然可伸缩性也是它的一个优点,项目不复杂也随便用。随着业务扩展,我认为这套方案也是可以一直hold住整个项目的。现在我们可以设想一个应用场景,来看看hook+provide/inject能做到什么地步。

vue3开发中,想必很多人会基于组合api写一个通用的ajax请求hook函数,一些针对请求的通用逻辑都放在这里面,类似下面的代码:

export function useRequest<R>(url: string, option: any) {
  const res = ref<R>(null);
  const status = ref('pending');

  const checkHttpStatus = (status: number) => {
    /** 处理错误status状态码的相关逻辑 */
  }

 onMounted(() => {
  fetch(url, option).then( response => {
    if(response.ok) {
      res.value = response.body;
      status.value = 'success'
    } else {
      checkHttpStatus(response.status);
      status.value = 'failed'
    }    
  });
 })

 return {
   res,
   status   
 }
}
复制代码

以上是一个比较简单的通用请求函数,在组件内或其他hook内使用时自动发送请求。但在很多场景下,这点代码是难以hold整个业务的。比如常见的,对请求拦截进行权限认证。

那么怎么办呢,直接在useRequest里面加入权限验证的相关代码?这样耦合太严重。单独写在一个函数内然后调用?嗯,可以。但是,如果我们的视角抬得更高一点,这个useRequest hook想要在公司通用库中被多个项目使用,这样直接调用权限验证函数好像不太行,每个项目的业务逻辑都不一样啊。

provide/inect再再次登场!

首先,我们先定一个暗号token,用来表示我们的useRequest需要的请求拦截功能,如下:

export const  HTTP_INTERCEPT = Symbol('intercept');
复制代码

现在,如果你的一个同事正在使用你写的useRequest方法,并且想使用请求拦截功能。那么,他首先需要写一个拦截函数,接着把引入上面的暗号HTTP_INTERCEPT,赋值给函数的token属性(请参考我上面定义过的一个functionalStore接口!)

//httpIntercept.ts
export function httpIntercept() {
  const auth = useAuth();
  const intercept = () => {
    /** 通过auth来判断,useAuth你就当是该项目存在的一个hook  返回一个Promise */
   return new Promise( resolve => { ... })
  }

  return  intercept  
}

httpIntercept.token = HTTP_INTERCEPT
复制代码

接着在app.vue通过useProvider提供该拦截函数:

//app.vue
<script>
  useProvider(httpIntercept);
</script>
复制代码

好了,接下来我们的useRequest需要改造一下

export function useRequest<R>(url: string, option: any) {
  const res = ref<R>(null);
  const status = ref('pending');
 
 //注意第二个参数optional,表示拦截器可选,上层组件如果没有提供拦截器函数则默认返回null
  const intercept  = useInjector(HTTP_INTERCEPT, 'optional');

  const checkHttpStatus = (status: number) => {
    /** 处理错误status状态码的相关逻辑 */
  }

  const request = () => {
    fetch(url, option).then( response => {
      if(response.ok) {
        res.value = response.body;
        status.value = 'success'
      } else {
        checkHttpStatus(response.status);
        status.value = 'failed'
      }
      
    });
  }

 onMounted(() => {
  if(!intercept) {
    request()
  } else {
    intercept().then( _ => {
      request()
    })
  }
  
 })

 return {
   res,
   status   
 }
}
复制代码

可以看到,结合了provide/inect的hook函数,简直是将代码的复用和解耦发挥到了极致!状态管理,不过是顺带而来的便利罢了。

文章分类
前端
文章标签