vueuse库的探索与实践

1,386 阅读8分钟

vuesue是什么

vueuse是一个可组合式函数的工具库,它提供了一系列常见的、可复用的函数,可以帮助您更快地开发Vue 3应用程序。它提供了许多有用的功能,如状态管理、表单处理、副作用管理、计时器、窗口尺寸检测、鼠标位置跟踪等,并且支持tree-shaking,只导入您需要的功能,避免打包不必要的代码,从而优化您的应用程序性能。VueUse库非常灵活,易于使用,适合在各种规模的应用程序中使用。

composables api与options api

我们都知道vue2中采用的是options api,在使用的过程中我们发现了一些问题

  • 不利于逻辑复用,通常需要使用mixin才能进行逻辑复用,而mixin会到来更多的问题
  • 上下文丢失,data和methods间隔很远,开发的过程中需要切换来切换去
  • 代码可读性差,难以维护和拓展
  • 不好进行单元测试,通常在方法里面使用了this来访问,不方便进行单元测试
  • typeScript类型支持不够友好

正是基于options api的这些缺点,vue3采用了composable api的形式来组织代码,用composables api来组织代码拥有以下的优点

  • 利于逻辑复用,可以将代码按功能和模块封装成可复用的代码
  • 可以进行灵活的组合
  • 更好的上下文语义
  • 更好的typeScript类型支持
  • 可以脱离vue组件,进行单独使用
  • 易于进行单元测试
  • 代码可读性强,易于维护和拓展

composiable api的核心

在于建立输入和输出的连接,输出会自动根据输入的改变而改变

我们都知道普通的函数,输入和输出是不会建立联系的,改变输入的值,输出的值不会跟着改变,例如:

function add(a: number, b: number){
  return a + b
}
let a = 1
let b = 2
const c = add(a + b) // 3

a = 3
console.log(c) // 3

以上代码,我们改变a的值,不会直接影响输入的c的结果。

而我们利用vue3的refcomputed等响应式api,可以实现输入和输出的连接,当改变输入的值时,输出的值会跟着改变

function add(a: Ref<number>, b: Ref<number>){
  return computed(()=> a.value + b.value)
}
const a = ref(1)
const b = ref(2)
const c = add(a,b)
console.log(unref(c)) // 3

a.value = 2
console.log(unref(c)) // 4

上面的代码通过ref和computed建立了输入和输出的连接,当输入a的值改变之后,输出c的值也会跟着改变,这就是组合式api跟普通函数最大的不同之处

当然add函数的参数既可以是ref也可以是普通变量

function add(
  a: Ref<number> | number,
  b: Ref<number> | number
){
  return computed(()=> unref(a) + unref(b))
}

通过改造之后,组合式函数的输入参数就更加灵活了。

我们可以抽象一个MaybeRef的类型工具来约束这种既可以是ref也可以是普通数据的参数

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

让输入和输出建立连接的更高级技巧

我们通过一个案例来实现更高级的将输入和输出建立连接的技巧,写一个实现useTitle的方法

type MaybeRef<T> = Ref<T> | T
export function useTitle(
  newTitle: MaybeRef<string | null | undefined>
) {
  const title = ref(newTitle || document.title)
  watch(title,(t)=>{
    if(t !== null)
      document.title = t;
  },{ immediate: true })

  return {
    title,
  };
}

上面我们定义了useTitle函数的入参是一个MaybeRef类型,我们都知道computed的返回值也是一个ref类型,那么也可以给useTitle的参数传入一个computed,这样也能建立输入与输出的连接。

const isDark = useDark()
useTitle(computed(()=> isDark.value? 'dark Mode' : 'Light mode'))

如果我想这样使用呢?

const isDark = useDark()
useTitle(() => isDark.value? 'dark Mode' : 'Light mode'))

也就是我们想将入参改成既支持computed又支持computed getter函数,进行改造的话,可以定义一个MaybeComputedRef类型,这个类型表示参数可以是一个ref,一个getter函数,一个computedRef

type MaybeComputedRef<T> = MaybeRef<T> | (()=> T) | ComputedRef<T>

我们都知道普通的函数是没有响应式能力的,那么需要对传入的getter函数进行处理,定义一个工具方法resolveRef,将传入的函数用computed进行包裹一下就可以了

function resolveRef<T>(input: MaybeRef<T>): Ref<T> {
  return typeof input === 'function'
  ? computed(input)
  : ref(input)
}

这里要注意ref包裹一个ref返回的还是一个ref

function ref(input){
  return isRef(input) 
    ? input
    : createRef(input)
}

