揭秘Vueuse是怎么利用effectScope实现React hook的特性的?

1,457 阅读6分钟

1. 前言

你应该具备的知识

1. TypeScript基础知识(对这篇文章并不是很重要,但后面会说关于类型的问题)
2. JavaScript基础知识
3. Vue3的基础知识

什么是Vueuse

image.png 官网是这样介绍的

Collection of essential Vue Composition Utilities

通俗的翻译过来就是

基于Vue Compistion API的一些基本函数库

这个库目前是我在Vue项目中经常使用的库,不过周下载量并不是很高,看来宣传的力度还是不强呀

image.png

对比React的hook库

image.png

这个库很有意思,有各种奇奇怪怪的钩子,甚至有检测你设配当前电池状态的钩子,我当前的设备就在充电,它给我检测出来了

image.png

还能监听你的内存状态

image.png

不过我的内存是16GB,为什么到这就是4GB了,难道是监听的当前网页的内存么?

作者信息

这个库是由Vue核心成员Anthony Fu发起的

image.png

我个人是很崇拜这位大神的,尤其是他那满满的绿点和commit的次数,无不震撼着我这幼小的心灵

如何使用

举个例子吧,比如说你当前想获取用户鼠标的坐标时,就可以用到一个hook -- useMouse

import { useMouse } from '@vueuse/core'

const { x, y, sourceType } = useMouse()

而其中的类型定义如下

export declare type MouseSourceType = "mouse" | "touch" | null

export declare function useMouse(options?: MouseOptions): {
  x: Ref<number>
  y: Ref<number>
  sourceType: Ref<MouseSourceType>
}

可以发现x y的类型都是Ref,所以我们使用的时候需要.value一下

什么是effectScope

这是官网的介绍

Effect 作用域是一个高阶的 API,主要服务于库作者。关于其使用细节请咨询相应的 RFC

创建一个 effect 作用域对象,以捕获在其内部创建的响应式 effect (例如计算属性或侦听器),使得这些 effect 可以一起被处理。

类型

function effectScope(detached?: boolean): EffectScope

interface EffectScope {
  run<T>(fn: () => T): T | undefined // 如果这个域不活跃则为 undefined
  stop(): void
}

示例

const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  watch(doubled, () => console.log(doubled.value))

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 处理该作用域内的所有 effect
scope.stop()

#getCurrentScope

如果有,则返回当前活跃的 effect 作用域

类型

function getCurrentScope(): EffectScope | undefined

onScopeDispose

在当前活跃的 effect 作用域上注册一个处理回调。该回调会在相关的 effect 作用域结束之后被调用。

该方法在复用组合式函数时可用作 onUnmounted 的非组件耦合替代品,因为每个 Vue 组件的 setup() 函数也同样在 effect 作用域内被调用。

我推荐大家去看对应的RFC以此来了解更多的信息

下面我介绍一下RFC中所说的effectScope的动机

In Vue's component setup(), effects will be collected and bound to the current instance. When the instance get unmounted, effects will be disposed automatically. This is a convenient and intuitive feature.
However, when we are using them outside of components or as a standalone package, it's not that simple. For example, this might be what we need to do for disposing the effects of computed & watch

大意就是说

在Vue组件中我们可以用类似于onUnmounted这种生命周期钩子来做一些在组件销毁时我们想做的事情,但当我们想在组件之外来做时,就不是那么容易了,比如你想在组件外实现一个钩子,在组件销毁时去做一些事情,这时你用unMounted就会出现一些问题,这时我们就需要effectScope的帮助了

为什么需要effectScope

举个RFC中的例子 假设你现在需要得到用户鼠标的坐标,你是这样写的

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function handler(e) {
    x.value = e.x
    y.value = e.y
  }

  window.addEventListener('mousemove', handler)

  onUnmounted(() => {
    window.removeEventListener('mousemove', handler)
  })

  return { x, y }
}

如果你像这样去实现useMouse,那么你在不同的组件中使用useMouse的时候,每个组件都会创建和重新计算x y,如果你有大量的组件都需要使用useMouse,那就会增加性能开销,这并不好,所以这时我们需要effectScope

下面看看effectScope是如何实现的

替换onUnmountedonScopeDispose

onUnmounted(() => {
    window.removeEventListener('mousemove', handler)
})
// 改为
onScopeDispose(() => {
  window.removeEventListener('mousemove', handler)
})

创建一个函数去管理useMouse的使用

使用闭包的原理来管理状态,以此来确保xy只计算一次

function createSharedComposable(composable) {
  let subscribers = 0
  let state, scope

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

  return (...args) => {
    subscribers++
    if (!state) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    onScopeDispose(dispose)
    return state
  }
}

