Vue3
带来的 Composition API
让各种业务逻辑有了组合的概念,各个业务逻辑彼此可能是独立的,也可能是耦合的,这样就需要把一些公共的逻辑抽离出来,造个属于自己的"hooks"轮子,那么 vue3
怎么来造这个轮子,是否能沉淀出一个 react
中 ahooks
一样的东西呢?
怎么造轮子
组合思路
Composition API
的设计理念就是把接口的可重复部分及其功能提取到可复用的代码段中,增加代码的可复用性、逻辑性,有点借鉴 react hooks
的意思。哪些逻辑需要封装起来进行复用就是造轮子的关键,所以还得从存量问题中寻找公共逻辑复用的方案。
这是一个经典的vue的框架结构, Template
和 script
通过双向绑定进行数据流的更新,各种第三方的插件也交错引用在业务逻辑中。业务量飞速增长的时候, script
下的逻辑代码就会无限膨胀,所以轮子封装的方向应该是两个:
公共业务逻辑抽离
第三方库的二次封装
我喜欢叫这个封装的组合式函数为 Vue-Hooks
,因为封装的思路和 react
自定义 hooks
有些类似,上图中也可以看出,Vue-Hooks
的封装和沉淀分别来源于 module
和业务逻辑,使用"Hooks"轮子不能影响原先的业务逻辑,所以在保证复用的前提下,还得考虑到工具的可插拔性,多个使用场景的设计。
使用模式
首先看看自定义组合式函数怎么使用,Vue-Hooks
本质上还是一个函数,所以我们只用关心传入的参数是什么,返回值是什么,然后是否能进行类型定义,这样就得到了如下图所示的使用模式。
下面就按着这种使用模式做出了一个操作本地localStorage
的useStorage
,传入的第一个值是key,第二个是初始value,第三个参数是选填项,可以选择操作 sessionStorage
。
const state = useStorage('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(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的社区中其实已经有一个类似 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.jsimport { 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 } },})
如上图所示,实现 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, 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,为社区做一些小贡献岂不美哉~