下面对useTitle进行改造

export function useTitle(
  newTitle: MaybeComputedRef<string | null | undefined>
) {
  const title = ref(resolveRef(newTitle ?? document.title))
  watch(title,(t)=>{
    if(t !== null)
      document.title = t;
  },{ immediate: true })

  return {
    title,
  };
}

改造之后就可以支持传入getter函数了。

与之相反还有一个resolveUnref方法

function resolveUnref<T>(input: MaybeComputedRef<T>): T {
  return typeof input === 'function'
    ? input()
    : unref(r)
}

在vueuse库中大量用到了这个方法

export function useFloor(value: MaybeComputedRef<number>): ComputedRef<number> {
  return computed<number>(() => Math.floor(resolveUnref(value)))
}

如何在组合式函数里清除副作用函数

我们都知道执行watch会返回一个回调函数,调用watch执行后的回调函数就可以清除这个watch的副作用

const count = ref(0)
const stop = watch(count,(val)=>{
  console.log(`count:${val}`)
})
count.value += 1 // count: 1
stop()
count.value += 1 // 无输出

那么我们可以仿造watch,将清除副作用函数的方法返回出去

function useEventListener(target: EventTarget, event:string, callback:(e: any )=> void){
  const cleanup = ()=>{
    target.removeEventListener(event,callback)
  }
  onMounted(()=>{
    target.addEventListener(event,callback)
  })
  onUnmounted(cleanup)
  return cleanup
}
const stop = useEventListener(document,'click',()=>{ })
// 手动清除
stop()

但是会存在以下问题,当一个函数调用多次,则需要多次调用stop回调方法

function useMouse(){
  const stop1 = useEventListener(document,'mousedown',() => { })
  const stop1 = useEventListener(document,'click',( )=> { })
  const stop1 = useEventListener(document,'mousemove',() => { })
  const cleanup = ()=>{
    stop1()
    stop2()
    stop3()
  }
  return cleanup
}

可以采用effectScope解决上面的问题,efffectScope可以集中收集所有的副作用函数,进行统一清除,这样就不需要在return 一个清除副作用的stop函数

import { effectScope } from 'vue'
const scope = effectScope()
scope.run(() => {
  watch(count,()=>{})
  useEventListener(document,'mousedown',() => { })
  seEventListener(document,'click',() => { })
})
scope.stop()

为了达到这种效果,调用onScopeDispose可以收集并且清除副作用函数

可以将onUnmounted 替换为onScopeDispose,当组件被销毁时,会自动调用清理函数

function useEventListener(target: EventTarget, event:string, callback:(e: any )=> void){
  const cleanup = () => {
    target.removeEventListener(event,callback)
  }
  onMounted(()=>{
    target.addEventListener(event,callback)
  })
  onScopeDispose(cleanup)
}

实现一个useDark

vueuse里面有一个useDark函数,我们来模仿实现一下

import { ref, watch, unref } from 'vue';
import { useLocalStorage } from '@vueuse/core';

export function useDark() {
  const isDark = ref(false)
  const mode = ref('')

  if (typeof window !== 'undefined') {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    mode.value = mediaQuery.matches ? 'dark' : 'light'

    const isDarkStore = useLocalStorage('isDark', null)

    if (isDarkStore.value === null) {
      isDark.value = mediaQuery.matches
    } else {
      isDark.value = JSON.parse(isDarkStore.value)
    }

    watch(isDark, (value: boolean) => {
      document.documentElement.classList.toggle('dark', value)
      isDarkStore.value = value
    }, { immediate: true })

    mediaQuery.addEventListener('change',(event) => {
      mode.value = event.matches ? 'dark' : 'light'
      if (isDarkStore.value === null) {
        isDark.value = event.matches
      }
    });
  }
  function toggleDark() {
    isDark.value = !isDark.value;
  }

  return { isDark, mode, toggleDark }
}

在html上添加不同的模式class之后,就可以通过如下css变量来实现暗黑模式的切换效果

:root {
  /* 背景顏色 */
  --c-bg: #fff;
}

html,
  body {
  background-color: var(--c-bg);
}

html.dark {
  --c-bg: #121212;
}

上面的实现中借助了window.matchMedia,我们也可以将这个方法封装为一个useMediaQuery()方法

windows.matchMedia方法用来动态监听媒体查询的变化,基本用法如下。

const mediaQuery = window.matchMedia("(max-width: 768px)");

function handleDeviceChange(e) {
  if (e.matches) {
    // 当设备宽度小于等于 768px 时执行的代码
  } else {
    // 当设备宽度大于 768px 时执行的代码
  }
}

