From cf663ff20ecc68884009612b2781198984e66e14 Mon Sep 17 00:00:00 2001 From: Ayaka Rizumu <464388324@qq.com> Date: Wed, 6 Jul 2022 11:13:33 +0800 Subject: [PATCH] feat(watchTriggerable): extending `watch` with a manual trigger (#1736) --- packages/shared/index.ts | 1 + packages/shared/watchTriggerable/demo.vue | 49 ++++++++++ packages/shared/watchTriggerable/index.md | 57 +++++++++++ .../shared/watchTriggerable/index.test.ts | 97 +++++++++++++++++++ packages/shared/watchTriggerable/index.ts | 90 +++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 packages/shared/watchTriggerable/demo.vue create mode 100644 packages/shared/watchTriggerable/index.md create mode 100644 packages/shared/watchTriggerable/index.test.ts create mode 100644 packages/shared/watchTriggerable/index.ts diff --git a/packages/shared/index.ts b/packages/shared/index.ts index 913b24f2e69..c0e5795d66c 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -51,5 +51,6 @@ export * from './watchIgnorable' export * from './watchOnce' export * from './watchPausable' export * from './watchThrottled' +export * from './watchTriggerable' export * from './watchWithFilter' export * from './whenever' diff --git a/packages/shared/watchTriggerable/demo.vue b/packages/shared/watchTriggerable/demo.vue new file mode 100644 index 00000000000..3cf81deef21 --- /dev/null +++ b/packages/shared/watchTriggerable/demo.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/shared/watchTriggerable/index.md b/packages/shared/watchTriggerable/index.md new file mode 100644 index 00000000000..b8d401af476 --- /dev/null +++ b/packages/shared/watchTriggerable/index.md @@ -0,0 +1,57 @@ +--- +category: Watch +--- + +# watchTriggerable + +Watch that can be triggered manually + +## Usage + +A `watch` wrapper that supports manual triggering of `WatchCallback`, which returns an additional `trigger` to execute a `WatchCallback` immediately. + +```ts +import { watchTriggerable } from '@vueuse/core' +import { nextTick, ref } from 'vue' + +const source = ref(0) + +const { trigger, ignoreUpdates } = watchTriggerable( + source, + v => console.log(`Changed to ${v}!`), +) + +source.value = 'bar' +await nextTick() // logs: Changed to bar! + +// Execution of WatchCallback via `trigger` does not require waiting +trigger() // logs: Changed to bar! +``` + +### `onCleanup` +When you want to manually call a `watch` that uses the onCleanup parameter; simply taking the `WatchCallback` out and calling it doesn't make it easy to implement the `onCleanup` parameter. + +Using `watchTriggerable` will solve this problem. +```ts +import { watchTriggerable } from '@vueuse/core' +import { ref } from 'vue' + +const source = ref(0) + +const { trigger } = watchTriggerable( + source, + async (v, _, onCleanup) => { + let canceled = false + onCleanup(() => canceled = true) + + await new Promise(resolve => setTimeout(resolve, 500)) + if (canceled) + return + + console.log(`The value is "${v}"\n`) + }, +) + +source.value = 1 // no log +await trigger() // logs (after 500 ms): The value is "1" +``` diff --git a/packages/shared/watchTriggerable/index.test.ts b/packages/shared/watchTriggerable/index.test.ts new file mode 100644 index 00000000000..449e7011656 --- /dev/null +++ b/packages/shared/watchTriggerable/index.test.ts @@ -0,0 +1,97 @@ +import { nextTick, reactive, ref } from 'vue-demi' +import { watchTriggerable } from '.' +describe('watchTriggerable', () => { + test('this should work', async () => { + const source = ref(0) + const effect = ref(0) + let cleanupCount = -1 + const { trigger } = watchTriggerable(source, (value, oldValue, onCleanup) => { + onCleanup(() => { + cleanupCount = value + }) + expect(value).toBe(source.value) + effect.value = value + }) + + // By default watch will be executed on the next tick + source.value = 1 + expect(effect.value).toBe(0) + await nextTick() + expect(effect.value).toBe(source.value) + expect(cleanupCount).toBe(-1) + + source.value = 2 + expect(cleanupCount).toBe(-1) + await nextTick() + expect(effect.value).toBe(source.value) + expect(cleanupCount).toBe(1) + + // trigger is executed immediately + effect.value = 0 + trigger() + expect(effect.value).toBe(source.value) + expect(cleanupCount).toBe(2) + }) + + test('source array', async () => { + const source1 = ref(0) + const source2 = reactive({ a: 'a' }) + const effect1 = ref(-1) + const effect2 = ref('z') + let cleanupCount = -1 + const { trigger } = watchTriggerable([source1, () => source2.a], ([value1, value2], [old1, old2], onCleanup) => { + onCleanup(() => { + cleanupCount = value1 + }) + expect(value1).toBe(source1.value) + effect1.value = value1 + effect2.value = value2 + }) + + trigger() + expect(effect1.value).toBe(source1.value) + expect(effect2.value).toBe(source2.a) + expect(cleanupCount).toBe(-1) + + source1.value = 1 + source2.a = 'b' + await nextTick() + expect(effect1.value).toBe(source1.value) + expect(effect2.value).toBe(source2.a) + expect(cleanupCount).toBe(0) + }) + + test('source reactive object', async () => { + const source = reactive({ a: 'a' }) + const effect = ref('') + let cleanupCount = 0 + const { trigger } = watchTriggerable(source, (value, old, onCleanup) => { + onCleanup(() => { + cleanupCount += 1 + }) + expect(value).toBe(source) + effect.value = value.a + }) + + trigger() + expect(effect.value).toBe(source.a) + expect(cleanupCount).toBe(0) + + source.a = 'b' + await nextTick() + expect(effect.value).toBe(source.a) + expect(cleanupCount).toBe(1) + }) + + test('trigger should await', async () => { + const source = ref(1) + const effect = ref(0) + const { trigger } = watchTriggerable(source, async (value) => { + await new Promise(resolve => setTimeout(resolve, 10)) + effect.value = value + }) + + await trigger() + expect(effect.value).toBe(source.value) + }) +}) diff --git a/packages/shared/watchTriggerable/index.ts b/packages/shared/watchTriggerable/index.ts new file mode 100644 index 00000000000..6caa9a58476 --- /dev/null +++ b/packages/shared/watchTriggerable/index.ts @@ -0,0 +1,90 @@ +import type { WatchSource } from 'vue-demi' +import { isReactive, unref } from 'vue-demi' +import type { MapOldSources, MapSources } from '../utils' +import type { WatchIgnorableReturn } from '../watchIgnorable' +import { watchIgnorable } from '../watchIgnorable' +import type { WatchWithFilterOptions } from '../watchWithFilter' + +// Watch that can be triggered manually +// A `watch` wrapper that supports manual triggering of `WatchCallback`, which returns an additional `trigger` to execute a `WatchCallback` immediately. + +export interface WatchTriggerableReturn extends WatchIgnorableReturn { + /** Execute `WatchCallback` immediately */ + trigger: () => FnReturnT +} + +type OnCleanup = (cleanupFn: () => void) => void + +export type WatchTriggerableCallback = (value: V, oldValue: OV, onCleanup: OnCleanup) => R + +export function watchTriggerable[]>, FnReturnT>(sources: [...T], cb: WatchTriggerableCallback, MapOldSources, FnReturnT>, options?: WatchWithFilterOptions): WatchTriggerableReturn +export function watchTriggerable(source: WatchSource, cb: WatchTriggerableCallback, options?: WatchWithFilterOptions): WatchTriggerableReturn +export function watchTriggerable(source: T, cb: WatchTriggerableCallback, options?: WatchWithFilterOptions): WatchTriggerableReturn + +export function watchTriggerable = false>( + source: any, + cb: any, + options: WatchWithFilterOptions = {}, +): WatchTriggerableReturn { + let cleanupFn: (() => void) | undefined + + function onEffect() { + if (!cleanupFn) + return + + const fn = cleanupFn + cleanupFn = undefined + fn() + } + + /** Register the function `cleanupFn` */ + function onCleanup(callback: () => void) { + cleanupFn = callback + } + + const _cb = ( + value: any, + oldValue: any, + ) => { + // When a new side effect occurs, clean up the previous side effect + onEffect() + + return cb(value, oldValue, onCleanup) + } + const res = watchIgnorable(source, _cb, options) + const { ignoreUpdates } = res + + const trigger = () => { + let res: any + ignoreUpdates(() => { + res = _cb(getWatchSources(source), getOldValue(source)) + }) + return res + } + + return { + ...res, + trigger, + } +} + +function getWatchSources(sources: any) { + if (isReactive(sources)) + return sources + if (Array.isArray(sources)) + return sources.map(item => getOneWatchSource(item)) + return getOneWatchSource(sources) +} + +function getOneWatchSource(source: Readonly>) { + return typeof source === 'function' + ? source() + : unref(source) +} + +// For calls triggered by trigger, the old value is unknown, so it cannot be returned +function getOldValue(source: any) { + return Array.isArray(source) + ? source.map(() => undefined) + : undefined +}