本文不会放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
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函数中可用作onUnmounted的非组件替代品,区别在于其工作在scope中而不是组件实例
1 2 3
| onScopeDispose(() => { window.removeEventListener('mousemove', handler) })
|
示例
在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的思想,hook被拆的很碎再组合在一起,并不能单纯靠分类进行解读,所以在讲某个分类的hook时也会带上其他的hooks,接下来是对我觉得 常用 或者 有学习到东西 的hook源码进行解读
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
| 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 }
|
useEyeDropper
new window.EyeDropper()可以获取取色器的功能
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 }
|
Watch
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
| const type = rawInit == null ? 'any' : rawInit instanceof Set ? 'set' : rawInit instanceof Map ? 'map' : typeof rawInit === 'boolean' ? 'boolean' : typeof rawInit === 'string' ? 'string' : typeof rawInit === 'object' ? 'object' : Array.isArray(rawInit) ? 'object' : !Number.isNaN(rawInit) ? 'number' : 'any'
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, }, )
|
但仍有巨隐蔽的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))
|
Sensors
onClickOutside
Animation
useRafFn
可以暂停、恢复、获取当前状态的requestAnimationFrame
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
| export function useRafFn(fn: Fn, options: RafFnOptions = {}): Pausable { const { immediate = true, window = defaultWindow, } = options
const isActive = ref(false)
function loop() { if (!isActive.value) return fn() if (window) window.requestAnimationFrame(loop) }
function resume() { if (!isActive.value) { isActive.value = true loop() } }
function pause() { isActive.value = false }
if (immediate) resume()
tryOnScopeDispose(pause)
return { isActive, pause, resume, } }
|
留有疑问:为什么pause中不用cancelAnimationFrame来取消requestAnimationFrame
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 })
|
未完待续。。。