从 ahook 到 vhook:将 React Hook 思维带入 Vue

371 阅读7分钟

React HookComposition API 概念是很相似的。事实上在 React 大部分可用的 Hook 都可以使用 Vue3 再实现一遍,但是 Composition API 语法需要加强去了解一下。

以下示例代码均不复杂,还需结合实际业务场景开发。示例中有参考 ahooksVueUse 源码。(当然!也可以直接使用 ahooks VueUse

查看示例 demo

useDebounce

用来处理防抖函数的 Hook。

Params

参数说明类型
callback需要防抖的函数(...args: any[]) => any
delay等待时间,单位为毫秒number

Result

参数说明类型
run触发执行 callback,函数参数将会传递给 callback(...args: any[]) => any
cancel取消当前防抖() => void
import { ref, onUnmounted } from 'vue'

/**
 * @param {Function} callback - 需要防抖的函数
 * @param {number} delay - 防抖时间,默认100ms
 */
export default function useDebounce<T extends (...args: any[]) => any>(callback: T, delay = 100) {
  const timer = ref<NodeJS.Timeout | ReturnType<typeof setTimeout> | null>(null)

  const run = (...args: Parameters<T>) => {
    cancel()

    timer.value = setTimeout(() => callback(...args), delay)
  }

  const cancel = () => {
    timer.value !== null && clearTimeout(timer.value)
    timer.value = null
  }

  onUnmounted(cancel)

  return {
    run,
    cancel
  }
}

useThrottle

用来处理函数节流的 Hook。

Params

参数说明类型
callback需要节流的函数(...args: any[]) => any
delay等待时间,单位为毫秒number

Result

参数说明类型
run触发执行 callback,函数参数将会传递给 callback(...args: any[]) => any
cancel取消当前节流() => void
import { onUnmounted, ref } from 'vue'

/**
 * @param {Function} callback - 要执行的回调函数
 * @param {number} delay - 延迟时间,单位为毫秒,默认为100毫秒
 */
export default function useThrottle<T extends (...args: any[]) => void>(callback: T, delay = 100) {
  const timer = ref<NodeJS.Timeout | ReturnType<typeof setTimeout> | null>(null)

  const run = (...args: Parameters<T>) => {
    if (timer.value) return

    timer.value = setTimeout(() => {
      callback(...args)
      cancel()
    }, delay)
  }

  const cancel = () => {
    timer.value && clearTimeout(timer.value)
    timer.value = null
  }

  onUnmounted(cancel)

  return {
    run,
    cancel
  }
}

useDebounceuseThrottle 同样也可以使用 Vue3 customRef 去改造。

useEventBus

在应用程序中创建事件总线。在组件之间共享状态或信息时,提高代码的可维护性和可读性,同时减少组件之间的耦合性。

Result

参数说明类型
emit发送一个事件通知(val: T) => void
useSubscription订阅事件(callback: (val: T) => void) => void
import { ref, onUnmounted, onMounted } from 'vue'

import { Subscription } from './type'

/**
 * 事件订阅器,用于管理事件的订阅和发布
 */
export class EventEmitter<T> {
  private readonly subscriptions = new Set<Subscription<T>>()

  /**
   * 发布事件
   * @param val 事件数据
   */
  emit = (val: T) => {
    for (const subscription of this.subscriptions) {
      subscription(val)
    }
  }

  /**
   * 订阅事件
   * @param callback 事件回调函数
   */
  useSubscription = (callback: Subscription<T>) => {
    const callbackRef = ref<Subscription<T>>()
    callbackRef.value = callback

    onMounted(() => {
      const subscription = (val: T) => callbackRef.value?.(val)

      this.subscriptions.add(subscription)
    })

    onUnmounted(() => this.subscriptions.clear())
  }
}

/**
 * 事件订阅器的自定义 Hook
 * @returns 事件订阅器实例
 */
export default function useEventBus<T = void>() {
  const eventEmitterRef = ref<EventEmitter<T>>()
  if (eventEmitterRef.value == null) eventEmitterRef.value = new EventEmitter()

  return eventEmitterRef.value
}

useImage

在浏览器中响应式加载图像,你可以等待结果显示或显示一个回退方案

Params

参数说明类型
options图片加载参数UseImageOptions
asyncStateOptions异步状态参数UseAsyncStateOptions

UseImageOptions

参数说明类型
src图片路径string
srcset在不同情况下使用的图像,例如高分辨率显示器、小型显示器等string
sizes不同页面布局的图像大小string

UseAsyncStateOptions

参数说明类型
delay超时时间number
immediate是否立即执行boolean
onError失败回调(e: unknown) => void
onSuccess成功回调(data: D) => void
resetOnExecute是否重置状态boolean

Result

参数说明类型
isLoading加载状态boolean
isReady成功状态boolean
error失败状态ShallowRef<unknown>
import { watch, ref, shallowRef } from 'vue'

import { toValue } from '@/utils'

import { UseImageOptions, UseAsyncStateOptions } from './type'
import { MaybeRefOrGetter } from '@/utils/types'

function loadImage({ src, srcset, sizes }: UseImageOptions): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.src = src

    if (srcset) img.srcset = srcset
    if (sizes) img.sizes = sizes

    img.onload = () => resolve(img)
    img.onerror = reject
  })
}

