Vue3造“hooks”轮子前先看看这个

17,007 阅读8分钟

Vue3 带来的 Composition API 让各种业务逻辑有了组合的概念,各个业务逻辑彼此可能是独立的,也可能是耦合的,这样就需要把一些公共的逻辑抽离出来,造个属于自己的"hooks"轮子,那么 vue3 怎么来造这个轮子,是否能沉淀出一个 react 中 ahooks 一样的东西呢?

怎么造轮子

组合思路

Composition API 的设计理念就是把接口的可重复部分及其功能提取到可复用的代码段中,增加代码的可复用性、逻辑性,有点借鉴 react hooks 的意思。哪些逻辑需要封装起来进行复用就是造轮子的关键,所以还得从存量问题中寻找公共逻辑复用的方案。

Composition API: v3.cn.vuejs.org/guide/compo…

vue-hook1.png

这是一个经典的vue的框架结构, Template 和 script 通过双向绑定进行数据流的更新,各种第三方的插件也交错引用在业务逻辑中。业务量飞速增长的时候, script 下的逻辑代码就会无限膨胀,所以轮子封装的方向应该是两个:

  • 公共业务逻辑抽离
  • 第三方库的二次封装

vue-hook2.png

我喜欢叫这个封装的组合式函数Vue-Hooks ,因为封装的思路和 react 自定义 hooks 有些类似,上图中也可以看出,Vue-Hooks的封装和沉淀分别来源于 module 和业务逻辑,使用"Hooks"轮子不能影响原先的业务逻辑,所以在保证复用的前提下,还得考虑到工具的可插拔性,多个使用场景的设计。

使用模式

首先看看自定义组合式函数怎么使用,Vue-Hooks本质上还是一个函数,所以我们只用关心传入的参数是什么,返回值是什么,然后是否能进行类型定义,这样就得到了如下图所示的使用模式。 vue-hook4.png

下面就按着这种使用模式做出了一个操作本地localStorageuseStorage,传入的第一个值是key,第二个是初始value,第三个参数是选填项,可以选择操作 sessionStorage 。

const state = useStorage<Student>('locale-setting', {
  name: 'lisi',
  class: 'machine',
})

然后按着设定好的使用模式,我们试着来封装一个 Vue-Hook 。

export function useStorage<T extends(string|number|boolean|object|null)> (key: string, defaultValue: T, storage: Storage = localStorage) {
  // step1 用ref初始一个响应式的数据
  const data = ref<T>(defaultValue)

  function read() {
    try {
      let rawValue = storage.getItem(key)
      if (rawValue === undefined && defaultValue) {
        // 把默认值处理成string类型存储
        rawValue = transValue(defaultValue)
        storage.setItem(key, rawValue)
      }
      else {
        // 把本地拿到的string类型数据做解析
        data.value = transValue(rawValue, defaultValue)
      }
    }
    catch (e) {
      console.warn(e)
    }
  }

  read()

  // step2 监听storage变化,如有其它页面也做修改,可以进行联动
  useEventListener('storage', read)

  // step3 监听data值变化,进行修改localStorage
  watch(
    data,
    () => {
      try {
        if (data.value == null)
          storage.removeItem(key)
        else
          storage.setItem(key, transValue(data.value))
      }
      catch (e) {
        console.warn(e)
      }
    },
    { flush: 'post', deep: true },
  )

  return data
}

step1

第一步先用 ref 把传入的默认值定义成一个响应式的数据,这样方便返回值的页面绑定操作

step2

useEventListener 进行事件监听(useEventListener等同于 window.addEventListener 绑定),这里会根据传入的值进行判断用不同的函数去转换,并且在浏览器端保持跨 tab 监听。

step3

利用 watch API 进行data的数据监听,如果数据发生任何改变,就会同步 localStorage 。

从上面的三步骤中可以发现:一个通用的 vue-hook 肯定离不开 vue3 的响应式API以及 Composition API ,搭配ts写起来也更加优雅。

VueUse

vue-hook3.png

