From 44378d0d59723449ee53bb1601115dcd7fc4367d Mon Sep 17 00:00:00 2001 From: Evan You Date: Sun, 2 Apr 2023 10:17:51 +0800 Subject: [PATCH] feat(reactivity): improve support of getter usage in reactivity APIs (#7997) --- .../compileScriptPropsDestructure.spec.ts | 34 +++++- packages/compiler-sfc/src/compileScript.ts | 2 +- .../src/compileScriptPropsDestructure.ts | 27 +++-- packages/dts-test/ref.test-d.ts | 71 +++++++++++- packages/reactivity/__tests__/ref.spec.ts | 27 ++++- packages/reactivity/src/index.ts | 3 + packages/reactivity/src/ref.ts | 105 +++++++++++++++--- packages/runtime-core/src/index.ts | 3 + 8 files changed, 237 insertions(+), 35 deletions(-) diff --git a/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts index 346f95a5c7a..8487270c265 100644 --- a/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts @@ -294,7 +294,7 @@ describe('sfc props transform', () => { ).toThrow(`Cannot assign to destructured props`) }) - test('should error when watching destructured prop', () => { + test('should error when passing destructured prop into certain methods', () => { expect(() => compile( `` ) - ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`) + ).toThrow( + `"foo" is a destructured prop and should not be passed directly to watch().` + ) expect(() => compile( @@ -313,7 +315,33 @@ describe('sfc props transform', () => { w(foo, () => {}) ` ) - ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`) + ).toThrow( + `"foo" is a destructured prop and should not be passed directly to watch().` + ) + + expect(() => + compile( + `` + ) + ).toThrow( + `"foo" is a destructured prop and should not be passed directly to toRef().` + ) + + expect(() => + compile( + `` + ) + ).toThrow( + `"foo" is a destructured prop and should not be passed directly to toRef().` + ) }) // not comprehensive, but should help for most common cases diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index de9e11d071f..ec476c4ad16 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -1442,7 +1442,7 @@ export function compileScript( startOffset, propsDestructuredBindings, error, - vueImportAliases.watch + vueImportAliases ) } diff --git a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts index bc38912653e..4ee09070d76 100644 --- a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts +++ b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts @@ -32,7 +32,7 @@ export function transformDestructuredProps( offset = 0, knownProps: PropsDestructureBindings, error: (msg: string, node: Node, end?: number) => never, - watchMethodName = 'watch' + vueImportAliases: Record ) { const rootScope: Scope = {} const scopeStack: Scope[] = [rootScope] @@ -152,6 +152,19 @@ export function transformDestructuredProps( return false } + function checkUsage(node: Node, method: string, alias = method) { + if (isCallOf(node, alias)) { + const arg = unwrapTSNode(node.arguments[0]) + if (arg.type === 'Identifier') { + error( + `"${arg.name}" is a destructured prop and should not be passed directly to ${method}(). ` + + `Pass a getter () => ${arg.name} instead.`, + arg + ) + } + } + } + // check root scope first walkScope(ast, true) ;(walk as any)(ast, { @@ -169,16 +182,8 @@ export function transformDestructuredProps( return this.skip() } - if (isCallOf(node, watchMethodName)) { - const arg = unwrapTSNode(node.arguments[0]) - if (arg.type === 'Identifier') { - error( - `"${arg.name}" is a destructured prop and cannot be directly watched. ` + - `Use a getter () => ${arg.name} instead.`, - arg - ) - } - } + checkUsage(node, 'watch', vueImportAliases.watch) + checkUsage(node, 'toRef', vueImportAliases.toRef) // function scopes if (isFunctionType(node)) { diff --git a/packages/dts-test/ref.test-d.ts b/packages/dts-test/ref.test-d.ts index dbf54de09c8..bbcde45ddda 100644 --- a/packages/dts-test/ref.test-d.ts +++ b/packages/dts-test/ref.test-d.ts @@ -7,10 +7,15 @@ import { reactive, proxyRefs, toRef, + toValue, toRefs, ToRefs, shallowReactive, - readonly + readonly, + MaybeRef, + MaybeRefOrGetter, + ComputedRef, + computed } from 'vue' import { expectType, describe } from './utils' @@ -26,6 +31,8 @@ function plainType(arg: number | Ref) { // ref unwrapping expectType(unref(arg)) + expectType(toValue(arg)) + expectType(toValue(() => 123)) // ref inner type should be unwrapped const nestedRef = ref({ @@ -203,6 +210,13 @@ expectType>(p2.obj.k) // Should not distribute Refs over union expectType>(toRef(obj, 'c')) + expectType>(toRef(() => 123)) + expectType>(toRef(() => obj.c)) + + const r = toRef(() => 123) + // @ts-expect-error + r.value = 234 + // toRefs expectType<{ a: Ref @@ -319,3 +333,58 @@ describe('reactive in shallow ref', () => { expectType(x.value.a.b) }) + +describe('toRef <-> toValue', () => { + function foo( + a: MaybeRef, + b: () => string, + c: MaybeRefOrGetter, + d: ComputedRef + ) { + const r = toRef(a) + expectType>(r) + // writable + r.value = 'foo' + + const rb = toRef(b) + expectType>>(rb) + // @ts-expect-error ref created from getter should be readonly + rb.value = 'foo' + + const rc = toRef(c) + expectType | Ref>>(rc) + // @ts-expect-error ref created from MaybeReadonlyRef should be readonly + rc.value = 'foo' + + const rd = toRef(d) + expectType>(rd) + // @ts-expect-error ref created from computed ref should be readonly + rd.value = 'foo' + + expectType(toValue(a)) + expectType(toValue(b)) + expectType(toValue(c)) + expectType(toValue(d)) + + return { + r: toValue(r), + rb: toValue(rb), + rc: toValue(rc), + rd: toValue(rd) + } + } + + expectType<{ + r: string + rb: string + rc: string + rd: string + }>( + foo( + 'foo', + () => 'bar', + ref('baz'), + computed(() => 'hi') + ) + ) +}) diff --git a/packages/reactivity/__tests__/ref.spec.ts b/packages/reactivity/__tests__/ref.spec.ts index 646cc6e6791..718b2bc61b8 100644 --- a/packages/reactivity/__tests__/ref.spec.ts +++ b/packages/reactivity/__tests__/ref.spec.ts @@ -11,7 +11,12 @@ import { } from '../src/index' import { computed } from '@vue/runtime-dom' import { shallowRef, unref, customRef, triggerRef } from '../src/ref' -import { isShallow, readonly, shallowReactive } from '../src/reactive' +import { + isReadonly, + isShallow, + readonly, + shallowReactive +} from '../src/reactive' describe('reactivity/ref', () => { it('should hold a value', () => { @@ -275,6 +280,15 @@ describe('reactivity/ref', () => { expect(toRef(r, 'x')).toBe(r.x) }) + test('toRef on array', () => { + const a = reactive(['a', 'b']) + const r = toRef(a, 1) + expect(r.value).toBe('b') + r.value = 'c' + expect(r.value).toBe('c') + expect(a[1]).toBe('c') + }) + test('toRef default value', () => { const a: { x: number | undefined } = { x: undefined } const x = toRef(a, 'x', 1) @@ -287,6 +301,17 @@ describe('reactivity/ref', () => { expect(x.value).toBe(1) }) + test('toRef getter', () => { + const x = toRef(() => 1) + expect(x.value).toBe(1) + expect(isRef(x)).toBe(true) + expect(unref(x)).toBe(1) + //@ts-expect-error + expect(() => (x.value = 123)).toThrow() + + expect(isReadonly(x)).toBe(true) + }) + test('toRefs', () => { const a = reactive({ x: 1, diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 60707febef4..ee4da5b1935 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -3,12 +3,15 @@ export { shallowRef, isRef, toRef, + toValue, toRefs, unref, proxyRefs, customRef, triggerRef, type Ref, + type MaybeRef, + type MaybeRefOrGetter, type ToRef, type ToRefs, type UnwrapRef, diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 85a19802d4f..5dd31a9f8ca 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -6,7 +6,7 @@ import { triggerEffects } from './effect' import { TrackOpTypes, TriggerOpTypes } from './operations' -import { isArray, hasChanged, IfAny } from '@vue/shared' +import { isArray, hasChanged, IfAny, isFunction, isObject } from '@vue/shared' import { isProxy, toRaw, @@ -87,9 +87,7 @@ export function isRef(r: any): r is Ref { * @param value - The object to wrap in the ref. * @see {@link https://vuejs.org/api/reactivity-core.html#ref} */ -export function ref( - value: T -): [T] extends [Ref] ? T : Ref> +export function ref(value: T): T export function ref(value: T): Ref> export function ref(): Ref export function ref(value?: unknown) { @@ -191,6 +189,9 @@ export function triggerRef(ref: Ref) { triggerRefValue(ref, __DEV__ ? ref.value : void 0) } +export type MaybeRef = T | Ref +export type MaybeRefOrGetter = MaybeRef | (() => T) + /** * Returns the inner value if the argument is a ref, otherwise return the * argument itself. This is a sugar function for @@ -207,10 +208,30 @@ export function triggerRef(ref: Ref) { * @param ref - Ref or plain value to be converted into the plain value. * @see {@link https://vuejs.org/api/reactivity-utilities.html#unref} */ -export function unref(ref: T | Ref): T { +export function unref(ref: MaybeRef): T { return isRef(ref) ? (ref.value as any) : ref } +/** + * Normalizes values / refs / getters to values. + * This is similar to {@link unref()}, except that it also normalizes getters. + * If the argument is a getter, it will be invoked and its return value will + * be returned. + * + * @example + * ```js + * toValue(1) // 1 + * toValue(ref(1)) // 1 + * toValue(() => 1) // 1 + * ``` + * + * @param source - A getter, an existing ref, or a non-function value. + * @see {@link https://vuejs.org/api/reactivity-utilities.html#tovalue} + */ +export function toValue(source: MaybeRefOrGetter): T { + return isFunction(source) ? source() : unref(source) +} + const shallowUnwrapHandlers: ProxyHandler = { get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), set: (target, key, value, receiver) => { @@ -305,7 +326,7 @@ export function toRefs(object: T): ToRefs { } const ret: any = isArray(object) ? new Array(object.length) : {} for (const key in object) { - ret[key] = toRef(object, key) + ret[key] = propertyToRef(object, key) } return ret } @@ -333,12 +354,36 @@ class ObjectRefImpl { } } +class GetterRefImpl { + public readonly __v_isRef = true + public readonly __v_isReadonly = true + constructor(private readonly _getter: () => T) {} + get value() { + return this._getter() + } +} + export type ToRef = IfAny, [T] extends [Ref] ? T : Ref> /** - * Can be used to create a ref for a property on a source reactive object. The - * created ref is synced with its source property: mutating the source property - * will update the ref, and vice-versa. + * Used to normalize values / refs / getters into refs. + * + * @example + * ```js + * // returns existing refs as-is + * toRef(existingRef) + * + * // creates a ref that calls the getter on .value access + * toRef(() => props.foo) + * + * // creates normal refs from non-function values + * // equivalent to ref(1) + * toRef(1) + * ``` + * + * Can also be used to create a ref for a property on a source reactive object. + * The created ref is synced with its source property: mutating the source + * property will update the ref, and vice-versa. * * @example * ```js @@ -358,10 +403,18 @@ export type ToRef = IfAny, [T] extends [Ref] ? T : Ref> * console.log(fooRef.value) // 3 * ``` * - * @param object - The reactive object containing the desired property. - * @param key - Name of the property in the reactive object. + * @param source - A getter, an existing ref, a non-function value, or a + * reactive object to create a property ref from. + * @param [key] - (optional) Name of the property in the reactive object. * @see {@link https://vuejs.org/api/reactivity-utilities.html#toref} */ +export function toRef( + value: T +): T extends () => infer R + ? Readonly> + : T extends Ref + ? T + : Ref> export function toRef( object: T, key: K @@ -371,15 +424,31 @@ export function toRef( key: K, defaultValue: T[K] ): ToRef> -export function toRef( - object: T, - key: K, - defaultValue?: T[K] -): ToRef { - const val = object[key] +export function toRef( + source: Record | MaybeRef, + key?: string, + defaultValue?: unknown +): Ref { + if (isRef(source)) { + return source + } else if (isFunction(source)) { + return new GetterRefImpl(source as () => unknown) as any + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key!, defaultValue) + } else { + return ref(source) + } +} + +function propertyToRef(source: object, key: string, defaultValue?: unknown) { + const val = (source as any)[key] return isRef(val) ? val - : (new ObjectRefImpl(object, key, defaultValue) as any) + : (new ObjectRefImpl( + source as Record, + key, + defaultValue + ) as any) } // corner case when use narrows type diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 06f9a2affd4..936d6ca3565 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -11,6 +11,7 @@ export { proxyRefs, isRef, toRef, + toValue, toRefs, isProxy, isReactive, @@ -152,6 +153,8 @@ declare module '@vue/reactivity' { export { TrackOpTypes, TriggerOpTypes } from '@vue/reactivity' export type { Ref, + MaybeRef, + MaybeRefOrGetter, ToRef, ToRefs, UnwrapRef,