现在我们可以使用useMouse像这样

const useSharedMouse = createSharedComposable(useMouse)

现在,我们就能拿到对应的x, y了

我对这个例子做了一个小demo,大家可以点开看看

image.png

证明useSharedMouse最后雀氏只会计算一次,大大的提高性能,防止眼神有些快的小伙伴错过链接,我在这补上demo的链接 stackblitz.com/edit/vue-jk…

effectScope在Vueuse中的使用

useEventListener

useEventListener的作用

以我们熟悉的useMouse为例子,在源码中,并不是使用

window.addEventListener('mousemove', handler)

来直接监听事件的,而是使用

useEventListener(window, 'mousemove', mouseHandler, { passive: true })

明显可以猜出这个钩子是拿来监听事件的,并且在组件销毁后,自动注销事件,避免内存的泄漏,下面我们来探究useEventListener的实现

源码探究

React的hook可以在组件销毁时自动执行hook返回出的函数,比如这样

const useMouse = () => {
    const [pos, setPos] = useState({ x: 0, y: 0 })
    
    const handler = (e) => {
        setPos({
            x: e.x,
            y: e.y
        })
    }
    
    window.addEventListener('mousemove', handler)
    
    const destroyEventListenr = () => {
        widnow.removeEventListener('mousemove', handler)
    }
    
    return () => {
      destroyEventListenr()
    }
}

这样在组件销毁的时候,会自动调用

() => {
  destroyEventListenr()
}

这就是React Hook特性

那么如何在Vue中实现呢?先不管它怎么实现,我们先来研究一下Vueuse中useEventListener是如何实现的

interface InferEventTarget<Events> {
  addEventListener(event: Events, fn?: any, options?: any): any
  removeEventListener(event: Events, fn?: any, options?: any): any
}

export function useEventListener(...args: any[]) {
  let target: MaybeRef<EventTarget> | undefined
  let event: string
  let listener: any
  let options: any

  if (isString(args[0])) {
    [event, listener, options] = args
    target = defaultWindow
  }
  else {
    [target, event, listener, options] = args
  }

  if (!target)
    return noop

  let cleanup = noop

  const stopWatch = watch(
    () => unref(target),
    (el) => {
      cleanup()
      if (!el)
        return

      el.addEventListener(event, listener, options)

      cleanup = () => {
        el.removeEventListener(event, listener, options)
        cleanup = noop
      }
    },
    { immediate: true, flush: 'post' },
  )

  const stop = () => {
    stopWatch()
    cleanup()
  }

  tryOnScopeDispose(stop)

  return stop
}

具体逻辑不作过多解释,我解释一下其中的MaybeRef类型

export type MaybeRef<T> = T | Ref<T>

React Hook特性的实现

useEventListener的源码中,它是这样实现React Hook特性的

  const stop = () => {
    stopWatch()
    cleanup()
  }

  tryOnScopeDispose(stop)

stop()看字面意思就能猜出是注销事件的,重点在于tryOnScopeDispose

下面分析一下tryOnScopeDispose的源码

export function tryOnScopeDispose(fn: Fn) {
  if (getCurrentScope()) {
    onScopeDispose(fn)
    return true
  }
  return false
}

其中getCurrentScope说白了就是判断当前是否存在effectScope作用域,也就是你是不是在组件内部操作 比如你在main.js中操作

import { getCurrentScope } from 'vue'
console.log(getCurrentScope()) // undefined

而你在一个SFC文件中操作

import { getCurrentScope } from 'vue';

export default {
  setup() {
    const { state, computedTime } = useSharedMouse();

    console.log(getCurrentScope());
  }
}

输出的就是

image.png

所以tryOnScopeDispose就是判断你当前的代码是不是在一个SFC文件内执行的,是的话那就执行onScopeDispose,而onScopeDispose,上面介绍了,是类似于onUnmounted的操作

所以,useEventListener利用Vue的特性实现了React Hook的特性

不过有趣的是,vueuse这个库的作者也是effectScope这个SFC的作者,可以做一个猜想,Anthony Fu在做Vueuse这个库的时候,遇到了如何实现Hook特性的这个问题,因这个问题提出了effectScope这个概念

写在最后

读大佬的源码是一件很美的事情,不但能提高自身的编码能力,还能碰见许多有意思的问题

比如,在Vueuse中,有一个公共模块名为shared image.png

文章中的tryOnScopeDispose就在这个模块内 image.png

要是我一般就命名为common,不过shared这个词似乎更合适,因为在这个模块中,也有实现本文提到的createSharedComposable

好啦,完结撒花(^o^)/~,如果有什么疑问,欢迎大家来评论区讨论