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
即可轻松拿到坐标信息。
- 为什么另一部分功能你都没想到会提供?
提供电池状态(useBattery)、剪切板(useClipboard)、颜色拾取(useEyeDropper)、设备列表(#useDevicesList)等系统级功能,涉及到系统级就牵扯到权限以及浏览器版本兼容性,所以使用时得斟酌。
常用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函数才能将新值存储到历史记录。
使用代码非常简单,直接调用返回的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:
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:
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:
核心源码如下,如果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:
底层一个属性搞定:performance.memory
。
top12: Sensors:useParallax
创建一个元素视差效果,移动端更适用。调用方式:
const container = ref(null)
const { tilt, roll, source } = useParallax(container)
demo:
底层监听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
})
设备坐标系如下:
底层还监听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:
底层核心是调用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:
代码浅析:插值函数在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