likes
comments
collection
share

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

作者站长头像
站长
· 阅读数 62

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

真的是被VueUse提供的功能折服了,就像标题所说:Vue中无处不use的VueUse。什么是VueUse? Vue是基于组合式API而封装的工具集,一部分功能是你非常想要的,另一部分功能是你都没想到会提供的,不信就接着往下看。

VueUse五年前问世,到目前已迭代至V11.0.3,npm周下载量接近100万,github标星19.6k。到目前功能多到什么程度?官方直接发文:Warning⚠️ Slowing down new functions,因为现有功能实在维护不过来了。

  • 为什么一部分功能是你非常想要的?

提供的状态化管理包含鼠标位置、localStorage、sessionStorage、history、元素大小、元素resize、快捷键等等。下图为状态化管理鼠标位置,使用useMouse即可轻松拿到坐标信息。

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

  • 为什么另一部分功能你都没想到会提供?

提供电池状态(useBattery)、剪切板(useClipboard)、颜色拾取(useEyeDropper)、设备列表(#useDevicesList)等系统级功能,涉及到系统级就牵扯到权限以及浏览器版本兼容性,所以使用时得斟酌。

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

常用Top15功能

VueUse提供了非常丰富的函数工具,覆盖State、Elements、Browser、Sensors、Animation、Component等。接下来看看常用的TOP 15函数有哪些?

top 1: State:useAsyncState

useAsyncState支持异步调用控制,当调用http接口并显示请求进度时非常适用,并且返回的数据自动支持响应式。

const { isLoading, state, isReady, execute } = useAsyncState(
  (args) => {
    const id = args?.id || 1
    return axios.get(`https://jsonplaceholder.typicode.com/todos/${id}`).then(t => t.data)
  },
  {}, // InitialState
  {
    delay: 2000,
    immediate: true,
    resetOnExecute: false,
  },
)

第一个入参为一个匿名函数,执行异步请求调用,args参数会通过execute函数传入。

方法返回中,isLoading控制请求状态,state为结果数据并且为响应式。execute(delay, args)为执行请求函数,其中args为请求参数。

源码浅析:

考虑到返回数据量大,可传入shallow限制仅浅监听。和watch类似,useAsyncState也支持immediate立即执行请求。

export function useAsyncState(...) {
  const {
    immediate = true,
    delay = 0,
    shallow = true,
  } = options ?? {}
  const state = shallow ? shallowRef(initialState) : ref(initialState)
  const isReady = ref(false)
  const isLoading = ref(false)
  const error = shallowRef<unknown | undefined>(undefined)

  async function execute(delay = 0, ...args: any[]) {...}

  if (immediate)
    execute(delay)

  return {
     state: state as Shallow extends true ? Ref<Data> : Ref<UnwrapRef<Data>>,
    isReady,
    isLoading,
    error,
    execute,
    then(onFulfilled, onRejected) {
      return waitUntilIsLoaded()
        .then(onFulfilled, onRejected)
    },
  }
}

最后返回的then有点意思,很少见这种写法,很好奇为什么要返回then? 使用者可通过then((data) => {})执行请求完成后的逻辑,但通过state就能拿到结果,then一般都不会去调用。

真正执行请求的是execute函数,先重置isLoading、state、isReady状态,然后发送请求,拿到数据后再更新状态。

  async function execute(delay = 0, ...args: any[]) {
    if (resetOnExecute)
      state.value = initialState
    error.value = undefined
    isReady.value = false
    isLoading.value = true

    const _promise = typeof promise === 'function'
      ? promise(...args as Params)
      : promise

    try {
      const data = await _promise
      state.value = data
      isReady.value = true
      onSuccess(data)
    }
    catch (e) {
      error.value = e
      onError(e)
      if (throwError)
        throw e
    }
    finally {
      isLoading.value = false
    }

    return state.value as Data
  }

top2: State:useStorage

和用户相关的配置经常需要缓存,可使用sessionStorage或者localStorage,但和Vue用起来就比较别扭,因为不能监听缓存变化。

有了useStorage你就可以像使用State一样轻松地使用localStorage和sessionStorage,useStorage(key, theDefault)两个参数,如果本地缓存中不存在key,则以theDefault作为初始值写入缓存。

如何更新值?直接state.color = 'Red'即可。刷新页面后,state能拿到缓存后的数据。

const theDefault = {
  name: 'Banana',
  color: 'Yellow',
  size: 'Medium',
  count: 0,
}
const state = useStorage('vue-use-local-storage', theDefault)

useStorage默认使用localStorage缓存,如果想使用sessionStorage怎么办?将sessionStorage作为第三个参数传入:useStorage(key, value, sessionStorage)

源码浅析: 先说useStorage函数包含的四个入参:如果key在storage不存在,则使用defaults作为初始值;默认使用localStorage存储,但可显式传入第3个参数显式指定localStorage或sessionStorage;第四个参数指定选项参数,具体有哪些选项?在逻辑中找答案。

export function useStorage<T>(
  key: string,
  defaults: MaybeRefOrGetter<T>,
  storage: StorageLike | undefined,
  options: UseStorageOptions<T> = {},
): RemovableRef<T> {...}

首先是准备数据,要准备哪些数据?data值需要吧,选项shallow设置是否浅监听,是则用需要用shallowRef(defaults)封装。storage需要吧,如果未显式传入storage,则看是否为服务端渲染,否则从浏览器window读取localStorage。

const data = (shallow ? shallowRef : ref)(typeof defaults === 'function' ? defaults() : defaults) as RemovableRef<T>

if (!storage) {
    storage = getSSRHandler('getDefaultStorage', () => defaultWindow?.localStorage)()
}

当数据data有更新,也得写入到storage中。写入依赖序列化方法serializer、write方法。

写入的值可能是number、string、boolean、object ,通过toValue获取原始值, 再通过原始值映射不同的序列化方法serializer

pasuableWatch(source, cb, options)监听数据data变化,监听策略通过options调整:

  • flush指定触发时机:pre(渲染前)、sync(同步)、post(渲染后);
  • deep指定监听对象属性深度;
  • eventFilter: 当涉及到I/O写入时,不希望触发太频繁,则可以通过eventFiler限制监听触发频率,如提供的debounceFilter、throttleFilter。
  const rawInit: T = toValue(defaults)
  const type = guessSerializerType<T>(rawInit)
  const serializer = options.serializer ?? StorageSerializers[type]

  const { pause: pauseWatch, resume: resumeWatch } = pausableWatch(
    data,
    () => write(data.value),
    { flush, deep, eventFilter },
  )

具体的写入在write函数内实现,写入逻辑挺简单,强调两点:首先,写之前使用前文获取的serializer序列化;其次,每次写入时要触发dispatchWriteEvent函数。

function write(v: unknown) {
    const oldValue = storage!.getItem(key)

    if (v == null) {
        dispatchWriteEvent(oldValue, null)
        storage!.removeItem(key)
        }
    else {
        const serialized = serializer.write(v as any)
        if (oldValue !== serialized) {
          storage!.setItem(key, serialized)
          dispatchWriteEvent(oldValue, serialized)
        }
    }
}

说到dispatchWriteEvent,终于到重点了,这也是为什么可以将storage状态化监听的原因: 可以通过注册window上的storage事件来监听写入变化。

引出另外一个问题:既然都能监听storage变化,那为什么还要调用dispatchWriteEvent手动触发?

MDN上明确说明: AddEventListener("storage", ...)只能监听不同document上下文的storage更新,例如同时打开A、B两个页面,A更新stoage后,A页面监听不到storage事件,而B页面能监听。所以,同一document上下文的storage事件只能通过手动触发。

// 监听storage更新
window.addEventListener("storage", (event) => {});
// 触发storage更新
window.dispatchEvent(storage instanceof Storage
    ? new StorageEvent('storage', payload)
    : new CustomEvent<StorageEventLike>(customStorageEventName, {
      detail: payload,
    }))

继续回到主线,调用pausableWatch实现value监听后,接下来看注册逻辑。listenToStorageChanges(默认为true)选项判断是否需要监听storage事件,当直接调用底层的localStorage.addItem()函数时,或者其他页面修改相同key的storage时,需要通过useEventListener监听变化。但什么情况下会将listenToStorageChanges设置为false?我没想到,所以对listenToStorageChanges存在必要性存疑!

initOnMounted设置onMounted事件中是否需要从storage取值初始化data,是则调用update,因此update函数的作用即是从storage读取值并赋值给data。

  if (window && listenToStorageChanges) {
    tryOnMounted(() => {
      useEventListener(window, 'storage', update)

      if (initOnMounted)
        update()
    })
  }

top3: State:useRefHistory

useRefHistory、useManualRefHistory可以记录Ref对象的修改历史,通过undo、redo方法实现撤回、取消撤回操作,两个工具函数的区别是,useManualRefHistory需要手动调用commit函数才能将新值存储到历史记录。

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

使用代码非常简单,直接调用返回的commit、redo、undo方法操作即可,并且提供了history查看历史栈。

import { useManualRefHistory } from '@vueuse/core' 

const counter = ref(0) 
const { history, commit, undo, redo } = useManualRefHistory(counter) 

counter.value += 1 commit()

useManualRefHistory代码浅析 入参中,source必须为Ref类型,options提供选项:capacity限制堆栈历史数据量,dump为source序列化函数,parse为反序列化函数,提供dump、parse的目的是可以让历史记录持久化。

返回对象为我们提供了灵活控制历史记录的方法,如clear清理历史记录、reset重置历史记录,以及canUndo、canRedo判断当前历史栈是否可撤销,返回的方法和属性都是撤销操作必须的API,当我们设计撤销应用时非常值得借鉴。

export function useManualRefHistory<Raw>(
    source: Ref<Raw>, 
    options: UseManualRefHistoryOptions
) {
  ...
  return {
    source,
    undoStack,
    redoStack,
    last,
    history,
    canUndo,
    canRedo,

    clear,
    commit,
    reset,
    undo,
    redo,
  }
}

dump、parse分别对应序列化、反序列化,clone可为boolean或者funtion类型,指定是否需要拷贝源数据。

const {
    clone = false,
    dump = defaultDump<Raw, Serialized>(clone), // 序列化
    parse = defaultParse<Raw, Serialized>(clone), // 反序列化
    setSource = fnSetSource,
} = options

如果source为简单值类型,则元数据不需要拷贝,因此dump、parse默认直接返回value。

function fnBypass<F, T>(v: F) {
  return v as unknown as T
}

如果source是object类型,则需要将clone设置为true或者自定义拷贝方法,默认的拷贝方法为cloneFnJSON:

export function cloneFnJSON<T>(source: T): T {
  return JSON.parse(JSON.stringify(source))
}

options中的setSource提供源数据更新函数,默认为source.value = value方式更新。

要实现undo、redo,得有过程记录,因此声明有last、undoStack、redoStack常量,last记录最新数据,undoStack、redoStack分别存储撤销、撤销还原历史堆栈。

const last: Ref<UseRefHistoryRecord<Serialized>> = ref(_createHistoryRecord()) as Ref<UseRefHistoryRecord<Serialized>>

const undoStack: Ref<UseRefHistoryRecord<Serialized>[]> = ref([])
const redoStack: Ref<UseRefHistoryRecord<Serialized>[]> = ref([])

到了最核心代码了,当数据版本从v1更新至v2,为了实现v1回退,需要显式地调用commit将v1提交到undoStack堆栈中,再调用_createHistoryRecord()将v2赋值到last.value。

存储堆栈时,需要考虑堆栈长度限制,如果当前撤销堆栈undoStack的长度超出限制capacity,则需要裁剪,由于是FIFO,因此直接将超出长度的老版本从堆栈中移除。另外,只要有新commit,则需要将撤销还原堆栈redoStack清空。

const commit = () => {
    undoStack.value.unshift(last.value)
    last.value = _createHistoryRecord()

    if (options.capacity && undoStack.value.length > options.capacity)
      undoStack.value.splice(options.capacity, Number.POSITIVE_INFINITY)
    if (redoStack.value.length)
      redoStack.value.splice(0, redoStack.value.length)
}

往历史堆栈推送了数据,就可以执行undo、redo操作:

undo操作如下,除了从undoStack堆栈拉最新数据外,还需要将最新版本数据push到redoStack,来支持还原操作,另外,不管是撤销或还原,都得调用_setSource(state)更新数据源。

  const undo = () => {
    const state = undoStack.value.shift()

    if (state) {
      redoStack.value.unshift(last.value)
      _setSource(state)
    }
  }

redo操作和undo类似:

const redo = () => {
    const state = redoStack.value.shift()

    if (state) {
      undoStack.value.unshift(last.value)
      _setSource(state)
    }
}

有时候需要将历史记录呈现到界面上,并且撤销、还原按钮也需要控制状态,这方面问题,useManualRefHistory为我们提供了history、canUndo、canRedo,用于界面显示。

const history = computed(() => [last.value, ...undoStack.value])

const canUndo = computed(() => undoStack.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)

top4: Elements:useResizeObserver

useResizeObserver用于监听元素的尺寸变化,包含两个入参,el为监听的元素,第二个参数为回调函数。

const el = ref(null);

useResizeObserver(el, (entries) => {
  const [entry] = entries
  const { width, height } = entry.contentRect
  text.value = `width: ${width}\nheight: ${height}`
})

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

useResizeObserver代码浅析 useResizeObserver依赖Web API ResizeObserver,官网描述: ResizeObserver 接口监视 Element 内容盒或边框盒或者 SVGElement 边界尺寸的变化。

ResizeObserver包含的方法:

  • observe(target, options): options提供了box选项,设置监听元素的盒子模式,值包含context-box|border-box|device-pixel-content-box
  • unobserve(target):取消target元素的监听。
  • disconnect(): 取消所有元素的监听。

继续回到useResizeObserver函数,以下为函数签名,入参中:target为监听目标,可以为数组类型;options可设置box属性。

对于有浏览器兼容性的API,可通过isSuppported判断浏览器是否知支持。返回结果的stop可停止对target的resize监听。

export function useResizeObserver(
  target: MaybeComputedElementRef | MaybeComputedElementRef[],
  callback: ResizeObserverCallback,
  options: UseResizeObserverOptions = {},
) {
  ...
  return {
    isSupported,
    stop,
  }
}

核心逻辑:传入的targets是Ref类型的列表,使用watch对其监听,每次使用新的observer监听前需要调用cleanup()将历史监听给销毁。

而监听逻辑正是使用ResizeObserver提供的observe方法,由于targets为数组类型,因此每一个el都得调用observer监听其resize变化。

  const stopWatch = watch(
    targets,
    (els) => {
      cleanup()
      if (isSupported.value && window) {
        observer = new ResizeObserver(callback)
        for (const _el of els) {
          if (_el)
            observer!.observe(_el, observerOptions)
        }
      }
    },
    { immediate: true, flush: 'post' },
  )

上文提到的cleanup函数,调用observer.disconnect()断开observer对所有元素的resize监听。

top5: Elements:useElementSize

使用方式:const { width, height } = useElementSize(el),底层直接使用的useResizeObserver函数。

top6: Browser:useEventListener

其底层正是使用的HTMLElement的addEventListner相比于原生事件注册, useEventListener有那些优点?

useEventListener会在onMounted事件自动注册handler,在unMounted事件自动注销handler。如果想手动注销,可通过返回值,例如代码中的stop,作为事件的销毁函数调用stop()

import { useEventListener } from '@vueuse/core' 

const stop = useEventListener(document, 'visibilitychange', (evt) => {
    console.log(evt) 
})

top7: Browser:useClipboard

大家都实现过拷贝至剪切板吧?一堆代码,还得考虑兼容性问题。现在一个useClipboard即搞定。

使用方式如下,useClipboard函数返回的text、copied、isSupported为响应式数据,可在template中使用,执行拷贝则调用copy(str)

const source = ref('Hello') 
const { text, copy, copied, isSupported } = useClipboard({ source })

useClipboard底层会判断navigator上是否存在clipboard,存着则执行:

navigator!.clipboard.writeText(value)

不存在则执行兜底函数legacyCopy

function legacyCopy(value: string) {
    const ta = document.createElement('textarea')
    ta.value = value ?? ''
    ta.style.position = 'absolute'
    ta.style.opacity = '0'
    document.body.appendChild(ta)
    ta.select()
    document.execCommand('copy')
    ta.remove()
}

top8: Browser:useEyeDropper

系统级颜色拾取函数,通过返回的Ref类型变量sRGBHex获取十六进制颜色,调用open函数打开浏览器颜色拾取器,和我们调试CSS使用的拾取器为同一个。

import { useEyeDropper } from '@vueuse/core' 
const { isSupported, open, sRGBHex } = useEyeDropper()

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

useEyeDropper底层使用window上类型为EyeDropper的实例化对象eyeDropper,当要拾取颜色时调用其open方法即可。

const eyeDropper: EyeDropper = new (window as any).EyeDropper()
const result = await eyeDropper.open(openOptions)

top9: Browser:useGeolocation

获取地理位置信息,坐标信息存储在coords对象,resume、pause函数用于恢复、暂停位置监听。

const { coords, locatedAt, error, resume, pause } = useGeolocation()

coords包含信息:

{
  "coords": {
    "accuracy": 20198.511783027254,
    "latitude": 22.3193039,
    "longitude": 114.1693611,
    "altitude": null,
    "altitudeAccuracy": null,
    "heading": null,
    "speed": null
  },
  "locatedAt": 1725457508531,
  "error": null
}

底层实际调用的是navigator!.geolocation.watchPosition方法,cb为位置变化回调函数。

watcher = navigator!.geolocation.watchPosition(
    cb,
    err => error.value = err,
    options,
)

第三个参数options,包含的选项有:

  • timeout: 单位毫秒,获取位置等待超时时间。
  • maxmumAge: 单位毫秒,位置信息的最大缓存周期,到期后重新获取。
  • enableHighAccuracy:是否获取高精度位置信息,开启后,获取位置信息耗时会变高。

top10: Sensors:onClickOutside

常用于dialog、tooltip场景,当点击dialog或tooltip容器外部时,触发回调函数, 关闭弹窗。使用方式:

import { onClickOutside } from '@vueuse/core' 
const target = ref(null) 
onClickOutside(target, event => console.log(event))

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

核心源码如下,如果event.target等于当前el,或者事件传递路径event.composedPath()列表包含el,都不会触发outside事件。

const listener = (event: PointerEvent) => {
    const el = unrefElement(target)

    if (!el || el === event.target || event.composedPath().includes(el))
      return
    ...
    handler(event)
}

event.composedPath方法:返回事件会触发的链路元素对象集合,通过该方法可判断点击的元素是否在该链路元素上。

top11: Sensors:useMemory

获取内存信息,调用方法:

import { useMemory } from '@vueuse/core'

const { isSupported, memory } = useMemory()

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

底层一个属性搞定:performance.memory

top12: Sensors:useParallax

创建一个元素视差效果,移动端更适用。调用方式:

const container = ref(null) 
const { tilt, roll, source } = useParallax(container)

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

底层监听deviceorientation事件,获取方向感应数据,alpha表示绕z轴运动角度,beta表示绕x轴运动角度,gamma表示绕y轴运动角度。

useEventListener(window, 'deviceorientation', (event) => {
      isAbsolute.value = event.absolute
      alpha.value = event.alpha
      beta.value = event.beta
      gamma.value = event.gamma
})

设备坐标系如下:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

底层还监听orientationchange获取设备纵横方向,其值可以是portrait(纵向) 或者 landscape(横向)

useEventListener(window, 'orientationchange', () => {
      orientation.value = screenOrientation.type
      angle.value = screenOrientation.angle
    })

top13: cmponent:useVModel

先看一个案例:

const props = defineProps<{ data: number }>()
...
props.data = 10;

某些场景我们希望能够直接修改props传递进来的属性值,但实际却不允许:

Cannot assign to 'data' because it is a read-only property.ts-plugin(2540)

useVModel为我们提供数据双向绑定的快速实现方式,通过useVModel(props, 'data', emit)后,获取的变量data即可直接赋值。

const data = useVModel(props, 'data', emit) 
console.log(data.value) // props.data 
data.value = 'foo' // emit('update:data', 'foo')

代码浅析:通过watch监听值变化,如果有更新,则触发triggerEmit函数。

 const proxy = ref<P[K]>(initialValue!)
 watch(
      proxy,
      (v) => {
        if (v !== props[key!])
          triggerEmit(v as P[K])
      },
      { deep },
 )

triggerEmit会调用emit(event, value)更新父级属性值,emit哪来的?vue为我们提供了getCurrentInstance()函数获取当前组件的上下文vm,从而获取vm.emit。

const vm = getCurrentInstance()
const _emit = vm?.emit

const triggerEmit = (value: P[K]) => {
    _emit(event, value)
}

top14: Animation:useAnimate

实现自定义动画效果,使用方式如下,useAnimation为开发者提了丰富的动画控制方法和属性。

const {
  play,
  pause,
  reverse,
  finish,
  cancel,
  startTime,
  currentTime,
  playbackRate,
  playState,
  replaceState,
  pending,
} = useAnimate(
  el,
  [
    { clipPath: 'circle(20% at 0% 30%)' },
    { clipPath: 'circle(20% at 50% 80%)' },
    { clipPath: 'circle(20% at 100% 30%)' },
  ],
  {
    duration: 3000,
    iterations: 5,
    direction: 'alternate',
    easing: 'cubic-bezier(0.46, 0.03, 0.52, 0.96)',
  },
)

demo:

Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

底层核心是调用HTMLElement的animate方法,第一参数为关键帧对象数组,第二个参数提供动画相关的参数: delay、duration、direction、easing等等。

animate.value = el.animate(toValue(keyframes), animateOptions)

top15: Animation:useTransition

在两个值之间自定义插值,插值类型可以为number或者数组,例如将baseNumber的值更新至100,则cubicBezierNumber会从0插值到100,duration为动画耗时,插值采用贝塞尔曲线。

const baseNumber = ref(0)
const baseVector = ref([0, 0])

const cubicBezierNumber = useTransition(baseNumber, {
  duration,
  transition: [0.75, 0, 0.25, 1],
})
const vector = useTransition(baseVector, {
  duration,
  transition: TransitionPresets.easeOutExpo,
})

demo: Vue无处不use的VueUse: Composition工具集,代码减半神器!被VueUse提供的功能折服,就像标题所

代码浅析:插值函数在requestAnimationFrame中执行,通过调用ease((now - startedAt))/duration计算插值alpha,ease默认采用和css一样的贝塞尔插值方式。

const now = Date.now()
const alpha = ease((now - startedAt) / duration)
const arr = toVec(source.value).map((n, i) => lerp(v1[i], v2[i], alpha))

当alpha计算完成后,再调用lerp计算当前插值结果。

function lerp(a: number, b: number, alpha: number) {
  return a + alpha * (b - a)
}

总结

VueUse为开发者提供了覆盖多场景、高频使用的工具集,并且开箱即用,上手快!本篇依据个人经验梳理了常用的一些函数,并且也从底层浅析其实现原理,API封装思路对我们自身开发能提供不小的借鉴价值,另一方面也了解到一些新的Web API,能够极大减少相关功能开发成本。 但是,如果对浏览器兼容性要求比较高,在使用较新Web API时,得提前确认各个浏览器兼容版本。

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!

转载自:https://juejin.cn/post/7410616997006196776
评论
请登录