本文不会放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 navigatorif (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 navigatorfunction 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/