/**
 * @param options 图片加载参数
 * @param asyncStateOptions 异步状态参数
 * @returns { isLoading, isReady, error }
 */
function useImage(
  options: MaybeRefOrGetter<UseImageOptions>,
  asyncStateOptions: UseAsyncStateOptions = {}
) {
  const {
    delay = 5000,
    immediate = true,
    onSuccess,
    onError,
    resetOnExecute = true
  } = asyncStateOptions
  const isLoading = ref(false)
  const isReady = ref(false)
  const error = shallowRef<unknown | undefined>(undefined)

  const loadImageWithTimeout = async () => {
    if (resetOnExecute) {
      isLoading.value = true
      isReady.value = false
    }

    return await Promise.race([
      loadImage(toValue(options)),
      new Promise((_resolve, reject) =>
        setTimeout(() => reject(new Error(`Image loading timed out`)), delay)
      )
    ])
      .then((res) => {
        isReady.value = true
        onSuccess?.(res)
      })
      .catch((e) => {
        error.value = e
        onError?.(e)
      })
      .finally(() => (isLoading.value = false))
  }

  if (immediate) loadImageWithTimeout()

  watch(
    () => toValue(options),
    () => loadImageWithTimeout(),
    { deep: true }
  )

  return {
    isLoading,
    isReady,
    error
  }
}

export default useImage

useInViewport

观察元素是否在可见区域,以及元素可见比例。 更多信息参考 Intersection Observer API

Params

参数说明类型
target目标元素Ref<T>|T
callback回调函数IntersectionObserverCallback
options设置项Options

Options

参数说明类型
root指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素,如果未指定或者为 null,则默认为浏览器视窗。Ref<T>|T
rootMargin根(root)元素的外边距string
threshold可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 ratio 会被更新number|number[]

Result

参数说明类型
stop停止监听函数() => void
import { ref, watch, onUnmounted } from 'vue'
import useSupported from '@/hooks/useSupported'

import { unrefElement } from '@/utils'

import { MaybeComputedElementRef } from '@/utils/types'
import { UseIntersectionObserverOptions } from './type'

/**
 * @param target 目标元素
 * @param callback 回调函数
 * @param options 选项
 * @returns 停止监听函数
 */
export default function useInViewport(
  target: MaybeComputedElementRef,
  callback: IntersectionObserverCallback,
  options: UseIntersectionObserverOptions = {}
) {
  const { root, rootMargin = '0px', threshold = 0.1 } = options
  const isSupported = useSupported(() => window && 'IntersectionObserver' in window)
  const intersectionObserverRef = ref<IntersectionObserver | null>(null)

  const stop = () => intersectionObserverRef.value && intersectionObserverRef.value.disconnect()

  watch(
    () => [unrefElement(target), unrefElement(root)] as const,
    ([target, root]) => {
      if (!isSupported.value || !target || !root) return

      intersectionObserverRef.value = new IntersectionObserver(callback, {
        rootMargin,
        threshold,
        root: unrefElement(root)
      })
      intersectionObserverRef.value.observe(target)
    },
    {
      immediate: true,
      flush: 'post'
    }
  )

  onUnmounted(stop)

  return { stop }
}

useIsWindowVisible

监听页面是否可见,参考 visibilityState API

Result

参数说明类型
isVisible判断 document 是否在是否处于可见状态boolean
import { ref, watchEffect, onUnmounted } from 'vue'

function isWindowVisible() {
  if (!(typeof document !== 'undefined' && 'visibilityState' in document)) return true

  return document.visibilityState === 'visible'
}

/**
 * 判断窗口是否可见
 * @param callback 回调函数,可选参数,当窗口可见性状态改变时调用
 */
function useIsWindowVisible(callback?: (event: boolean) => void) {
  const isVisible = ref(isWindowVisible())

  const handleVisibilityChange = () => (isVisible.value = isWindowVisible())
  const clear = () => document.removeEventListener('visibilitychange', handleVisibilityChange)

  watchEffect((onInvalidate) => {
    if (!('visibilityState' in document)) return undefined

    document.addEventListener('visibilitychange', handleVisibilityChange)
    callback?.(isVisible.value)

    onInvalidate(clear)
  })

  onUnmounted(clear)

  return isVisible
}

export default useIsWindowVisible

useMutationObserver

一个监听指定的 DOM 树发生变化的 Hook,参考 MutationObserver

Params

参数说明类型
target监听的元素Ref<T>|T
callback回调函数MutationCallback
options设置项MutationObserverInit

Result