vue的社区中其实已经有一个类似 ahooks 的工具集合,叫 VueUse ,VueUse 的灵感是来自于 react-use , react-use 已经沉淀得相当不错了,在 github 上有超过16k的star,VueUse 也有1.8k的star,而且增长迅速。 VueUse 趁着这一波 vue3 的更新,跟上了响应式API的潮流,官方的介绍说这是一个 Composition API 的工具集合,适用于vue2.x或者vue3.x,用起来和 react hooks 还挺像的。

下面是 VueUse 的特点:

  • ⚡ 零依赖:不用担心代码体积问题
  • 🌴 tree shaking结构:只会引入想要的代码
  • 🏷 强类型检查:ts代码全覆盖
  • 🕶 无缝迁移:Vue2和3都支持
  • 🌎 浏览器兼容性好:可直接CDN引入
  • 🔌 支持其他工具

传送门

使用入口

npm引入

npm i @vueuse/core # yarn add @vueuse/core

下面是cdn引入方式,浏览器环境可以直接使用 window.VueUse 调用API

<script src="https://unpkg.com/@vueuse/core"></script>

接下来就来介绍几个比较常用的API

createGlobalState

首先看到 globalState 就会想到全局状态管理,一般vue中进行跨组件公共状态管理用的是 vuex ,但是 vuex 是绑定在单个vue实例下的,而 vue-use 的 createGlobalState 是用来做跨vue实例的公共状态管理。

// store.js
import { createGlobalState, useStorage } from '@vueuse/core'

export const useGlobalState = createGlobalState(
  () => useStorage('vue-use-locale-storage'),
)

上面是定义了一个公共的store,结合之前的 useStorage 就能在各个不同的实例下创建的组件进行公共状态管理。

import { useGlobalState } from './store'

export default defineComponent({
  setup() {
    const state = useGlobalState()
    return { state }
  },
})

vue-hook5.png

如上图所示,实现 createGlobalState 的原理也很简单,就是创建一个 reactive 的初始值在 globalState 中,然后创建一个vue实例进行绑定,这样这个响应式的状态就能做到跨实例通用,同时因为是在 createApp 中调用的,初始化的逻辑或在所有组件的生命周期之前就被创建。

function withScope<T extends object>(factory: () => T): T {
  const container = document.createElement('div')

  let state: T = null as any

  //创建vue实例进行初始化绑定
  createApp({
    setup() {
      state = reactive(factory()) as T
    },
    render: () => null,
  }).mount(container)

  return state
}

export function createGlobalState<T extends object>(
  factory: () => T,
) {
  let state: T

  // 利用闭包对state做了一层缓存
  return () => {
    if (state == null)
      state = withScope(factory)

    return state
  }
}

useEventListener

上面封装 useStorage 的时候有提到useEventListener,那么这个API的设计肯定会考虑以下几点:

  • 事件类型
  • 监听回调逻辑
  • 事件捕获还是冒泡
  • 监听的挂载对象

还需要结合vue的生命周期进行考虑,如果挂载对象还没在页面中加载成功,js的上下文就拿不到对应的dom实例,也就无法完成绑定事件逻辑,必须要在 mounted 的生命周期里进行绑定。同时,当组件卸载的时候也得把已经绑定的事件卸载,防止资源浪费,这些就能被抽离成公共的逻辑了。

export function useEventListener(
  type: string,
  listener: EventListenerOrEventListenerObject,
  options?: boolean | AddEventListenerOptions,
  target: EventTarget = window,
) {
  // 处理mounted绑定逻辑
  tryOnMounted(() => {
    target.addEventListener(type, listener, options)
  })

  // 处理unMounted的解绑逻辑
  tryOnUnmounted(() => {
    target.removeEventListener(type, listener, options)
  })
}

export function tryOnMounted(fn: () => void, sync = true) {
  // 先判断当前是否存在vue实例
  if (getCurrentInstance())
    onMounted(fn)
  else if (sync)
    fn()
  else
    nextTick(fn)
}

从上面的代码可以看到,核心处理还是在于 tryOnMouned 和tryOnUnMouned的实现,对整个生命周期做了兜底处理。

useAsyncState

