VueUse中的createSharedComposable会比Pinia更好用吗?

874 阅读18分钟

前言

本文的主题是VueUse中的CreateSharedComposable会比Pinia更好用吗。讨论这个话题之前会对VueUse、createSharedComposable、Pinia做一下简单的介绍,并且依据VueUse的源码对createSharedComposable做一些改动,对于没有使用过createSharedComposable甚至VueUse提供更全面的开发思路,下面就让我们开始吧。

Pinia

Pinia是一个专门的状态管理库,提供了更加集中化和高级的状态管理模式。它以 “store” 的概念为核心,将应用的状态集中存储在一个或多个 store 中。每个 store 可以包含多个状态(state)、修改状态的操作(actions)和基于状态的计算属性(getters)。这里不做过多介绍,有很多关于Pinia的好文章以及官网链接戳这里

VueUse

VueUse 是一个基于 Vue.js 的工具库,它提供了一系列的组合式函数(Composables),这些函数可以帮助开发者更方便地处理各种常见的逻辑,如 DOM 操作、状态管理、动画效果、传感器数据获取等。它是为了在 Vue 3 的组合式 API(Composition API)环境下,简化开发流程和提高代码复用性而创建的。

VueUse使用过的人都说好,其实不仅仅是使用层面上的好,更可以在其源码上找到很多优秀的编码思路以及代码设计,我们平时也会去封装各种插件、工具,研究VueUse各种函数的封装实现真的会提升你的能力!后面我将介绍其中一个函数CreateSharedComposable,看一下他的魅力之处吧。 链接戳这里

CreateSharedComposable

CreateSharedComposable官方文档 这里的介绍很简单

import { createSharedComposable, useMouse } from '@vueuse/core'

const useSharedMouse = createSharedComposable(useMouse)

// CompA.vue
const { x, y } = useSharedMouse()

// CompB.vue - will reuse the previous state and no new event listeners will be registered
const { x, y } = useSharedMouse()
  • @vueuse/core中引入createSharedComposable,useMouse是用于在 Vue 组件中获取与鼠标相关的信息,比如鼠标的当前位置(x 和 y 坐标)等的组合式函数。使用createSharedComposable对useMouse进行包装,创造一个新的组合式函数useSharedMouse。这样做的目的是为了让后续在不同的组件中使用这个组合式函数时能够共享其状态和相关的操作逻辑。如果没有做这样的共享处理时,如果在两个不同的组件(比如 CompA.vue 和 CompB.vue)中都直接使用 useMouse,那么每个组件内部都会独立地注册事件监听器来获取鼠标信息,这不仅会导致重复的操作,而且可能会在内存中占用额外的资源,并且如果两个组件对鼠标信息的处理方式不同(比如一个组件只关心鼠标在某个区域内的移动,另一个组件关心整个页面的鼠标移动),还可能会出现数据不一致的情况。

  • 包裹后在A组件调用useSharedMouse,这时候内部会执行行相关的逻辑来获取鼠标的初始位置信息,并注册相应的事件监听器来实时更新 x 和 y 的值,以反映鼠标的后续移动情况。所以在B组件中调用useSharedMouse的时候由于之前在创建 useSharedMouse 时使用了 createSharedComposable 进行了共享处理,所以这里在 CompB.vue 中调用时,并不会重新执行一遍 useMouse 内部所有的初始化操作(比如重新注册事件监听器等),而是会复用之前在 CompA.vue 中已经执行过的操作所得到的状态。

  • 也就是说,CompB.vue 会直接获取到已经存在的与鼠标位置相关的信息(即复用了 CompA.vue 中已经获取到的 x 和 y 坐标以及相关的状态),并且不会再额外注册新的事件监听器来获取鼠标信息,这样就避免了重复的操作和资源浪费,同时保证了在不同组件之间获取到的鼠标信息是一致的。

这里代码就这几行,但是上面详细的解释了createSharedComposable的作用,当然这里的A、B组件的调用useSharedMouse并不存在先后顺序,因为他们的数据是共享的。下面分析一下源码,看是如何实现这个功能的

createSharedComposable源码分析

先上🐴看看

import type { EffectScope } from 'vue-demi'
import type { AnyFn } from '../utils'
import { effectScope } from 'vue-demi'
import { tryOnScopeDispose } from '../tryOnScopeDispose'

/**
 * Make a composable function usable with multiple Vue instances.
 *
 * @see https://vueuse.org/createSharedComposable
 */
