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`)
})
test('should error when watching destructured prop', () => {
test('should error when passing destructured prop into certain methods', () => {
expect(() =>
compile(
`<script setup>
@ -303,7 +303,9 @@ describe('sfc props transform', () => {
watch(foo, () => {})
</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(
@ -313,7 +315,33 @@ describe('sfc props transform', () => {
w(foo, () => {})
</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

View File

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

View File

@ -32,7 +32,7 @@ export function transformDestructuredProps(
offset = 0,
knownProps: PropsDestructureBindings,
error: (msg: string, node: Node, end?: number) => never,
watchMethodName = 'watch'
vueImportAliases: Record<string, string>
) {
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)) {

View File

@ -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<number>) {
// ref unwrapping
expectType<number>(unref(arg))
expectType<number>(toValue(arg))
expectType<number>(toValue(() => 123))
// ref inner type should be unwrapped
const nestedRef = ref({
@ -203,6 +210,13 @@ expectType<Ref<string>>(p2.obj.k)
// Should not distribute Refs over union
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
expectType<{
a: Ref<number>
@ -319,3 +333,58 @@ describe('reactive in shallow ref', () => {
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'
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,

View File

@ -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,

View File

@ -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<T extends object>(
value: T
): [T] extends [Ref] ? T : Ref<UnwrapRef<T>>
export function ref<T extends Ref>(value: T): T
export function ref<T>(value: T): Ref<UnwrapRef<T>>
export function ref<T = any>(): Ref<T | undefined>
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 = 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
* 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<T>(ref: T | Ref<T>): T {
export function unref<T>(ref: MaybeRef<T>): 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<T>(source: MaybeRefOrGetter<T>): T {
return isFunction(source) ? source() : unref(source)
}
const shallowUnwrapHandlers: ProxyHandler<any> = {
get: (target, key, receiver) => unref(Reflect.get(target, key, 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) : {}
for (const key in object) {
ret[key] = toRef(object, key)
ret[key] = propertyToRef(object, key)
}
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>>
/**
* 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<T> = IfAny<T, Ref<T>, [T] extends [Ref] ? T : Ref<T>>
* 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<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>(
object: T,
key: K
@ -371,15 +424,31 @@ export function toRef<T extends object, K extends keyof T>(
key: K,
defaultValue: T[K]
): ToRef<Exclude<T[K], undefined>>
export function toRef<T extends object, K extends keyof T>(
object: T,
key: K,
defaultValue?: T[K]
): ToRef<T[K]> {
const val = object[key]
export function toRef(
source: Record<string, any> | 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<string, any>,
key,
defaultValue
) as any)
}
// corner case when use narrows type

View File

@ -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,