vue项目中的数据请求一般会用到 axios , useAsyncState 对异步操作做了一些封装,非常适合结合 axios 来使用,让异步的逻辑更加清晰,不用把赋值的操作写进 resolve 回调中。

const { state, ready } = useAsyncState(
  axios
  .get('https://jsonplaceholder.typicode.com/todos/1')
  .then(t => t.data),
  {},
  2000,
)

任何 promise 的操作都可以用 useAsyncState 进行包裹,ready返回值一般也可以用作请求过渡状态的loading。

export function useAsyncState<T>(
  promise: Promise<T>,
  defaultState: T,
  delay = 0,
  catchFn = (e: Error) => {},
) {
  // 初始化state值
  const state = ref(defaultState)
  const ready = ref(false)

  function run() {
    promise
      .then((data) => {
        state.value = data
        ready.value = true
      })
      .catch(catchFn)
  }

  // 可设置延迟执行
  if (!delay)
    run()
  else
    useTimeoutFn(run, delay)

  return { state, ready }
}

useDebounceFn/useThrottleFn

讲这两个API之前先补充一个小知识,你知道防抖( debounce )和节流( throttle )的区别吗?

防抖:

触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次被触发,则重新计算时间

节流:

高频事件触发,但在n秒内只会执行一次,所以节流会稀释函数的执行频率

所以两者之前的区别在于每次触发事件时等待执行的延时函数是否需要**重新定义,**防抖一般会用作窗口缩放事件的监听,而节流会用作 input 输入事件的监听。

useDebounceFn

export function useDebounceFn<T extends Function>(fn: T, delay = 200): T {
  if (delay <= 0)
    return fn

  let timer: ReturnType<typeof setTimeout> | undefined

  function wrapper(this: any, ...args: any[]) {
    const exec = () => {
      timer = undefined
      return fn.apply(this, args)
    }
		// 如果已经存在时间回调,重新刷新定时器
    if (timer)
      clearTimeout(timer)

    timer = setTimeout(exec, delay)
  }

  return wrapper as any as T
}

useThrottleFn

export function useThrottleFn<T extends Function>(fn: T, delay = 200, trailing = true): T {
  if (delay <= 0)
    return fn

  let lastExec = 0
  let timer: ReturnType<typeof setTimeout> | undefined
  let lastThis: any
  let lastArgs: any[]

  function clear() {
    if (timer) {
      clearTimeout(timer)
      timer = undefined
    }
  }

  function timeoutCallback() {
    clear()
    fn.apply(lastThis, lastArgs)
  }

  function wrapper(this: any, ...args: any[]) {
    const elapsed = Date.now() - lastExec

    clear()
		// 比较上一次执行的事件和这次的gap
    if (elapsed > delay) {
      lastExec = Date.now()
      fn.apply(this, args)
    }
    else if (trailing) {
      // 记录当前执行栈的上下文
      lastArgs = args
      lastThis = this
      timer = setTimeout(timeoutCallback, delay)
    }
  }

  return wrapper as any as T
}

useWindowScroll

useWindowScroll 是用来监听页面的滚动事件, useEventListener 直接监听 window 的 scroll 事件,做控制 sidebar 的显示隐藏还是比较方便的。

// 使用方法
const { x, y } = useWindowScroll()

export function useWindowScroll() {
  const x = ref(isClient ? window.pageXOffset : 0)
  const y = ref(isClient ? window.pageYOffset : 0)

  useEventListener(
    'scroll',
    () => {
      x.value = window.pageXOffset
      y.value = window.pageYOffset
    },
    {
      capture: false,
      passive: true,
    })

  return { x, y }
}

结束

Vue-Use 还提供了其他操作 firebase , rxjs 的api,各位掘友在封装自己的Hooks的时候也可以先看下Vue-Use里是否有可以替代的函数,还可以结合自身的业务场景进行二次封装,沉淀自己的hooks,为社区做一些小贡献岂不美哉~

创造不易,希望掘有多多 点赞 + 关注 二连,持续更新中!!!

PS: 文中有任何错误,欢迎掘友指正

往期精彩📌

公众号:前端小DT