export function createSharedComposable<Fn extends AnyFn>(composable: Fn): Fn {
  let subscribers = 0
  let state: ReturnType<Fn> | undefined
  let scope: EffectScope | undefined

  const dispose = () => {
    subscribers -= 1
    if (scope && subscribers <= 0) {
      scope.stop()
      state = undefined
      scope = undefined
    }
  }

  return <Fn>((...args) => {
    subscribers += 1
    if (!scope) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    tryOnScopeDispose(dispose)
    return state
  })
}
1. 导入必要的类型和函数
import type { EffectScope } from 'vue-demi';
import type { AnyFn } from '../utils';
import { effectScope } from 'vue-demi';
import { tryOnScopeDispose } from '../tryOnScopeDispose';
  • vue-demi库中导入了EffectScope类型定义和effectScope函数。EffectScope用于表示一个作用域,在这个作用域内可以管理相关的响应式副作用(如watchcomputed等),effectScope函数则用于创建这样的作用域。
  • ../utils目录下导入了AnyFn类型定义,通常用于表示任意的函数类型。
  • ../tryOnScopeDispose文件中导入了tryOnScopeDispose函数,该函数的作用是在指定的作用域被销毁时尝试执行一个回调函数(用于清理相关资源等操作)。
2. 函数定义及初始变量声明
xport function createSharedComposable<Fn extends AnyFn>(composable: Fn): Fn {
  let subscribers = 0;
  let state: ReturnType<Fn> | undefined;
  let scope: EffectScope | undefined;

  //...后续代码
}
  • 定义了一个名为createSharedComposable的函数,它接受一个类型为FnFn是扩展自AnyFn的任意函数类型)的参数composable,并且返回值类型也是Fn

  • 在函数内部,声明了三个变量:

    • subscribers:用于记录当前有多少个实例在使用由createSharedComposable包装后的组合式函数。初始值为0
    • state:用于存储组合式函数composable的执行结果。初始时为undefined
    • scope:用于创建一个EffectScope,它将作为组合式函数执行以及相关响应式副作用管理的作用域。初始也为undefined
3. dispose函数的定义
const dispose = () => {
  subscribers -= 1;
  if (scope && subscribers <= 0) {
    scope.stop();
    state = undefined;
    scope = undefined;
  }
}
  • dispose函数用于在某个使用组合式函数的实例不再需要时(即subscribers减到小于等于0时)进行资源清理和状态重置操作。

  • 首先将subscribers的值减1,表示有一个实例不再使用了。

  • 然后检查如果scope存在且subscribers已经小于等于0,就会执行以下操作:

    • 通过scope.stop()停止当前的EffectScope,这会导致在这个作用域内注册的所有响应式副作用(如watchcomputed等)都停止执行,从而释放相关资源。
    • state重置为undefined,因为此时已经没有实例在使用这个组合式函数的结果了,所以可以清理掉这个状态。
    • scope也重置为undefined,同样是为了清理掉这个作用域相关的资源和状态信息。
4. 返回包装后的组合式函数
return <Fn>((...args) => {
 subscribers += 1;
 if (!scope) {
   scope = effectScope(true);
   state = scope.run(() => composable(...args));
 }
 tryOnScopeDispose(dispose);
 return state;
})
  • 返回了一个新的函数,这个函数就是经过包装后的组合式函数,它的类型被强制转换为Fn

  • 在这个新函数内部:

    • 首先将subscribers的值加1,表示又有一个实例开始使用这个包装后的组合式函数。

    • 然后检查如果scope还未创建(即!scope),就会执行以下操作:

      • 通过effectScope(true)创建一个新的EffectScope,这里的true参数可能表示创建一个独立的作用域(具体含义可能根据vue-demi库的实现而定)。
      • 在新创建的scope内,通过scope.run(() => composable(...args))执行原始的组合式函数composable,并将执行结果存储到state中。
    • 接着调用tryOnScopeDispose(dispose),这是为了确保当当前这个新函数所在的作用域(比如某个 Vue 组件的setup函数所在的作用域)被销毁时,能够自动执行dispose函数,从而进行资源清理和状态重置等操作。

    • 最后,返回state,也就是原始组合式函数composable的执行结果,供调用者使用。

通过上述代码,createSharedComposable函数实现了将一个普通的组合式函数包装成一个可在多个实例间共享的组合式函数的功能。它通过管理subscribers数量、EffectScope以及相关的资源清理操作,确保了在多个 Vue 实例使用同一个组合式函数时能够合理地共享状态、避免重复执行昂贵的操作以及正确地进行资源清理。

