feat(reactivity): improve support of getter usage in reactivity APIs (#7997)

This commit is contained in:
Evan You 2023-04-02 10:17:51 +08:00 committed by GitHub
parent dfb21a5363
commit 59e828448e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 237 additions and 35 deletions

View File

@ -294,7 +294,7 @@ describe('sfc props transform', () => {
).toThrow(`Cannot assign to destructured props`) ).toThrow(`Cannot assign to destructured props`)
}) })
test('should error when watching destructured prop', () => { test('should error when passing destructured prop into certain methods', () => {
expect(() => expect(() =>
compile( compile(
`<script setup> `<script setup>
@ -303,7 +303,9 @@ describe('sfc props transform', () => {
watch(foo, () => {}) watch(foo, () => {})
</script>` </script>`
) )
).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(() => expect(() =>
compile( compile(
@ -313,7 +315,33 @@ describe('sfc props transform', () => {
w(foo, () => {}) w(foo, () => {})
</script>` </script>`
) )
).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(
`<script setup>
import { toRef } from 'vue'
const { foo } = defineProps(['foo'])
toRef(foo)
</script>`
)
).toThrow(
`"foo" is a destructured prop and should not be passed directly to toRef().`
)
expect(() =>
compile(
`<script setup>
import { toRef as r } from 'vue'
const { foo } = defineProps(['foo'])
r(foo)
</script>`
)
).toThrow(
`"foo" is a destructured prop and should not be passed directly to toRef().`
)
}) })
// not comprehensive, but should help for most common cases // not comprehensive, but should help for most common cases

View File

@ -1442,7 +1442,7 @@ export function compileScript(
startOffset, startOffset,
propsDestructuredBindings, propsDestructuredBindings,
error, error,
vueImportAliases.watch vueImportAliases
) )
} }

View File

@ -32,7 +32,7 @@ export function transformDestructuredProps(
offset = 0, offset = 0,
knownProps: PropsDestructureBindings, knownProps: PropsDestructureBindings,
error: (msg: string, node: Node, end?: number) => never, error: (msg: string, node: Node, end?: number) => never,
watchMethodName = 'watch' vueImportAliases: Record<string, string>
) { ) {
const rootScope: Scope = {} const rootScope: Scope = {}
const scopeStack: Scope[] = [rootScope] const scopeStack: Scope[] = [rootScope]
@ -152,6 +152,19 @@ export function transformDestructuredProps(
return false 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 // check root scope first
walkScope(ast, true) walkScope(ast, true)
;(walk as any)(ast, { ;(walk as any)(ast, {
@ -169,16 +182,8 @@ export function transformDestructuredProps(
return this.skip() return this.skip()
} }
if (isCallOf(node, watchMethodName)) { checkUsage(node, 'watch', vueImportAliases.watch)
const arg = unwrapTSNode(node.arguments[0]) checkUsage(node, 'toRef', vueImportAliases.toRef)
if (arg.type === 'Identifier') {
error(
`"${arg.name}" is a destructured prop and cannot be directly watched. ` +
`Use a getter () => ${arg.name} instead.`,
arg
)
}
}
// function scopes // function scopes
if (isFunctionType(node)) { if (isFunctionType(node)) {

View File

@ -7,10 +7,15 @@ import {
reactive, reactive,
proxyRefs, proxyRefs,
toRef, toRef,
toValue,
toRefs, toRefs,
ToRefs, ToRefs,
shallowReactive, shallowReactive,
readonly readonly,
MaybeRef,
MaybeRefOrGetter,
ComputedRef,
computed
} from 'vue' } from 'vue'
import { expectType, describe } from './utils' import { expectType, describe } from './utils'
@ -26,6 +31,8 @@ function plainType(arg: number | Ref<number>) {
// ref unwrapping // ref unwrapping
expectType<number>(unref(arg)) expectType<number>(unref(arg))
expectType<number>(toValue(arg))
expectType<number>(toValue(() => 123))
// ref inner type should be unwrapped // ref inner type should be unwrapped
const nestedRef = ref({ const nestedRef = ref({
@ -203,6 +210,13 @@ expectType<Ref<string>>(p2.obj.k)
// Should not distribute Refs over union // Should not distribute Refs over union
expectType<Ref<number | string>>(toRef(obj, 'c')) expectType<Ref<number | string>>(toRef(obj, 'c'))
expectType<Ref<number>>(toRef(() => 123))
expectType<Ref<number | string>>(toRef(() => obj.c))
const r = toRef(() => 123)
// @ts-expect-error
r.value = 234
// toRefs // toRefs
expectType<{ expectType<{
a: Ref<number> a: Ref<number>
@ -319,3 +333,58 @@ describe('reactive in shallow ref', () => {
expectType<number>(x.value.a.b) expectType<number>(x.value.a.b)
}) })
describe('toRef <-> toValue', () => {
function foo(
a: MaybeRef<string>,
b: () => string,
c: MaybeRefOrGetter<string>,
d: ComputedRef<string>
) {
const r = toRef(a)
expectType<Ref<string>>(r)
// writable
r.value = 'foo'
const rb = toRef(b)
expectType<Readonly<Ref<string>>>(rb)
// @ts-expect-error ref created from getter should be readonly
rb.value = 'foo'
const rc = toRef(c)
expectType<Readonly<Ref<string> | Ref<string>>>(rc)
// @ts-expect-error ref created from MaybeReadonlyRef should be readonly
rc.value = 'foo'
const rd = toRef(d)
expectType<ComputedRef<string>>(rd)
// @ts-expect-error ref created from computed ref should be readonly
rd.value = 'foo'
expectType<string>(toValue(a))
expectType<string>(toValue(b))
expectType<string>(toValue(c))
expectType<string>(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')
)
)
})

View File

@ -11,7 +11,12 @@ import {
} from '../src/index' } from '../src/index'
import { computed } from '@vue/runtime-dom' import { computed } from '@vue/runtime-dom'
import { shallowRef, unref, customRef, triggerRef } from '../src/ref' 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', () => { describe('reactivity/ref', () => {
it('should hold a value', () => { it('should hold a value', () => {
@ -275,6 +280,15 @@ describe('reactivity/ref', () => {
expect(toRef(r, 'x')).toBe(r.x) 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', () => { test('toRef default value', () => {
const a: { x: number | undefined } = { x: undefined } const a: { x: number | undefined } = { x: undefined }
const x = toRef(a, 'x', 1) const x = toRef(a, 'x', 1)
@ -287,6 +301,17 @@ describe('reactivity/ref', () => {
expect(x.value).toBe(1) 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', () => { test('toRefs', () => {
const a = reactive({ const a = reactive({
x: 1, x: 1,

View File

@ -3,12 +3,15 @@ export {
shallowRef, shallowRef,
isRef, isRef,
toRef, toRef,
toValue,
toRefs, toRefs,
unref, unref,
proxyRefs, proxyRefs,
customRef, customRef,
triggerRef, triggerRef,
type Ref, type Ref,
type MaybeRef,
type MaybeRefOrGetter,
type ToRef, type ToRef,
type ToRefs, type ToRefs,
type UnwrapRef, type UnwrapRef,

View File

@ -6,7 +6,7 @@ import {
triggerEffects triggerEffects
} from './effect' } from './effect'
import { TrackOpTypes, TriggerOpTypes } from './operations' import { TrackOpTypes, TriggerOpTypes } from './operations'
import { isArray, hasChanged, IfAny } from '@vue/shared' import { isArray, hasChanged, IfAny, isFunction, isObject } from '@vue/shared'
import { import {
isProxy, isProxy,
toRaw, toRaw,
@ -87,9 +87,7 @@ export function isRef(r: any): r is Ref {
* @param value - The object to wrap in the ref. * @param value - The object to wrap in the ref.
* @see {@link https://vuejs.org/api/reactivity-core.html#ref} * @see {@link https://vuejs.org/api/reactivity-core.html#ref}
*/ */
export function ref<T extends object>( export function ref<T extends Ref>(value: T): T
value: T
): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
export function ref<T>(value: T): Ref<UnwrapRef<T>> export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined> export function ref<T = any>(): Ref<T | undefined>
export function ref(value?: unknown) { export function ref(value?: unknown) {
@ -191,6 +189,9 @@ export function triggerRef(ref: Ref) {
triggerRefValue(ref, __DEV__ ? ref.value : void 0) triggerRefValue(ref, __DEV__ ? ref.value : void 0)
} }
export type MaybeRef<T = any> = T | Ref<T>
export type MaybeRefOrGetter<T = any> = MaybeRef<T> | (() => T)
/** /**
* Returns the inner value if the argument is a ref, otherwise return the * Returns the inner value if the argument is a ref, otherwise return the
* argument itself. This is a sugar function for * 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. * @param ref - Ref or plain value to be converted into the plain value.
* @see {@link https://vuejs.org/api/reactivity-utilities.html#unref} * @see {@link https://vuejs.org/api/reactivity-utilities.html#unref}
*/ */
export function unref<T>(ref: T | Ref<T>): T { export function unref<T>(ref: MaybeRef<T>): T {
return isRef(ref) ? (ref.value as any) : ref 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<T>(source: MaybeRefOrGetter<T>): T {
return isFunction(source) ? source() : unref(source)
}
const shallowUnwrapHandlers: ProxyHandler<any> = { const shallowUnwrapHandlers: ProxyHandler<any> = {
get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => { set: (target, key, value, receiver) => {
@ -305,7 +326,7 @@ export function toRefs<T extends object>(object: T): ToRefs<T> {
} }
const ret: any = isArray(object) ? new Array(object.length) : {} const ret: any = isArray(object) ? new Array(object.length) : {}
for (const key in object) { for (const key in object) {
ret[key] = toRef(object, key) ret[key] = propertyToRef(object, key)
} }
return ret return ret
} }
@ -333,12 +354,36 @@ class ObjectRefImpl<T extends object, K extends keyof T> {
} }
} }
class GetterRefImpl<T> {
public readonly __v_isRef = true
public readonly __v_isReadonly = true
constructor(private readonly _getter: () => T) {}
get value() {
return this._getter()
}
}
export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>> export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>
/** /**
* Can be used to create a ref for a property on a source reactive object. The * Used to normalize values / refs / getters into refs.
* created ref is synced with its source property: mutating the source property *
* will update the ref, and vice-versa. * @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 * @example
* ```js * ```js
@ -358,10 +403,18 @@ export type ToRef<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>
* console.log(fooRef.value) // 3 * console.log(fooRef.value) // 3
* ``` * ```
* *
* @param object - The reactive object containing the desired property. * @param source - A getter, an existing ref, a non-function value, or a
* @param key - Name of the property in the reactive object. * 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} * @see {@link https://vuejs.org/api/reactivity-utilities.html#toref}
*/ */
export function toRef<T>(
value: T
): T extends () => infer R
? Readonly<Ref<R>>
: T extends Ref
? T
: Ref<UnwrapRef<T>>
export function toRef<T extends object, K extends keyof T>( export function toRef<T extends object, K extends keyof T>(
object: T, object: T,
key: K key: K
@ -371,15 +424,31 @@ export function toRef<T extends object, K extends keyof T>(
key: K, key: K,
defaultValue: T[K] defaultValue: T[K]
): ToRef<Exclude<T[K], undefined>> ): ToRef<Exclude<T[K], undefined>>
export function toRef<T extends object, K extends keyof T>( export function toRef(
object: T, source: Record<string, any> | MaybeRef,
key: K, key?: string,
defaultValue?: T[K] defaultValue?: unknown
): ToRef<T[K]> { ): Ref {
const val = object[key] 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) return isRef(val)
? val ? val
: (new ObjectRefImpl(object, key, defaultValue) as any) : (new ObjectRefImpl(
source as Record<string, any>,
key,
defaultValue
) as any)
} }
// corner case when use narrows type // corner case when use narrows type

View File

@ -11,6 +11,7 @@ export {
proxyRefs, proxyRefs,
isRef, isRef,
toRef, toRef,
toValue,
toRefs, toRefs,
isProxy, isProxy,
isReactive, isReactive,
@ -152,6 +153,8 @@ declare module '@vue/reactivity' {
export { TrackOpTypes, TriggerOpTypes } from '@vue/reactivity' export { TrackOpTypes, TriggerOpTypes } from '@vue/reactivity'
export type { export type {
Ref, Ref,
MaybeRef,
MaybeRefOrGetter,
ToRef, ToRef,
ToRefs, ToRefs,
UnwrapRef, UnwrapRef,