本文不会放api的用法,建议先看看是怎么用的
写本篇文章时间间隔较长,所以代码版本不一
项目架构
采用monorepo的形式,项目目录下有多个子项目,下面放了资料链接和几处用法,其他本文不多赘述。
现代前端工程为什么越来越离不开 Monorepo?
为什么使用pnpm可以光速建立好用的monorepo(比yarn/lerna效率高)
pnpm workspace文档
为了使所有子项目都使用同一个依赖版本,使用pnpm.overrides配置,如下
1 2 3 4 5 6 7 8
| { "pnpm": { "overrides": { "vue-demi": "0.12.1", "vite": "^2.6.7" } } }
|
在正常配置下,如果直接pnpm install将会在每个子项目的node_modules有同样的依赖,但是所有子项目相同的依赖其实只需要提取出来放在根目录的node_modules下即可,可配置如下
1 2 3 4
| "peerDependencies": { "@vue/composition-api": "^1.1.0", "vue": "^2.6.0 || ^3.2.0" },
|
详情请看如下链接
探讨npm依赖管理之peerDependencies
pnpm monorepo之多组件实例和peerDependencies困境回溯
如何处理 peers
前置知识
unref - ref的反操作
1 2 3 4
| function unref<T>(ref: T | Ref<T>): T { return isRef(ref) ? ref.value : ref }
|
使用例子:实现一个响应式的add函数,可以传入ref或值
1 2 3 4 5 6
| function add( a: Ref<number> | number, b: Ref<number> | number ) { return computed(() => unref(a) + unref(b)) }
|
MaybeRef类型
vueuse大量使用MaybeRef来支持可选择性的响应式参数
1
| type MaybeRef<T> = Ref<T> | T
|
上面的加法函数就可以简写为
1 2 3
| function add(a: MaybeRef<number>, b: MaybeRef<number>) { return computed(() => unref(a) + unref(b)) }
|
Effect作用域API
Vue官方文档:Effect 作用域 API
动机
在Vue的setup中,响应式effect会在初始化的时候被收集,在实例被卸载的时候,响应式effect就会自动被取消了,但是在我们在组件外写一个独立的包(就如vueuse)时,我们该如何取消computed & watch的响应式依赖呢?vue3.2提出了effectScope,接下来介绍相关的api
effectScope
利用effectScope创建一个作用域对象,如下面接口定义所示,run接受一个函数,这个作用域对象会自动捕获函数内部的响应式effect (例如计算属性或侦听器)
类型
1 2 3 4 5 6
| function effectScope(detached?: boolean): EffectScope
interface EffectScope { run<T>(fn: () => T): T | undefined stop(): void }
|
示例
1 2 3 4 5 6 7 8 9 10
| const scope = effectScope()
scope.run(() => { const doubled = computed(() => counter.value * 2) watch(doubled, () => console.log(doubled.value)) watchEffect(() => console.log('Count: ', doubled.value)) })
scope.stop()
|
getCurrentScope
如果有,则返回当前活跃的 effect 作用域。
类型
1
| function getCurrentScope(): EffectScope | undefined
|
onScopeDispose
在当前活跃的 effect 作用域上注册一个处理回调。该回调会在相关的 effect 作用域结束之后被调用,在VCA(Vue Composition API)函数中可用作onUnmounted的非组件替代品,区别在于其工作在scope中而不是组件实例
1 2 3 4
| onScopeDispose(() => { window.removeEventListener('mousemove', handler) })
|
那么如此抽象的effectScope在尤大降低心智负担的主张下为什么要被提出呢,下面列了个官方rfc中的例子
示例
在vue的rfc中reactivity-effect-scope有这样的一个例子,如果有个监控鼠标位置的hook(useMouse),需要监听mousemove事件,如果在多个组件调用了这个hook,而内部是通过在onUnmounted钩子来移除mousemove监听器,onUnmounted耦合在每个组件实例,则无法以更有效率的方式共享这个mousemove监听器(即)
为了做到在组件间共享useMouse的响应式effect和监听器,可以创建一个函数来管理scope如下(这个hook也在vueuse中)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function createSharedComposable(composable) { let subscribers = 0 let state, scope
const dispose = () => { if (scope && --subscribers <= 0) { scope.stop() state = scope = null } }
return (...args) => { subscribers++ if (!state) { scope = effectScope(true) state = scope.run(() => composable(...args)) } onScopeDispose(dispose) return state } }
|
可以看到dispose函数的含义是,当没有一个组件在使用它的时候会注销(dispose)创建的effectScope,我们只需要如下操作,即可得到一个在所有组件共享的useMouse
1
| const useSharedMouse = createSharedComposable(useMouse)
|
更加详细的关于effectScope的讨论在Reactivity’s effectScope API #212
本来是打算完全按照官网的分类进行解读,但是由于VCA(Vue Composition API)的思想,hook被拆的很碎再组合在一起,并不能单纯靠分类进行解读,所以在讲某个分类的hook时也会带上其他的hooks,接下来是对我觉得 常用 或者 有学习到东西 的hook源码进行解读
State
createGlobalState
用来做挂组件公共状态管理
简单的单例模式实现,effectScope的参数为true时,其不会被父scope收集和回收,独立存在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export function createGlobalState<T>( stateFactory: () => T, ): CreateGlobalStateReturn<T> { let initialized = false let state: T const scope = effectScope(true)
return () => { if (!initialized) { state = scope.run(stateFactory)! initialized = true } return state } }
|
useLocalStorage
1 2 3 4 5 6 7 8
| export function useLocalStorage<T extends(string|number|boolean|object|null)> ( key: string, initialValue: MaybeRef<T>, options: StorageOptions<T> = {}, ): RemovableRef<any> { const { window = defaultWindow } = options return useStorage(key, initialValue, window?.localStorage, options) }
|
可以看到就是用useStorage来实现的,useSessionStorage也是一样,下面我们来看看useStorage
useStorage
在浏览器默认的Storage之上像store.js一样对数据进行预处理(序列化),否则如果存一个对象在浏览器的Storage存的会是xxx.toString()之后的值。但是useStorage比起store.js更进一步的增加了对Map和Set类型的数据的处理(**Map和Set存在默认的Storage和store.js都是空对象**)
利用适配器模式,对每种数据类型定义read、write
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| export const StorageSerializers: Record<'boolean' | 'object' | 'number' | 'any' | 'string' | 'map' | 'set', Serializer<any>> = { boolean: { read: (v: any) => v === 'true', write: (v: any) => String(v), }, object: { read: (v: any) => JSON.parse(v), write: (v: any) => JSON.stringify(v), }, number: { read: (v: any) => Number.parseFloat(v), write: (v: any) => String(v), }, any: { read: (v: any) => v, write: (v: any) => String(v), }, string: { read: (v: any) => v, write: (v: any) => String(v), }, map: { read: (v: any) => new Map(JSON.parse(v)), write: (v: any) => JSON.stringify(Array.from((v as Map<any, any>).entries())), }, set: { read: (v: any) => new Set(JSON.parse(v)), write: (v: any) => JSON.stringify(Array.from((v as Set<any>).entries())), }, }
|
接下来判断用户传入的数据类型,进行选择对应的read、write方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| export function guessSerializerType<T extends(string | number | boolean | object | null)>(rawInit: T) { return rawInit == null ? 'any' : rawInit instanceof Set ? 'set' : rawInit instanceof Map ? 'map' : rawInit instanceof Date ? 'date' : typeof rawInit === 'boolean' ? 'boolean' : typeof rawInit === 'string' ? 'string' : typeof rawInit === 'object' ? 'object' : !Number.isNaN(rawInit) ? 'number' : 'any' }
const type = guessSerializerType<T>(rawInit) const serializer = options.serializer ?? StorageSerializers[type]
|
下面是对数据的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const rawInit: T = unref(initialValue) const data = (shallow ? shallowRef : ref)(initialValue) as Ref<T>
if (!storage) return
try { const rawValue = storage.getItem(key) if (rawValue == null) { data.value = rawInit if (writeDefaults && rawInit !== null) storage.setItem(key, serializer.write(rawInit)) } else { data.value = serializer.read(rawValue) } } catch (e) { onError(e) }
|
然后是根据监听data的变化存入Storage,watchWithFilter可以看做是新增了eventFilter选项的watch,在后文会描述,也是vueuse很重要的一个特性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| watchWithFilter( data, () => { try { if (data.value == null) storage.removeItem(key) else storage.setItem(key, serializer.write(data.value)) } catch (e) { onError(e) } }, { flush, deep, eventFilter, }, )
|
是不是觉得很OK了,但仍有巨隐蔽的bug(哈哈哈),为了支持响应式的存取Storage,用了ref类型的data,在同源下的多个标签的情况下,其中一个页面对另一个页面用useStorage修改了数据,但另一个页面useStorage内部的data还是原来的值
解决方式:监听storage事件(当页面使用的storage被其他页面修改时会触发)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function read(event?: StorageEvent) { if (!storage || (event && event.key !== key)) return
try { const rawValue = event ? event.newValue : storage.getItem(key) } catch (e) { onError(e) } }
if (window && listenToStorageChanges) useEventListener(window, 'storage', e => setTimeout(() => read(e), 0))
|
Browser
浏览器相关的hook,基于web暴露的api来实现
可配置全局对象
Browser hook的源码开头都有或类似下面这一段
1 2 3 4 5 6 7
| export const defaultWindow = isClient ? window : undefined
export function useXXX<T>(options: ConfigurableWindow = {}) { const { window = defaultWindow } = options window.xxx }
|
用户可以配置当前window对象,这种配置方式对于使用iframe和测试环境不同的window对象十分有用
useEventListener
先上一个简易版本的useEventListener,利用VCA,将事件的监听和注销放在一个函数中处理
1 2 3 4 5 6 7 8 9
| function useEventListener (target, listener, options, target = window) { onMounted(() => { target.addEventListener(type, listener, options) })
onUnmounted(() => { target.removeEventListener(type, listener, options) }) }
|
除此之外,源码还提供了当target的类型是ref时的情况,为了减少篇幅,下面的代码删减了参数为空的边界情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| export function useEventListener(...args: any[]) { let target: MaybeRef<EventTarget> | undefined = defaultWindow; let event: string let listener: any let options: any
[target, event, listener, options] = args
let cleanup = noop
const stopWatch = watch( () => unref(target), (el) => { cleanup() if (!el) return
el.addEventListener(event, listener, options)
cleanup = () => { el.removeEventListener(event, listener, options) cleanup = noop } }, { immediate: true, flush: 'post' }, )
const stop = () => { stopWatch() cleanup() }
tryOnScopeDispose(stop)
return stop }
|
为什么需要支持ref target
用unref得到ref中的dom元素,监听dom元素的改变以重新监听事件,应用场景就放官网例子
1 2 3 4
| <template> <div v-if="cond" ref="element">Div1</div> <div v-else ref="element">Div2</div> </template>
|
1 2 3 4
| import { useEventListener } from '@vueuse/core'
const element = ref<HTMLDivElement>() useEventListener(element, 'keydown', (e) => { console.log(e.key) })
|
为什么需要watch设置flush为post
详情请看#356 Bug: useEventListener doesn’t work correctly with v-if,意思就是新版vue的watch默认在所有组件update前执行,那么ref没有更新导致事件没有更新,所以需要在组件update完毕后更新事件。
tryOnScopeDispose是啥
先判断是否有活跃的effectScope,有的话用onScopeDispose在其上注册fn回调
1 2 3 4 5 6 7
| export function tryOnScopeDispose(fn: Fn) { if (getCurrentScope()) { onScopeDispose(fn) return true } return false }
|
v10.4.1更新:支持event传递数组,方便浏览器兼容事件
useEyeDropper
new window.EyeDropper()可以获取取色器的功能
usePreferredReducedMotion
prefers-reduced-motion用于检测用户的系统是否被开启了动画减弱功能
1
| useMediaQuery('(prefers-reduced-motion: reduce)', options)
|
usePreferredContrast
prefers-contrast判断用户喜欢何种对比度(more、less、custom)
window.matchMedia(query)返回MediaQueryList类型
1 2 3 4 5 6 7 8
| interface MediaQueryList extends EventTarget { readonly matches: boolean; readonly media: string; onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | null; }
let mql: MediaQueryList = window.matchMedia('(max-width: 600px)');
|
usePermission
navigator.permissions可以获取用户权限(摄像头、麦克风等)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const isSupported = Boolean(navigator && 'permissions' in navigator) const state = ref<PermissionState | undefined>()
const onChange = () => { if (permissionStatus) state.value = permissionStatus.state }
try { permissionStatus = await navigator!.permissions.query({ name: 'camera' }) useEventListener(permissionStatus, 'change', onChange) onChange() } catch { state.value = 'prompt' }
|
usePreferredLanguages
navigator.languages可以获取用户的偏好语言,可以通过languagechange事件监听改变
1 2 3 4 5
| const value = ref<readonly string[]>(navigator.languages)
useEventListener(window, 'languagechange', () => { value.value = navigator.languages })
|
useShare
navigator.share可以进行分享
1 2 3 4 5 6 7 8 9 10 11 12 13
| const isSupported = navigator && 'canShare' in navigator
if (isSupported) { granted = navigator.canShare({ title?: string files?: File[] text?: string url?: string })
if (granted) return navigator.share!(data) }
|
useWakeLock
Screen Wake Lock API可以阻止设备变暗或锁屏
1 2 3 4 5 6 7 8 9 10 11 12
| const isSupported = navigator && 'wakeLock' in navigator
function request(type: WakeLockType) { if (!isSupported) return wakeLock = await navigator.wakeLock.request(type) }
function release() { if (!isSupported || !wakeLock) return await wakeLock.release() wakeLock = null }
|
useVibrate
Vibration API可以使设备振动
1 2
| window.navigator.vibrate([200, 100, 200]); navigator.vibrate(0);
|
useWebNotification
Notification可以给用户发送消息
useFullscreen
Fullscreen_API用以控制全屏展示,虽然常见,但是看源码对兼容性有了多一层考虑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| const requestMethod = computed<'requestFullscreen' | undefined>(() => { return [ 'requestFullscreen', 'webkitRequestFullscreen', 'webkitEnterFullscreen', 'webkitEnterFullScreen', 'webkitRequestFullScreen', 'mozRequestFullScreen', 'msRequestFullscreen', ].find(m => (document && m in document) || (targetRef.value && m in targetRef.value)) as any })
const exitMethod = computed<'exitFullscreen' | undefined>(() => { return [ 'exitFullscreen', 'webkitExitFullscreen', 'webkitExitFullScreen', 'webkitCancelFullScreen', 'mozCancelFullScreen', 'msExitFullscreen', ].find(m => (document && m in document) || (targetRef.value && m in targetRef.value)) as any })
const fullscreenEnabled = computed<'fullscreenEnabled' | undefined>(() => { return [ 'fullScreen', 'webkitIsFullScreen', 'webkitDisplayingFullscreen', 'mozFullScreen', 'msFullscreenElement', ].find(m => (document && m in document) || (targetRef.value && m in targetRef.value)) as any })
const fullscreenElementMethod = [ 'fullscreenElement', 'webkitFullscreenElement', 'mozFullScreenElement', 'msFullscreenElement', ].find(m => (document && m in document)) as 'fullscreenElement' | undefined
|
利用适配器的方式进行调用,target[requestMethod.value]()
下面是不同浏览器监听全局变化的事件名
1 2 3 4 5 6 7 8 9 10
| const eventHandlers = [ 'fullscreenchange', 'webkitfullscreenchange', 'webkitendfullscreen', 'mozfullscreenchange', 'MSFullscreenChange', ]
useEventListener(() => unrefElement(targetRef), eventHandlers, handlerCallback, false)
|
Sensors
onStartTyping
在开始输入的时候执行,我们可以拿来做到不管编辑器是否聚焦,只要输入就聚焦到编辑器并输入
1 2 3 4 5 6 7 8 9 10 11
| export function onStartTyping(callback: (event: KeyboardEvent) => void, options: ConfigurableDocument = {}) { const { document = defaultDocument } = options
const keydown = (event: KeyboardEvent) => { isTypedCharValid(event) && callback(event) }
if (document) useEventListener(document, 'keydown', keydown, { passive: true }) }
|
在上面的基础上还需要做一点,如果已经聚焦到non-editable elements,那么就不执行,如何检测non-editable elements呢,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function isFocusedElementEditable() { const { activeElement, body } = document
if (!activeElement) return false
if (activeElement === body) return false
switch (activeElement.tagName) { case 'INPUT': case 'TEXTAREA': return true } return activeElement.hasAttribute('contenteditable') }
|
useMagicKeys
恰如其名,magic,先上用法
1 2 3 4 5 6
| const { space, shift } = useMagicKeys()
watch(space, (v) => { if (v) console.log('space has been pressed') })
|
又没传参,他怎么监听到我要的space和shift,难不成他返回了所有键?那当然不是,上源码(抽离了关键逻辑)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| export function useMagicKeys(options: UseMagicKeysOptions<boolean> = {}): any { const current = reactive(new Set<string>()) const obj = { toJSON() { return {} }, current, } const refs: Record<string, any> = useReactive ? reactive(obj) : obj useEventListener(target, 'keydown', (e: KeyboardEvent) => { updateRefs(e, true) }, { passive }) useEventListener(target, 'keyup', (e: KeyboardEvent) => { updateRefs(e, false) }, { passive }) const proxy = new Proxy( refs, { get(target, prop, rec) { if (!(prop in refs)) { refs[prop] = ref(false) } const r = Reflect.get(target, prop, rec) return useReactive ? toValue(r) : r }, }, )
return proxy as any }
|
这样refs只存了取的键的状态,而不是所有键的状态
usePageLeave
用MouseEvent.relatedTarget检测是否离开屏幕
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export function usePageLeave(options: ConfigurableWindow = {}) { const handler = (event: MouseEvent) => { const from = event.relatedTarget || event.toElement isLeft.value = !from }
if (window) { useEventListener(window, 'mouseout', handler, { passive: true }) useEventListener(window.document, 'mouseleave', handler, { passive: true }) useEventListener(window.document, 'mouseenter', handler, { passive: true }) }
return isLeft }
|
useSpeechSynthesis
利用SpeechSynthesis来做到语音阅读
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| export function useSpeechSynthesis(text: MaybeRefOrGetter<string>, options: UseSpeechSynthesisOptions = {}) { const synth = window && (window as any).speechSynthesis as SpeechSynthesis; const bindEventsForUtterance = (utterance: SpeechSynthesisUtterance) => { utterance.lang = toValue(lang) utterance.voice = toValue(options.voice) || null utterance.pitch = toValue(pitch) utterance.rate = toValue(rate) utterance.volume = volume
utterance.onstart = () => { isPlaying.value = true status.value = 'play' }
utterance.onpause = () => { isPlaying.value = false status.value = 'pause' }
utterance.onresume = () => { isPlaying.value = true status.value = 'play' }
utterance.onend = () => { isPlaying.value = false status.value = 'end' }
utterance.onerror = (event) => { error.value = event } }
const utterance = computed(() => { isPlaying.value = false status.value = 'init' const newUtterance = new SpeechSynthesisUtterance(spokenText.value) bindEventsForUtterance(newUtterance) return newUtterance })
const speak = () => { synth!.cancel() utterance && synth!.speak(utterance.value) }
const stop = () => { synth!.cancel() isPlaying.value = false }
tryOnScopeDispose(() => { isPlaying.value = false })
}
|
Animation
useTransition
可配置项
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| type CubicBezierPoints = [number, number, number, number] type EasingFunction = (n: number) => number
export function useTransition( source: Ref<number | number[]> | MaybeRef<number>[], options: TransitionOptions = {}, ): ComputedRef<any> { const { delay = 0, disabled = false, duration = 1000, onFinished = noop, onStarted = noop, transition = linear, } = options
}
|
预处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| export function useTransition( source: Ref<number | number[]> | MaybeRef<number>[], options: TransitionOptions = {}, ): ComputedRef<any> { const currentTransition = computed(() => { const t = unref(transition) return isFunction(t) ? t : createEasingFunction(t) })
const sourceValue = computed(() => { const s = unref<number | MaybeRef<number>[]>(source) return isNumber(s) ? s : s.map(unref) as number[] }) const sourceVector = computed(() => isNumber(sourceValue.value) ? [sourceValue.value] : sourceValue.value)
}
function createEasingFunction([p0, p1, p2, p3]: CubicBezierPoints): EasingFunction { const a = (a1: number, a2: number) => 1 - 3 * a2 + 3 * a1 const b = (a1: number, a2: number) => 3 * a2 - 6 * a1 const c = (a1: number) => 3 * a1
const calcBezier = (t: number, a1: number, a2: number) => ((a(a1, a2) * t + b(a1, a2)) * t + c(a1)) * t
const getSlope = (t: number, a1: number, a2: number) => 3 * a(a1, a2) * t * t + 2 * b(a1, a2) * t + c(a1)
const getTforX = (x: number) => { let aGuessT = x
for (let i = 0; i < 4; ++i) { const currentSlope = getSlope(aGuessT, p0, p2) if (currentSlope === 0) return aGuessT const currentX = calcBezier(aGuessT, p0, p2) - x aGuessT -= currentX / currentSlope }
return aGuessT }
return (x: number) => p0 === p1 && p2 === p3 ? x : calcBezier(getTforX(x), p1, p3) }
|
提供了几种过渡预设
1 2 3 4 5
| export const TransitionPresets: Record<string, CubicBezierPoints | EasingFunction> = { easeInSine: [0.12, 0, 0.39, 0], easeOutSine: [0.61, 1, 0.88, 1], }
|
详情可以看 缓动函数、 MDN:缓动函数
关键逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| const outputVector = ref(sourceVector.value.slice(0))
const { resume, pause } = useRafFn(() => { const now = Date.now() const progress = clamp(1 - ((endAt - now) / currentDuration), 0, 1)
outputVector.value = startVector.map((val, i) => val + ((diffVector[i] ?? 0) * currentTransition.value(progress)))
if (progress >= 1) { pause() onFinished() } }, { immediate: false })
const timeout = useTimeoutFn(start, delay, { immediate: false })
const start = () => { pause()
currentDuration = unref(duration) diffVector = outputVector.value.map((n, i) => (sourceVector.value[i] ?? 0) - (outputVector.value[i] ?? 0)) startVector = outputVector.value.slice(0) startAt = Date.now() endAt = startAt + currentDuration
resume() onStarted() }
watch(sourceVector, () => { if (unref(disabled)) { outputVector.value = sourceVector.value.slice(0) } else { if (unref(delay) <= 0) start() else timeout.start() } }, { deep: true })
return computed(() => { const targetVector = unref(disabled) ? sourceVector : outputVector return isNumber(sourceValue.value) ? targetVector.value[0] : targetVector.value })
|
@Sound
可以简单的让你的网站带上声音
1 2 3
| import buttonSfx from '../assets/sounds/button.mp3'
const { play } = useSound(buttonSfx)
|
demo:https://sound.vueuse.org/