源码链接戳这里

createSharedComposable相对Pinia的优势、劣势和应用场景

  • 优势

    • 轻量级共享机制:核心功能是实现组合式函数的共享,通过内部的EffectScope和订阅者计数机制,简单直接地避免了在多个组件使用同一组合式函数时的重复计算和资源浪费。例如,对于一个获取用户位置信息的组合式函数useUserLocation,在多个组件中使用createSharedComposable进行包装后,只会在首次调用时执行获取位置的操作,后续调用复用结果,减少了不必要的开销。
    • 与组合式函数紧密结合:它的设计理念紧密围绕组合式函数展开。在以组合式函数为主要逻辑构建方式的 Vue 项目中,这种方式非常自然,开发者可以很容易地将已有的组合式函数进行包装,实现共享。比如,在一个大量使用useXXX形式的函数来获取数据或状态的项目里,createSharedComposable能够无缝融入,不需要改变原有的函数编写风格和基本逻辑。
  • 劣势

    • 缺乏集中式管理架构:在处理复杂应用的状态时,没有像 Pinia 那样提供一个集中的状态存储和管理机制。当应用中有多个相关的共享状态需要协同管理,或者状态之间存在复杂的依赖和交互关系时,createSharedComposable可能会使状态分散在各个组合式函数中。例如,在一个电商应用中,如果要管理购物车状态、用户信息状态以及商品库存状态之间的复杂交互,使用createSharedComposable可能会导致这些状态管理逻辑分散在不同的组合式函数中,难以进行统一的协调和维护。
    • 功能有限性:相比 Pinia,其功能较为基础。它主要关注组合式函数的共享和基本的资源管理,缺少一些高级的状态管理特性。例如,没有像 Pinia 中的 getters 用于方便地计算和派生状态,也没有提供像状态持久化这样的功能。在需要对状态进行复杂的转换、持久化或者批量操作时,使用createSharedComposable可能会比较麻烦。
  • 应用场景

    • 简单组合式函数共享:适用于在少数几个组件之间共享简单的状态获取或计算逻辑的组合式函数。例如,在一个简单的表单页面,有多个组件需要共享一个验证表单数据有效性的组合式函数,createSharedComposable可以有效地实现这个函数的共享,避免在每个组件中都重复执行验证逻辑。
    • 局部状态复用优化:当需要对一些计算成本较高的组合式函数(如涉及复杂计算或者频繁的网络请求获取数据)进行复用,并且这些函数主要在局部的相关组件中使用时,createSharedComposable可以发挥很好的作用。比如,在一个数据可视化页面,多个图表组件可能都需要使用一个组合式函数来获取和预处理数据,使用它可以优化性能,避免重复获取和处理数据。

上面对createSharedComposable源码做了详尽的解释,也分析了它的优劣和应用场景当我们了解了这种工具函数的编写思路之后,会很大的提升我们对这类问题处理的能力,或者说在了解本质之后我们在使用它遇到问题的时候也可以更方便的去做更改和扩展

对createSharedComposable进行修改扩展

这小节的内容就是上面源码分析的延展,我们了解清楚了createSharedComposable的原理,但是在使用中也发现了其不足之处。源码中的createSharedComposable它主要聚焦于通过 EffectScope 管理资源、根据 subscribers 数量进行资源清理以及在多个实例间共享状态等方面。当调用由它包装后的组合式函数时,它会执行相关逻辑(如创建作用域、执行被包装的函数等),但并没有一种方式让外部代码能方便地知道这个包装后的函数何时完成了可能存在的异步操作(比如被包装的函数内部有异步请求或者异步的计算等情况),也没有提供一种机制让外部可以等待这个异步操作完成后再进行一些依赖其结果的后续操作。这个时候我们可以自己拓展createSharedComposable这个hooks

新建一个ts文件,命名就叫createSharedComposable.ts,通常我们会将它放在hooks文件夹下,直接上修改后的代码,然后我们逐步分析

import { effectScope, type EffectScope, getCurrentScope } from "vue";
import { type AnyFn, tryOnScopeDispose } from "@vueuse/core";

// 泛型类型约束,确保传入的可组合函数返回一个确定类型的值
export function createSharedComposable<
  Fn extends AnyFn,
  ReturnTypeFn extends ReturnType<Fn>
