feat(defineModel): support local mutation by default, remove local option

ref https://github.com/vuejs/rfcs/discussions/503#discussioncomment-7566278
This commit is contained in:
Evan You 2023-12-12 16:47:34 +08:00
parent 7e60d1058f
commit f74785bc4a
3 changed files with 194 additions and 70 deletions

View File

@ -318,10 +318,6 @@ describe('defineModel', () => {
defineModel<string>({ default: 123 }) defineModel<string>({ default: 123 })
// @ts-expect-error unknown props option // @ts-expect-error unknown props option
defineModel({ foo: 123 }) defineModel({ foo: 123 })
// accept defineModel-only options
defineModel({ local: true })
defineModel('foo', { local: true })
}) })
describe('useModel', () => { describe('useModel', () => {

View File

@ -14,7 +14,9 @@ import {
ComputedRef, ComputedRef,
shallowReactive, shallowReactive,
nextTick, nextTick,
ref ref,
Ref,
watch
} from '@vue/runtime-test' } from '@vue/runtime-test'
import { import {
defineEmits, defineEmits,
@ -184,13 +186,17 @@ describe('SFC <script setup> helpers', () => {
foo.value = 'bar' foo.value = 'bar'
} }
const compRender = vi.fn()
const Comp = defineComponent({ const Comp = defineComponent({
props: ['modelValue'], props: ['modelValue'],
emits: ['update:modelValue'], emits: ['update:modelValue'],
setup(props) { setup(props) {
foo = useModel(props, 'modelValue') foo = useModel(props, 'modelValue')
}, return () => {
render() {} compRender()
return foo.value
}
}
}) })
const msg = ref('') const msg = ref('')
@ -206,6 +212,8 @@ describe('SFC <script setup> helpers', () => {
expect(foo.value).toBe('') expect(foo.value).toBe('')
expect(msg.value).toBe('') expect(msg.value).toBe('')
expect(setValue).not.toBeCalled() expect(setValue).not.toBeCalled()
expect(compRender).toBeCalledTimes(1)
expect(serializeInner(root)).toBe('')
// update from child // update from child
update() update()
@ -214,42 +222,55 @@ describe('SFC <script setup> helpers', () => {
expect(msg.value).toBe('bar') expect(msg.value).toBe('bar')
expect(foo.value).toBe('bar') expect(foo.value).toBe('bar')
expect(setValue).toBeCalledTimes(1) expect(setValue).toBeCalledTimes(1)
expect(compRender).toBeCalledTimes(2)
expect(serializeInner(root)).toBe('bar')
// update from parent // update from parent
msg.value = 'qux' msg.value = 'qux'
expect(msg.value).toBe('qux')
await nextTick() await nextTick()
expect(msg.value).toBe('qux') expect(msg.value).toBe('qux')
expect(foo.value).toBe('qux') expect(foo.value).toBe('qux')
expect(setValue).toBeCalledTimes(1) expect(setValue).toBeCalledTimes(1)
expect(compRender).toBeCalledTimes(3)
expect(serializeInner(root)).toBe('qux')
}) })
test('local', async () => { test('without parent value (local mutation)', async () => {
let foo: any let foo: any
const update = () => { const update = () => {
foo.value = 'bar' foo.value = 'bar'
} }
const compRender = vi.fn()
const Comp = defineComponent({ const Comp = defineComponent({
props: ['foo'], props: ['foo'],
emits: ['update:foo'], emits: ['update:foo'],
setup(props) { setup(props) {
foo = useModel(props, 'foo', { local: true }) foo = useModel(props, 'foo')
}, return () => {
render() {} compRender()
return foo.value
}
}
}) })
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const updateFoo = vi.fn() const updateFoo = vi.fn()
render(h(Comp, { 'onUpdate:foo': updateFoo }), root) render(h(Comp, { 'onUpdate:foo': updateFoo }), root)
expect(compRender).toBeCalledTimes(1)
expect(serializeInner(root)).toBe('<!---->')
expect(foo.value).toBeUndefined() expect(foo.value).toBeUndefined()
update() update()
// when parent didn't provide value, local mutation is enabled
expect(foo.value).toBe('bar') expect(foo.value).toBe('bar')
await nextTick() await nextTick()
expect(updateFoo).toBeCalledTimes(1) expect(updateFoo).toBeCalledTimes(1)
expect(compRender).toBeCalledTimes(2)
expect(serializeInner(root)).toBe('bar')
}) })
test('default value', async () => { test('default value', async () => {
@ -257,25 +278,156 @@ describe('SFC <script setup> helpers', () => {
const inc = () => { const inc = () => {
count.value++ count.value++
} }
const compRender = vi.fn()
const Comp = defineComponent({ const Comp = defineComponent({
props: { count: { default: 0 } }, props: { count: { default: 0 } },
emits: ['update:count'], emits: ['update:count'],
setup(props) { setup(props) {
count = useModel(props, 'count', { local: true }) count = useModel(props, 'count')
}, return () => {
render() {} compRender()
return count.value
}
}
}) })
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const updateCount = vi.fn() const updateCount = vi.fn()
render(h(Comp, { 'onUpdate:count': updateCount }), root) render(h(Comp, { 'onUpdate:count': updateCount }), root)
expect(compRender).toBeCalledTimes(1)
expect(serializeInner(root)).toBe('0')
expect(count.value).toBe(0) expect(count.value).toBe(0)
inc() inc()
// when parent didn't provide value, local mutation is enabled
expect(count.value).toBe(1) expect(count.value).toBe(1)
await nextTick() await nextTick()
expect(updateCount).toBeCalledTimes(1) expect(updateCount).toBeCalledTimes(1)
expect(compRender).toBeCalledTimes(2)
expect(serializeInner(root)).toBe('1')
})
test('parent limiting child value', async () => {
let childCount: Ref<number>
const compRender = vi.fn()
const Comp = defineComponent({
props: ['count'],
emits: ['update:count'],
setup(props) {
childCount = useModel(props, 'count')
return () => {
compRender()
return childCount.value
}
}
})
const Parent = defineComponent({
setup() {
const count = ref(0)
watch(count, () => {
if (count.value < 0) {
count.value = 0
}
})
return () =>
h(Comp, {
count: count.value,
'onUpdate:count': val => {
count.value = val
}
})
}
})
const root = nodeOps.createElement('div')
render(h(Parent), root)
expect(serializeInner(root)).toBe('0')
// child update
childCount!.value = 1
// not yet updated
expect(childCount!.value).toBe(0)
await nextTick()
expect(childCount!.value).toBe(1)
expect(serializeInner(root)).toBe('1')
// child update to invalid value
childCount!.value = -1
// not yet updated
expect(childCount!.value).toBe(1)
await nextTick()
// limited to 0 by parent
expect(childCount!.value).toBe(0)
expect(serializeInner(root)).toBe('0')
})
test('has parent value -> no parent value', async () => {
let childCount: Ref<number>
const compRender = vi.fn()
const Comp = defineComponent({
props: ['count'],
emits: ['update:count'],
setup(props) {
childCount = useModel(props, 'count')
return () => {
compRender()
return childCount.value
}
}
})
const toggle = ref(true)
const Parent = defineComponent({
setup() {
const count = ref(0)
return () =>
toggle.value
? h(Comp, {
count: count.value,
'onUpdate:count': val => {
count.value = val
}
})
: h(Comp)
}
})
const root = nodeOps.createElement('div')
render(h(Parent), root)
expect(serializeInner(root)).toBe('0')
// child update
childCount!.value = 1
// not yet updated
expect(childCount!.value).toBe(0)
await nextTick()
expect(childCount!.value).toBe(1)
expect(serializeInner(root)).toBe('1')
// parent change
toggle.value = false
await nextTick()
// localValue should be reset
expect(childCount!.value).toBeUndefined()
expect(serializeInner(root)).toBe('<!---->')
// child local mutation should continue to work
childCount!.value = 2
expect(childCount!.value).toBe(2)
await nextTick()
expect(serializeInner(root)).toBe('2')
}) })
}) })

View File

@ -5,7 +5,8 @@ import {
Prettify, Prettify,
UnionToIntersection, UnionToIntersection,
extend, extend,
LooseRequired LooseRequired,
hasChanged
} from '@vue/shared' } from '@vue/shared'
import { import {
getCurrentInstance, getCurrentInstance,
@ -30,8 +31,8 @@ import {
} from './componentProps' } from './componentProps'
import { warn } from './warning' import { warn } from './warning'
import { SlotsType, StrictUnwrapSlotsType } from './componentSlots' import { SlotsType, StrictUnwrapSlotsType } from './componentSlots'
import { Ref, ref } from '@vue/reactivity' import { Ref, customRef, ref } from '@vue/reactivity'
import { watch, watchSyncEffect } from './apiWatch' import { watchSyncEffect } from '.'
// dev only // dev only
const warnRuntimeUsage = (method: string) => const warnRuntimeUsage = (method: string) =>
@ -227,9 +228,8 @@ export function defineSlots<
* Otherwise the prop name will default to "modelValue". In both cases, you * Otherwise the prop name will default to "modelValue". In both cases, you
* can also pass an additional object which will be used as the prop's options. * can also pass an additional object which will be used as the prop's options.
* *
* The options object can also specify an additional option, `local`. When set * If the parent did not provide the corresponding v-model props, the returned
* to `true`, the ref can be locally mutated even if the parent did not pass * ref can still be used and will behave like a normal local ref.
* the matching `v-model`.
* *
* @example * @example
* ```ts * ```ts
@ -246,32 +246,26 @@ export function defineSlots<
* *
* // with specified name and default value * // with specified name and default value
* const count = defineModel<number>('count', { default: 0 }) * const count = defineModel<number>('count', { default: 0 })
*
* // local mutable model, can be mutated locally
* // even if the parent did not pass the matching `v-model`.
* const count = defineModel<number>('count', { local: true, default: 0 })
* ``` * ```
*/ */
export function defineModel<T>( export function defineModel<T>(
options: { required: true } & PropOptions<T> & DefineModelOptions options: { required: true } & PropOptions<T>
): Ref<T> ): Ref<T>
export function defineModel<T>( export function defineModel<T>(
options: { default: any } & PropOptions<T> & DefineModelOptions options: { default: any } & PropOptions<T>
): Ref<T> ): Ref<T>
export function defineModel<T>( export function defineModel<T>(options?: PropOptions<T>): Ref<T | undefined>
options?: PropOptions<T> & DefineModelOptions
): Ref<T | undefined>
export function defineModel<T>( export function defineModel<T>(
name: string, name: string,
options: { required: true } & PropOptions<T> & DefineModelOptions options: { required: true } & PropOptions<T>
): Ref<T> ): Ref<T>
export function defineModel<T>( export function defineModel<T>(
name: string, name: string,
options: { default: any } & PropOptions<T> & DefineModelOptions options: { default: any } & PropOptions<T>
): Ref<T> ): Ref<T>
export function defineModel<T>( export function defineModel<T>(
name: string, name: string,
options?: PropOptions<T> & DefineModelOptions options?: PropOptions<T>
): Ref<T | undefined> ): Ref<T | undefined>
export function defineModel(): any { export function defineModel(): any {
if (__DEV__) { if (__DEV__) {
@ -279,10 +273,6 @@ export function defineModel(): any {
} }
} }
interface DefineModelOptions {
local?: boolean
}
type NotUndefined<T> = T extends undefined ? never : T type NotUndefined<T> = T extends undefined ? never : T
type InferDefaults<T> = { type InferDefaults<T> = {
@ -357,14 +347,9 @@ export function useAttrs(): SetupContext['attrs'] {
export function useModel<T extends Record<string, any>, K extends keyof T>( export function useModel<T extends Record<string, any>, K extends keyof T>(
props: T, props: T,
name: K, name: K
options?: { local?: boolean }
): Ref<T[K]> ): Ref<T[K]>
export function useModel( export function useModel(props: Record<string, any>, name: string): Ref {
props: Record<string, any>,
name: string,
options?: { local?: boolean }
): Ref {
const i = getCurrentInstance()! const i = getCurrentInstance()!
if (__DEV__ && !i) { if (__DEV__ && !i) {
warn(`useModel() called without active instance.`) warn(`useModel() called without active instance.`)
@ -376,34 +361,25 @@ export function useModel(
return ref() as any return ref() as any
} }
if (options && options.local) { let localValue: any
const proxy = ref<any>(props[name]) watchSyncEffect(() => {
watchSyncEffect(() => { localValue = props[name]
proxy.value = props[name] })
})
watch( return customRef((track, trigger) => ({
proxy, get() {
value => { track()
if (value !== props[name]) { return localValue
i.emit(`update:${name}`, value) },
} set(value) {
}, const rawProps = i.vnode!.props
{ flush: 'sync' } if (!(rawProps && name in rawProps) && hasChanged(value, localValue)) {
) localValue = value
trigger()
return proxy
} else {
return {
__v_isRef: true,
get value() {
return props[name]
},
set value(value) {
i.emit(`update:${name}`, value)
} }
} as any i.emit(`update:${name}`, value)
} }
}))
} }
function getContext(): SetupContext { function getContext(): SetupContext {