mediaQuery.addEventListener(''handleDeviceChange); // 添加监听器
handleDeviceChange(mediaQuery); // 初始化执行一次

下面将这个api封装成一个组合式useMediaQuery方法

import { ref, watchEffect, onScopeDispose } from 'vue'

export function useMediaQuery(query: string) {
  const mediaQuery = window.matchMedia(query)
  const matches = ref(mediaQuery.matches)

  const update = () => {
    matches.value = mediaQuery.matches
  }

  mediaQuery.addEventListener('change',update)

  function cleanUp(){
    mediaQuery.removeEventListener('change', update)
  }
  onScopeDispose(cleanUp)

  return matches
}

useMediaQuery的使用

const isLargeScreen = useMediaQuery('(min-width: 1024px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')

通过阅读vusue的源码我们发现,还有一个获取当前设备的暗黑模式的方法usePreferredDark(),利用已经封装好的useMediaQuery方法可以很简单实现这个方法,代码如下

export function usePreferredDark() {
  return useMediaQuery('(prefers-color-scheme: dark)')
}

现在我们用封装好的useMediaQuery方法,和usePreferredDark方法对useDark方法进行改进

import { ref, watch, unref } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { usePreferredDark } from './usePreferredDark';

export function useDark() {
  const isDark = ref(false)
  const mode = ref('')

  if (typeof window !== 'undefined') {
    const preferredDark = usePreferredDark()
    mode.value = preferredDark.value ? 'dark' : 'light'
    const isDarkStore = useLocalStorage('isDark', null)
    function update(){
      if (isDarkStore.value === null) {
        isDark.value = preferredDark.value
      } else {
        isDark.value = JSON.parse(isDarkStore.value)
      }  
    }
    watch(isDark, (value: boolean) => {
      document.documentElement.classList.toggle('dark', value)
      isDarkStore.value = value
    }, { immediate: true })

    watch(preferredDark,(value)=> {
      update()
    })
  }

  function toggleDark() {
    isDark.value = !isDark.value;
  }

  return { isDark, mode, toggleDark }
}

通过实现一个useDark方法,相信我们对可组合式api有了一个更深入的理解,可组合式api可以将单一职责的方法进行封装,形成一块块积木,然后可以将这些积木拼接起来,实现更多的功能。

关于vueuse库的一些知识

@vueuse/components如何让组合式api支持以组件形式使用

<UseEyeDropper v-slot="{ isSupported, sRGBHex, open }">
      <button :disabled="!isSupported" @click="open">
          sRGBHex: {{ sRGBHex }}
      </button>
</UseEyeDropper>
import { useEyeDropper } from '@vueuse/core'

export const UseEyeDropper = defineComponent({
  name: 'UseEyeDropper',
  props: {
    sRGBHex: String,
  },
  setup(props, { slots }) {
    const data = reactive(useEyeDropper())

    return () => {
      if (slots.default)
        return slots.default(data)
    }
  },
})

使用window对象或者document对象时候, 要注意兼容ssr、测试环境、iframe,可使用ConfigurableWindow类型,为了能够在iframe和测试模拟以及ssr环境可进行可配置

export interface ConfigurableWindow {
  window?: Window
}
export const defaultWindow = isClient ? window : undefined
export function useBreakpoints<K extends string=string>(options: ConfigurableWindow = {}) {
  const { window = defaultWindow } = options
}

相对应的还有

export const defaultDocument = isClient ? window.document : undefined
export const defaultNavigator = isClient ? window.navigator : undefined
export const defaultLocation = isClient ? window.location : undefined

export interface ConfigurableDocument {
  document?: Document
}

export interface ConfigurableNavigator {
  navigator?: Navigator
}

export interface ConfigurableLocation {
  location?: Location
}

多层次嵌套的数据使用shallowRef

export function useFetch<T>(url: MaybeRef<string>) {
  // use `shallowRef` to prevent deep reactivity
  const data = shallowRef<T | undefined>()
    const error = shallowRef<Error | undefined>()

    fetch(unref(url))
    .then(r => r.json())
    .then(r => data.value = r)
    .catch(e => error.value = e)

  /* ... */
}

对于浏览器还未大量使用的api,需要进行isSupported判断

export function useEyeDropper() {
  const isSupported = useSupported(() => typeof window !== 'undefined' && 'EyeDropper' in window)
  async function open() {
    if (!isSupported.value)
      return
  }
  return { isSupported }
}

本文参考资料

Anthony Fu 在2021年和2022年的VUECONF会议上的分享视频