>(
  composable: Fn
): Fn & {
  readonly subscribers: number;
  readonly state: ReturnTypeFn | undefined;
  // 将scope的类型改为可空的EffectScope类型
  readonly scope: EffectScope | null;
  readonly runReadyPromise: Promise<ReturnTypeFn | undefined>;
} {
  let subscribers = 0;
  let state: ReturnTypeFn | undefined;
  let scope: EffectScope | null = null;

  // 创建一个用于控制异步操作完成的Promise对象及对应的resolve函数
  let resolveRunReady: ((value: ReturnTypeFn | undefined) => void) | undefined;
  const runReadyPromise: Promise<ReturnTypeFn | undefined> = new Promise(
    (resolve) => {
      resolveRunReady = resolve;
    }
  );

  const dispose = () => {
    subscribers -= 1;
    if (scope && subscribers <= 0) {
      scope.stop();
      state = undefined;
      scope = null;
      // 当所有订阅者都取消订阅且作用域停止时,重新创建runReadyPromise
      createNewRunReadyPromise();
    }
  };

  const createNewRunReadyPromise = () => {
    resolveRunReady = undefined;
    const newPromise = new Promise<ReturnTypeFn | undefined>((resolve) => {
      resolveRunReady = resolve;
    });
    runReadyPromise.then(() => newPromise);
  };

  const fn = ((...args) => {
    if (!getCurrentScope()) {
      throw new Error("createSharedComposable should inside setup function");
    }
    subscribers += 1;
    if (!state) {
      scope = effectScope(true);
      state = scope.run(() => composable(...args)) as ReturnTypeFn;
      // 当首次创建状态时,runReadyPromise
      resolveRunReady?.(state);
    }
    tryOnScopeDispose(dispose);
    return state;
  }) as Fn;

  return Object.defineProperties(fn, {
    subscribers: {
      get() {
        return subscribers;
      },
    },
    state: {
      get() {
        return state;
      },
    },
    scope: {
      get() {
        return scope;
      },
    },
    runReadyPromise: {
      get() {
        return runReadyPromise;
      },
    },
  }) as unknown as Fn & {
    readonly subscribers: number;
    readonly state: ReturnTypeFn | undefined;
    readonly scope: EffectScope | null;
    readonly runReadyPromise: Promise<ReturnTypeFn | undefined>;
  };
}

对比一下源码,我们这里的改动有哪些

1. 泛型类型定义的细化
//源码版本
export function createSharedComposable<Fn extends AnyFn>(composable: Fn): Fn {
  //...
}

仅使用一个泛型参数 Fn 来约束传入的可组合函数的类型,返回值类型也直接复用 Fn

  • 修改后版本
export function createSharedComposable<
  Fn extends AnyFn,
  ReturnTypeFn extends ReturnType<Fn>
>(
  composable: Fn
): Fn & {
  //...
} {
  //...
}

除了 Fn 用于约束可组合函数本身的类型外,新增了 ReturnTypeFn 泛型参数来明确表示可组合函数 composable 的返回值类型。并且返回值类型不再简单复用 Fn,而是一个包含了更多属性(如 subscribersstate 等)且与细化后的泛型类型相关的交叉类型。

2. scope 类型的调整
  • 原码版本
let scope: EffectScope | undefined;

scope 的类型定义为可空的 EffectScope 类型,即它可能是一个有效的 EffectScope 对象,也可能是 undefined

  • 修改后版本
let scope: EffectScope | null = null;

将 scope 的类型改为可空的 EffectScope 类型,但初始值明确设置为 null,并且在后续处理(如资源清理时)也将其设置为 null,与原始版本的 undefined 处理方式有所不同。

3. 异步操作相关处理的增强
  • 原码版本
    没有针对异步操作完成后的处理提供专门的机制,主要侧重于共享状态管理和资源清理等方面。

  • 修改后版本

    • 新增了 resolveRunReady 函数和 runReadyPromise 相关逻辑来处理异步操作完成的情况。
let resolveRunReady: ((value: ReturnTypeFn | undefined) => void) | undefined;
const runReadyPromise: Promise<ReturnTypeFn | undefined> = new Promise(
  (resolve) => {
    resolveRunReady = resolve;
  }
);

创建了一个 Promise 对象 runReadyPromise 以及对应的 resolve 函数 resolveRunReady,用于在可组合函数首次执行完成(包括其中的异步操作完成)时进行状态通知。

  • 在资源清理逻辑(dispose 函数)中,当所有订阅者都取消订阅且作用域停止时,除了清理 state 和 scope,还会重新创建 runReadyPromise,以确保后续再次使用该共享组合式函数时能正确处理异步操作完成的通知。