参数说明类型
stop停止监听函数() => void
import { ref, watch, onUnmounted } from 'vue'
import useSupported from '../useSupported'

import { unrefElement } from '@/utils'

import { UseMutationObserverOptions } from './type'
import { MaybeElementRef } from '@/utils/types'

/**
 * @param {MaybeElementRef} target - 监听的元素
 * @param {MutationCallback} callback - 回调函数
 * @param {UseMutationObserverOptions} options - 配置项
 * @returns {Object} - 返回一个对象,包含 stop 方法
 */
const useMutationObserver = (
  target: MaybeElementRef,
  callback: MutationCallback,
  options: UseMutationObserverOptions = {}
) => {
  const isSupported = useSupported(() => window && 'MutationObserver' in window)
  const mutationObserverRef = ref<MutationObserver | null>(null)

  const stop = () => mutationObserverRef.value && mutationObserverRef.value.disconnect()

  watch(
    () => unrefElement(target),
    (el) => {
      if (!isSupported.value || !el) return

      mutationObserverRef.value = new MutationObserver(callback)
      mutationObserverRef.value.observe(el, options)
    },
    {
      immediate: true,
      flush: 'post'
    }
  )

  onUnmounted(stop)

  return { stop }
}

export default useMutationObserver

useRequest

异步数据管理的 Hooks,案例展示仅实现:自动请求/手动请求。更多参考 ahooks - useRequest

Params

参数说明类型
service请求函数类型(...args: P) => Promise<T>
options设置项Options

Options

参数说明类型
manual默认 false。 即在初始化时自动执行 service。如果设置为 true,则需要手动调用 run 触发执行。boolean
onSuccess成功回调(data: T, params: P) => void
onError失败回调(e: Error, params: P) => void

Result

参数说明类型
loadingservice 是否正在执行boolean
dataservice 返回的数据Ref<T>
errorservice 抛出的异常Ref<Error>
run手动触发 service 执行,参数会传递给 service。异常自动处理,通过 onError 反馈(...args: P) => Promise<void>
import { ref, onMounted } from 'vue'

import { noop } from '@/utils'
import { Service, Options } from './type'

/**
 * @template T 请求成功后返回的数据类型
 * @template P 请求函数的参数类型
 * @param {(...args: P) => Promise<T>} Service 请求函数类型
 * @param {object} Options 请求配置项
 * @property {boolean} [manual=false] 是否手动触发请求
 * @property {(data: T, args: P) => void} [onSuccess] 请求成功后的回调函数
 * @property {(error: Error, args: P) => void} [onError] 请求失败后的回调函数
 * @returns {{loading: Ref<boolean>, data: Ref<T | undefined>, error: Ref<Error | undefined>, run: (...args: P) => Promise<void>}}
 */

function useRequest<T, P extends any[]>(service: Service<T, P>, options?: Options<T, P>) {
  const { manual = false, onSuccess = noop, onError = noop } = options ?? {}
  const loading = ref(false)
  const data = ref<T>()
  const error = ref<Error>()

  const run = async (...args: P) => {
    loading.value = true

    try {
      const result = await service(...args)
      data.value = result
      onSuccess?.(result, args)
    } catch (e) {
      error.value = e as Error
      onError?.(e as Error, args)
    } finally {
      loading.value = false
    }
  }

  // @ts-expect-error
  onMounted(() => !manual && run())

  return {
    loading,
    data,
    error,
    run
  }
}

export default useRequest

useSetInterval

一个可以处理 setInterval 的 Hook。

Params

参数说明类型
callback定时器回调函数() => void
delay定时器间隔时间number
import { ref, watchEffect, onUnmounted } from 'vue'

/**
 * @param callback 定时器回调函数
 * @param delay 定时器间隔时间,默认为1000ms
 */
function useSetInterval(callback: () => void, delay: number = 1000) {
  const timer = ref<NodeJS.Timeout | ReturnType<typeof setTimeout> | null>(null)

  const clear = () => timer.value && clearInterval(timer.value)

  watchEffect((onInvalidate) => {
    timer.value = setInterval(callback, delay)

    onInvalidate(clear)
  })

  onUnmounted(clear)
}

export default useSetInterval

useTheme

在应用程序中轻松地使用主题变量,例如颜色、字体大小和样式,以及切换不同的主题,而无需手动管理这些值。可以节省大量的代码和时间。

Result

参数说明类型
theme当前主题Ref<Theme>
toggle切换主题(val: Theme) => Theme
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'

import { Theme } from './type'

const useTheme = defineStore(
  'theme',
  () => {
    const theme = ref('light')
    const toggle = (val: Theme) => (theme.value = val)

    watch(theme, (newVal, oldVal) => {
      document.querySelector('html')?.classList.remove(oldVal)
      document.querySelector('html')?.classList.add(newVal)
    })

    return { theme, toggle }
  },
  {
    persist: true
  }
)

export default useTheme