feat(defineModel): support modifiers and transformers

This commit is contained in:
Evan You 2023-12-26 22:13:04 +08:00
parent d7bb32f9a3
commit a772031ea8
7 changed files with 303 additions and 107 deletions

View File

@ -6,8 +6,11 @@ exports[`defineModel() > basic usage 1`] = `
export default {
props: {
"modelValue": { required: true },
"modelModifiers": {},
"count": {},
"countModifiers": {},
"toString": { type: Function },
"toStringModifiers": {},
},
emits: ["update:modelValue", "update:count", "update:toString"],
setup(__props, { expose: __expose }) {
@ -23,12 +26,58 @@ return { modelValue, c, toString }
}"
`;
exports[`defineModel() > get / set transformers 1`] = `
"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
"modelValue": {
required: true
},
"modelModifiers": {},
},
emits: ["update:modelValue"],
setup(__props, { expose: __expose }) {
__expose();
const modelValue = _useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 }, })
return { modelValue }
}
})"
`;
exports[`defineModel() > get / set transformers 2`] = `
"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
"modelValue": {
default: 0,
required: true,
},
"modelModifiers": {},
},
emits: ["update:modelValue"],
setup(__props, { expose: __expose }) {
__expose();
const modelValue = _useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 }, })
return { modelValue }
}
})"
`;
exports[`defineModel() > w/ array props 1`] = `
"import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
export default {
props: /*#__PURE__*/_mergeModels(['foo', 'bar'], {
"count": {},
"countModifiers": {},
}),
emits: ["update:count"],
setup(__props, { expose: __expose }) {
@ -49,6 +98,7 @@ exports[`defineModel() > w/ defineProps and defineEmits 1`] = `
export default {
props: /*#__PURE__*/_mergeModels({ foo: String }, {
"modelValue": { default: 0 },
"modelModifiers": {},
}),
emits: /*#__PURE__*/_mergeModels(['change'], ["update:modelValue"]),
setup(__props, { expose: __expose }) {
@ -64,47 +114,19 @@ return { count }
}"
`;
exports[`defineModel() > w/ local flag 1`] = `
"import { useModel as _useModel } from 'vue'
const local = true
export default {
props: {
"modelValue": { local: true, default: 1 },
"bar": { [key]: true },
"baz": { ...x },
"qux": x,
"foo2": { local: true, ...x },
"hoist": { local },
},
emits: ["update:modelValue", "update:bar", "update:baz", "update:qux", "update:foo2", "update:hoist"],
setup(__props, { expose: __expose }) {
__expose();
const foo = _useModel(__props, "modelValue", { local: true })
const bar = _useModel(__props, "bar", { [key]: true })
const baz = _useModel(__props, "baz", { ...x })
const qux = _useModel(__props, "qux", x)
const foo2 = _useModel(__props, "foo2", { local: true })
const hoist = _useModel(__props, "hoist", { local })
return { foo, bar, baz, qux, foo2, local, hoist }
}
}"
`;
exports[`defineModel() > w/ types, basic usage 1`] = `
"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
"modelValue": { type: [Boolean, String] },
"modelModifiers": {},
"count": { type: Number },
"countModifiers": {},
"disabled": { type: Number, ...{ required: false } },
"disabledModifiers": {},
"any": { type: Boolean, skipCheck: true },
"anyModifiers": {},
},
emits: ["update:modelValue", "update:count", "update:disabled", "update:any"],
setup(__props, { expose: __expose }) {
@ -127,10 +149,15 @@ exports[`defineModel() > w/ types, production mode 1`] = `
export default /*#__PURE__*/_defineComponent({
props: {
"modelValue": { type: Boolean },
"modelModifiers": {},
"fn": {},
"fnModifiers": {},
"fnWithDefault": { type: Function, ...{ default: () => null } },
"fnWithDefaultModifiers": {},
"str": {},
"strModifiers": {},
"optional": { required: false },
"optionalModifiers": {},
},
emits: ["update:modelValue", "update:fn", "update:fnWithDefault", "update:str", "update:optional"],
setup(__props, { expose: __expose }) {

View File

@ -69,6 +69,7 @@ describe('defineModel()', () => {
assertCode(content)
expect(content).toMatch(`props: /*#__PURE__*/_mergeModels(['foo', 'bar'], {
"count": {},
"countModifiers": {},
})`)
expect(content).toMatch(`const count = _useModel(__props, "count")`)
expect(content).not.toMatch('defineModel')
@ -79,29 +80,6 @@ describe('defineModel()', () => {
})
})
test('w/ local flag', () => {
const { content } = compile(
`<script setup>
const foo = defineModel({ local: true, default: 1 })
const bar = defineModel('bar', { [key]: true })
const baz = defineModel('baz', { ...x })
const qux = defineModel('qux', x)
const foo2 = defineModel('foo2', { local: true, ...x })
const local = true
const hoist = defineModel('hoist', { local })
</script>`,
)
assertCode(content)
expect(content).toMatch(`_useModel(__props, "modelValue", { local: true })`)
expect(content).toMatch(`_useModel(__props, "bar", { [key]: true })`)
expect(content).toMatch(`_useModel(__props, "baz", { ...x })`)
expect(content).toMatch(`_useModel(__props, "qux", x)`)
expect(content).toMatch(`_useModel(__props, "foo2", { local: true })`)
expect(content).toMatch(`_useModel(__props, "hoist", { local })`)
})
test('w/ types, basic usage', () => {
const { content, bindings } = compile(
`
@ -115,6 +93,7 @@ describe('defineModel()', () => {
)
assertCode(content)
expect(content).toMatch('"modelValue": { type: [Boolean, String] }')
expect(content).toMatch('"modelModifiers": {}')
expect(content).toMatch('"count": { type: Number }')
expect(content).toMatch(
'"disabled": { type: Number, ...{ required: false } }',
@ -176,4 +155,43 @@ describe('defineModel()', () => {
optional: BindingTypes.SETUP_REF,
})
})
test('get / set transformers', () => {
const { content } = compile(
`
<script setup lang="ts">
const modelValue = defineModel({
get(v) { return v - 1 },
set: (v) => { return v + 1 },
required: true
})
</script>
`,
)
assertCode(content)
expect(content).toMatch(/"modelValue": {\s+required: true,?\s+}/m)
expect(content).toMatch(
`_useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 }, })`,
)
const { content: content2 } = compile(
`
<script setup lang="ts">
const modelValue = defineModel({
default: 0,
get(v) { return v - 1 },
required: true,
set: (v) => { return v + 1 },
})
</script>
`,
)
assertCode(content2)
expect(content2).toMatch(
/"modelValue": {\s+default: 0,\s+required: true,?\s+}/m,
)
expect(content2).toMatch(
`_useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 }, })`,
)
})
})