const createNewRunReadyPromise = () => {
    resolveRunReady = undefined;
    const newPromise = new Promise<ReturnTypeFn | undefined>((resolve) => {
      resolveRunReady = resolve;
    });
    runReadyPromise.then(() => newPromise);
  };
  • 在可组合函数执行逻辑(fn 函数)中,当首次创建状态时,会通过 resolveRunReady?.(state) 来解决 runReadyPromise,从而通知外部代码可组合函数(包括可能的异步操作)已完成首次执行。
4. 返回值属性的明确类型定义
  • 原码版本
    返回值只是简单地将包装后的函数通过类型强制转换为 Fn,没有对添加的属性(如 subscribersstate 等)进行详细的类型定义说明。
  • 修改后版本
return Object.defineProperties(fn, {
    subscribers: {
      get() {
        return subscribers;
      },
    },
    state: {
      get() {
        return state;
      },
    },
    scope: {
      get() {
        return scope;
      },
    },
    runReadyPromise: {
      get() {
        return runReadyPromise;
      },
    },
  }) as unknown as Fn & {
    readonly subscribers: number;
    readonly state: ReturnTypeFn | undefined;
    readonly scope: EffectScope | null;
    readonly runReadyPromise: Promise<ReturnTypeFn | undefined;
  };

对返回值添加的各个属性(subscribersstatescoperunReadyPromise)都明确给出了类型定义,并且通过交叉类型详细说明了返回值整体的类型结构,使得返回值的类型信息更加清晰准确,便于在使用该函数时进行类型检查和正确的代码编写。

拓展源码后的好处

1. 更精确的类型安全
  • 通过细化泛型类型定义,特别是明确了可组合函数返回值的类型 ReturnTypeFn,以及对返回值各个属性的详细类型定义,使得在使用 createSharedComposable 函数时,TypeScript 能够提供更准确的类型检查。这有助于在开发过程中更早地发现类型相关的错误,提高代码的质量和可维护性。例如,在获取 state 属性时,能够明确知道其具体类型应该是 ReturnTypeFn 或 undefined,而不是像原码版本那样相对模糊的类型信息。
2. 更清晰的资源状态管理
  • 将 scope 的类型初始化为 null 并在整个处理过程中保持一致的 null 或有效 EffectScope 对象的处理方式,相比于原始版本的 undefined 处理,使得资源管理相关的逻辑更加清晰。在代码阅读和理解上,更容易明确 scope 的状态变化情况以及与其他操作(如资源清理、重新创建 runReadyPromise 等)的关联,有助于避免因类型不明确(undefined 可能带来的一些隐式类型转换等问题)导致的错误。
3. 更好的异步操作支持
  • 新增的异步操作处理机制为使用共享组合式函数的场景带来了很大的便利。当可组合函数内部存在异步操作(如异步数据获取、异步计算等)时,外部代码可以通过 await 这个 runReadyPromise 来等待可组合函数完成首次执行(包括异步操作完成),然后再进行后续依赖其结果的操作。这使得在处理异步相关的业务逻辑时,能够更加有序地进行代码编写和执行,避免了因异步操作未完成而导致的潜在问题(如获取到未初始化的状态等)。同时,在资源清理后重新创建 runReadyPromise 的机制也确保了在后续再次使用该共享组合式函数时,异步操作完成的通知机制依然能够正常工作。

总体而言,修改后的 createSharedComposable 实现方式在类型安全、资源管理和异步操作支持等方面都有了明显的改进,使得我们自己依据源码实现的 createSharedComposable在复杂的 Vue 应用开发场景中能够更加稳健、高效地发挥作用。

结语

内容到这里就结束了,从一开始我们简单介绍Pinia、VueUse,然后引出文章的主题createSharedComposable,分析了它的源码、讲解了相对于Pinia的优劣势以及应用场景,最后我们依据实际开发对createSharedComposable进行了拓展修改(当然它已经完全足够直接使用了),看到这里的同学应该是把这篇文章至少大概看了一遍吧,哈哈哈。希望能对看到这篇文章的同学有一定帮助,有什么不正确的地方也欢迎讨论一起进步,在这种寒冬的就业环境下多学习一点也能多安心一点吧。。。。。。

Salute!!