View File

@ -1,4 +1,4 @@
import type { LVal, Node, ObjectProperty, TSType } from '@babel/types'
import type { LVal, Node, TSType } from '@babel/types'
import type { ScriptCompileContext } from './context'
import { inferRuntimeType } from './resolveType'
import {
@ -45,7 +45,42 @@ export function processDefineModel(
ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
}
const optionsString = options && ctx.getString(options)
let optionsString = options && ctx.getString(options)
let runtimeOptions = ''
let transformOptions = ''
if (options) {
if (options.type === 'ObjectExpression') {
for (let i = options.properties.length - 1; i >= 0; i--) {
const p = options.properties[i]
if (p.type === 'SpreadElement' || p.computed) {
runtimeOptions = optionsString!
break
}
if (
(p.type === 'ObjectProperty' || p.type === 'ObjectMethod') &&
((p.key.type === 'Identifier' &&
(p.key.name === 'get' || p.key.name === 'set')) ||
(p.key.type === 'StringLiteral' &&
(p.key.value === 'get' || p.key.value === 'set')))
) {
transformOptions = ctx.getString(p) + ', ' + transformOptions
// remove transform option from prop options to avoid duplicates
const offset = p.start! - options.start!
const next = options.properties[i + 1]
const end = (next ? next.start! : options.end! - 1) - options.start!
optionsString =
optionsString.slice(0, offset) + optionsString.slice(end)
}
}
if (!runtimeOptions && transformOptions) {
runtimeOptions = `{ ${transformOptions} }`
}
} else {
runtimeOptions = optionsString!
}
}
ctx.modelDecls[modelName] = {
type,
@ -56,31 +91,6 @@ export function processDefineModel(
// register binding type
ctx.bindingMetadata[modelName] = BindingTypes.PROPS
let runtimeOptions = ''
if (options) {
if (options.type === 'ObjectExpression') {
const local = options.properties.find(
p =>
p.type === 'ObjectProperty' &&
((p.key.type === 'Identifier' && p.key.name === 'local') ||
(p.key.type === 'StringLiteral' && p.key.value === 'local')),
) as ObjectProperty
if (local) {
runtimeOptions = `{ ${ctx.getString(local)} }`
} else {
for (const p of options.properties) {
if (p.type === 'SpreadElement' || p.computed) {
runtimeOptions = optionsString!
break
}
}
}
} else {
runtimeOptions = optionsString!
}
}
ctx.s.overwrite(
ctx.startOffset! + node.start!,
ctx.startOffset! + node.end!,
@ -133,6 +143,12 @@ export function genModelProps(ctx: ScriptCompileContext) {
decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
}
modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
// also generate modifiers prop
const modifierPropName = JSON.stringify(
name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`,
)
modelPropsDecl += `\n ${modifierPropName}: {},`
}
return `{${modelPropsDecl}\n }`
}

View File

@ -314,6 +314,37 @@ describe('defineModel', () => {
const inferredRequired = defineModel({ default: 123, required: true })
expectType<Ref<number>>(inferredRequired)
// modifiers
const [_, modifiers] = defineModel<string>()
expectType<true | undefined>(modifiers.foo)
// limit supported modifiers
const [__, typedModifiers] = defineModel<string, 'trim' | 'capitalize'>()
expectType<true | undefined>(typedModifiers.trim)
expectType<true | undefined>(typedModifiers.capitalize)
// @ts-expect-error
typedModifiers.foo
// transformers with type
defineModel<string>({
get(val) {
return val.toLowerCase()
},
set(val) {
return val.toUpperCase()
},
})
// transformers with runtime type
defineModel({
type: String,
get(val) {
return val.toLowerCase()
},
set(val) {
return val.toUpperCase()
},
})
// @ts-expect-error type / default mismatch
defineModel<string>({ default: 123 })
// @ts-expect-error unknown props option

View File

@ -513,6 +513,73 @@ describe('SFC <script setup> helpers', () => {
expect(slotRender).toBeCalledTimes(2)
expect(serializeInner(root)).toBe('<div>bar</div>')
})
test('with modifiers & transformers', async () => {
let childMsg: Ref<string>
let childModifiers: Record<string, true | undefined>
const compRender = vi.fn()
const Comp = defineComponent({
props: ['msg', 'msgModifiers'],
emits: ['update:msg'],
setup(props) {
;[childMsg, childModifiers] = useModel(props, 'msg', {
get(val) {
return val.toLowerCase()
},
set(val) {
if (childModifiers.upper) {
return val.toUpperCase()
}
},
})
return () => {
compRender()
return childMsg.value
}
},
})
const msg = ref('HI')
const Parent = defineComponent({
setup() {
return () =>
h(Comp, {
msg: msg.value,
msgModifiers: { upper: true },
'onUpdate:msg': val => {
msg.value = val
},
})
},
})
const root = nodeOps.createElement('div')
render(h(Parent), root)
// should be lowered
expect(serializeInner(root)).toBe('hi')
// child update
childMsg!.value = 'Hmm'
await nextTick()
expect(childMsg!.value).toBe('hmm')
expect(serializeInner(root)).toBe('hmm')
// parent should get uppercase value
expect(msg.value).toBe('HMM')
// parent update
msg.value = 'Ughh'
await nextTick()
expect(serializeInner(root)).toBe('ughh')
expect(msg.value).toBe('Ughh')
// child update again
childMsg!.value = 'ughh'
await nextTick()
expect(msg.value).toBe('UGHH')
})
})
test('createPropsRestProxy', () => {

View File

@ -1,4 +1,5 @@
import {
EMPTY_OBJ,
type LooseRequired,
type Prettify,
type UnionToIntersection,
@ -218,6 +219,9 @@ export function defineSlots<
return null as any
}
export type ModelRef<T, M extends string | number | symbol = string> = Ref<T> &
[ModelRef<T, M>, Record<M, true | undefined>]
/**
* Vue `<script setup>` compiler macro for declaring a
* two-way binding prop that can be consumed via `v-model` from the parent
@ -251,25 +255,27 @@ export function defineSlots<
* const count = defineModel<number>('count', { default: 0 })
* ```
*/
export function defineModel<T>(
options: { required: true } & PropOptions<T>,
): Ref<T>
export function defineModel<T>(
options: { default: any } & PropOptions<T>,
): Ref<T>
export function defineModel<T>(options?: PropOptions<T>): Ref<T | undefined>
export function defineModel<T>(
export function defineModel<T, M extends string | number | symbol = string>(
options: { required: true } & PropOptions<T> & UseModelOptions<T>,
): ModelRef<T, M>
export function defineModel<T, M extends string | number | symbol = string>(
options: { default: any } & PropOptions<T> & UseModelOptions<T>,
): ModelRef<T, M>
export function defineModel<T, M extends string | number | symbol = string>(
options?: PropOptions<T> & UseModelOptions<T>,
): ModelRef<T | undefined, M>
export function defineModel<T, M extends string | number | symbol = string>(
name: string,
options: { required: true } & PropOptions<T>,
): Ref<T>
export function defineModel<T>(
options: { required: true } & PropOptions<T> & UseModelOptions<T>,
): ModelRef<T, M>
export function defineModel<T, M extends string | number | symbol = string>(
name: string,
options: { default: any } & PropOptions<T>,
): Ref<T>
export function defineModel<T>(
options: { default: any } & PropOptions<T> & UseModelOptions<T>,
): ModelRef<T, M>
export function defineModel<T, M extends string | number | symbol = string>(
name: string,
options?: PropOptions<T>,
): Ref<T | undefined>
options?: PropOptions<T> & UseModelOptions<T>,
): ModelRef<T | undefined, M>
export function defineModel(): any {
if (__DEV__) {
warnRuntimeUsage('defineModel')
@ -348,11 +354,21 @@ export function useAttrs(): SetupContext['attrs'] {
return getContext().attrs
}
export function useModel<T extends Record<string, any>, K extends keyof T>(
props: T,
name: K,
): Ref<T[K]>
export function useModel(props: Record<string, any>, name: string): Ref {
type UseModelOptions<T = any> = {
get?: (v: T) => any
set?: (v: T) => any
}
export function useModel<
M extends string | number | symbol,
T extends Record<string, any>,
K extends keyof T,
>(props: T, name: K, options?: UseModelOptions<T[K]>): ModelRef<T[K], M>
export function useModel(
props: Record<string, any>,
name: string,
options: UseModelOptions = EMPTY_OBJ,
): Ref {
const i = getCurrentInstance()!
if (__DEV__ && !i) {
warn(`useModel() called without active instance.`)
@ -364,7 +380,7 @@ export function useModel(props: Record<string, any>, name: string): Ref {
return ref() as any
}
return customRef((track, trigger) => {
const res = customRef((track, trigger) => {
let localValue: any
watchSyncEffect(() => {
const propValue = props[name]
@ -376,7 +392,7 @@ export function useModel(props: Record<string, any>, name: string): Ref {
return {
get() {
track()
return localValue
return options.get ? options.get(localValue) : localValue
},
set(value) {
const rawProps = i.vnode!.props
@ -384,10 +400,29 @@ export function useModel(props: Record<string, any>, name: string): Ref {
localValue = value
trigger()
}
i.emit(`update:${name}`, value)
i.emit(`update:${name}`, options.set ? options.set(value) : value)
},
}
})
const modifierKey =
name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
// @ts-expect-error
res[Symbol.iterator] = () => {
let i = 0
return {
next() {
if (i < 2) {
return { value: i++ ? props[modifierKey] : res, done: false }
} else {
return { done: true }
}
},
}
}
return res
}
function getContext(): SetupContext {

View File

@ -60,7 +60,7 @@ export { provide, inject, hasInjectionContext } from './apiInject'
export { nextTick } from './scheduler'
export { defineComponent } from './apiDefineComponent'
export { defineAsyncComponent } from './apiAsyncComponent'
export { useAttrs, useSlots, type DefineProps } from './apiSetupHelpers'
export { useAttrs, useSlots } from './apiSetupHelpers'
// <script setup> API ----------------------------------------------------------
@ -74,6 +74,8 @@ export {
defineModel,
withDefaults,
useModel,
type DefineProps,
type ModelRef,
} from './apiSetupHelpers'
/**