feat(sfc): introduce `defineModel` macro and `useModel` helper (#8018)

This commit is contained in:
三咲智子 Kevin Deng 2023-04-08 12:13:05 +08:00 committed by GitHub
parent 3b02c27e6d
commit 14f3d747a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1000 additions and 91 deletions

View File

@ -653,6 +653,100 @@ return { }
}"
`;
exports[`SFC compile <script setup> > defineModel() > basic usage 1`] = `
"import { useModel as _useModel } from 'vue'
export default {
props: {
\\"modelValue\\": { required: true },
\\"count\\": {},
},
emits: [\\"update:modelValue\\", \\"update:count\\"],
setup(__props, { expose: __expose }) {
__expose();
const modelValue = _useModel(__props, \\"modelValue\\")
const c = _useModel(__props, \\"count\\")
return { modelValue, c }
}
}"
`;
exports[`SFC compile <script setup> > defineModel() > w/ array props 1`] = `
"import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
export default {
props: _mergeModels(['foo', 'bar'], {
\\"count\\": {},
}),
emits: [\\"update:count\\"],
setup(__props, { expose: __expose }) {
__expose();
const count = _useModel(__props, \\"count\\")
return { count }
}
}"
`;
exports[`SFC compile <script setup> > defineModel() > w/ defineProps and defineEmits 1`] = `
"import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
export default {
props: _mergeModels({ foo: String }, {
\\"modelValue\\": { default: 0 },
}),
emits: _mergeModels(['change'], [\\"update:modelValue\\"]),
setup(__props, { expose: __expose }) {
__expose();
const count = _useModel(__props, \\"modelValue\\")
return { count }
}
}"
`;
exports[`SFC compile <script setup> > 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[`SFC compile <script setup> > defineOptions() > basic usage 1`] = `
"export default /*#__PURE__*/Object.assign({ name: 'FooApp' }, {
setup(__props, { expose: __expose }) {
@ -1596,6 +1690,58 @@ return { emit }
})"
`;
exports[`SFC compile <script setup> > with TypeScript > defineModel() > basic usage 1`] = `
"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
\\"modelValue\\": { type: [Boolean, String] },
\\"count\\": { type: Number },
\\"disabled\\": { type: Number, ...{ required: false } },
\\"any\\": { type: Boolean, skipCheck: true },
},
emits: [\\"update:modelValue\\", \\"update:count\\", \\"update:disabled\\", \\"update:any\\"],
setup(__props, { expose: __expose }) {
__expose();
const modelValue = _useModel(__props, \\"modelValue\\")
const count = _useModel(__props, \\"count\\")
const disabled = _useModel(__props, \\"disabled\\")
const any = _useModel(__props, \\"any\\")
return { modelValue, count, disabled, any }
}
})"
`;
exports[`SFC compile <script setup> > with TypeScript > defineModel() > w/ production mode 1`] = `
"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
\\"modelValue\\": { type: Boolean },
\\"fn\\": {},
\\"fnWithDefault\\": { type: Function, ...{ default: () => null } },
\\"str\\": {},
\\"optional\\": { required: false },
},
emits: [\\"update:modelValue\\", \\"update:fn\\", \\"update:fnWithDefault\\", \\"update:str\\", \\"update:optional\\"],
setup(__props, { expose: __expose }) {
__expose();
const modelValue = _useModel(__props, \\"modelValue\\")
const fn = _useModel(__props, \\"fn\\")
const fnWithDefault = _useModel(__props, \\"fnWithDefault\\")
const str = _useModel(__props, \\"str\\")
const optional = _useModel(__props, \\"optional\\")
return { modelValue, fn, fnWithDefault, str, optional }
}
})"
`;
exports[`SFC compile <script setup> > with TypeScript > defineProps w/ TS assertion 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
@ -1961,7 +2107,7 @@ export default /*#__PURE__*/_defineComponent({
foo: { type: Function },
bar: { type: Boolean },
baz: { type: [Boolean, Function] },
qux: null
qux: {}
}, { ...defaults }),
setup(__props: any, { expose: __expose }) {
__expose();
@ -2063,7 +2209,7 @@ exports[`SFC compile <script setup> > with TypeScript > withDefaults (static) w/
export default /*#__PURE__*/_defineComponent({
props: {
foo: null,
foo: {},
bar: { type: Boolean },
baz: { type: [Boolean, Function], default: true },
qux: { default: 'hi' }

View File

@ -123,7 +123,7 @@ export default /*#__PURE__*/_defineComponent({
props: {
foo: { default: 1 },
bar: { default: () => ({}) },
baz: null,
baz: {},
boola: { type: Boolean },
boolb: { type: [Boolean, Number] },
func: { type: Function, default: () => {} }

View File

@ -306,6 +306,48 @@ const myEmit = defineEmits(['foo', 'bar'])
'[@vue/compiler-sfc] defineOptions() cannot be used to declare props. Use defineProps() instead.'
)
})
it('should emit an error with declaring props/emits/slots/expose', () => {
expect(() =>
compile(`
<script setup>
defineOptions({ props: ['foo'] })
</script>
`)
).toThrowError(
'[@vue/compiler-sfc] defineOptions() cannot be used to declare props. Use defineProps() instead'
)
expect(() =>
compile(`
<script setup>
defineOptions({ emits: ['update'] })
</script>
`)
).toThrowError(
'[@vue/compiler-sfc] defineOptions() cannot be used to declare emits. Use defineEmits() instead'
)
expect(() =>
compile(`
<script setup>
defineOptions({ expose: ['foo'] })
</script>
`)
).toThrowError(
'[@vue/compiler-sfc] defineOptions() cannot be used to declare expose. Use defineExpose() instead'
)
expect(() =>
compile(`
<script setup lang="ts">
defineOptions({ slots: Object })
</script>
`)
).toThrowError(
'[@vue/compiler-sfc] defineOptions() cannot be used to declare slots. Use defineSlots() instead'
)
})
})
test('defineExpose()', () => {
@ -323,6 +365,109 @@ defineExpose({ foo: 123 })
expect(content).toMatch(/\b__expose\(\{ foo: 123 \}\)/)
})
describe('defineModel()', () => {
test('basic usage', () => {
const { content, bindings } = compile(
`
<script setup>
const modelValue = defineModel({ required: true })
const c = defineModel('count')
</script>
`,
{ defineModel: true }
)
assertCode(content)
expect(content).toMatch('props: {')
expect(content).toMatch('"modelValue": { required: true },')
expect(content).toMatch('"count": {},')
expect(content).toMatch('emits: ["update:modelValue", "update:count"],')
expect(content).toMatch(
`const modelValue = _useModel(__props, "modelValue")`
)
expect(content).toMatch(`const c = _useModel(__props, "count")`)
expect(content).toMatch(`return { modelValue, c }`)
expect(content).not.toMatch('defineModel')
expect(bindings).toStrictEqual({
modelValue: BindingTypes.SETUP_REF,
count: BindingTypes.PROPS,
c: BindingTypes.SETUP_REF
})
})
test('w/ defineProps and defineEmits', () => {
const { content, bindings } = compile(
`
<script setup>
defineProps({ foo: String })
defineEmits(['change'])
const count = defineModel({ default: 0 })
</script>
`,
{ defineModel: true }
)
assertCode(content)
expect(content).toMatch(`props: _mergeModels({ foo: String }`)
expect(content).toMatch(`"modelValue": { default: 0 }`)
expect(content).toMatch(`const count = _useModel(__props, "modelValue")`)
expect(content).not.toMatch('defineModel')
expect(bindings).toStrictEqual({
count: BindingTypes.SETUP_REF,
foo: BindingTypes.PROPS,
modelValue: BindingTypes.PROPS
})
})
test('w/ array props', () => {
const { content, bindings } = compile(
`
<script setup>
defineProps(['foo', 'bar'])
const count = defineModel('count')
</script>
`,
{ defineModel: true }
)
assertCode(content)
expect(content).toMatch(`props: _mergeModels(['foo', 'bar'], {
"count": {},
})`)
expect(content).toMatch(`const count = _useModel(__props, "count")`)
expect(content).not.toMatch('defineModel')
expect(bindings).toStrictEqual({
foo: BindingTypes.PROPS,
bar: BindingTypes.PROPS,
count: BindingTypes.SETUP_REF
})
})
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>`,
{ defineModel: true }
)
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('<script> after <script setup> the script content not end with `\\n`', () => {
const { content } = compile(`
<script setup>
@ -1391,7 +1536,7 @@ const emit = defineEmits(['a', 'b'])
expect(content).toMatch(`const props = __props`)
// foo has no default value, the Function can be dropped
expect(content).toMatch(`foo: null`)
expect(content).toMatch(`foo: {}`)
expect(content).toMatch(`bar: { type: Boolean }`)
expect(content).toMatch(
`baz: { type: [Boolean, Function], default: true }`
@ -1469,7 +1614,7 @@ const emit = defineEmits(['a', 'b'])
foo: { type: Function },
bar: { type: Boolean },
baz: { type: [Boolean, Function] },
qux: null
qux: {}
}, { ...defaults })`.trim()
)
})
@ -1679,6 +1824,86 @@ const emit = defineEmits(['a', 'b'])
})
})
describe('defineModel()', () => {
test('basic usage', () => {
const { content, bindings } = compile(
`
<script setup lang="ts">
const modelValue = defineModel<boolean | string>()
const count = defineModel<number>('count')
const disabled = defineModel<number>('disabled', { required: false })
const any = defineModel<any | boolean>('any')
</script>
`,
{ defineModel: true }
)
assertCode(content)
expect(content).toMatch('"modelValue": { type: [Boolean, String] }')
expect(content).toMatch('"count": { type: Number }')
expect(content).toMatch(
'"disabled": { type: Number, ...{ required: false } }'
)
expect(content).toMatch('"any": { type: Boolean, skipCheck: true }')
expect(content).toMatch(
'emits: ["update:modelValue", "update:count", "update:disabled", "update:any"]'
)
expect(content).toMatch(
`const modelValue = _useModel(__props, "modelValue")`
)
expect(content).toMatch(`const count = _useModel(__props, "count")`)
expect(content).toMatch(
`const disabled = _useModel(__props, "disabled")`
)
expect(content).toMatch(`const any = _useModel(__props, "any")`)
expect(bindings).toStrictEqual({
modelValue: BindingTypes.SETUP_REF,
count: BindingTypes.SETUP_REF,
disabled: BindingTypes.SETUP_REF,
any: BindingTypes.SETUP_REF
})
})
test('w/ production mode', () => {
const { content, bindings } = compile(
`
<script setup lang="ts">
const modelValue = defineModel<boolean>()
const fn = defineModel<() => void>('fn')
const fnWithDefault = defineModel<() => void>('fnWithDefault', { default: () => null })
const str = defineModel<string>('str')
const optional = defineModel<string>('optional', { required: false })
</script>
`,
{ defineModel: true, isProd: true }
)
assertCode(content)
expect(content).toMatch('"modelValue": { type: Boolean }')
expect(content).toMatch('"fn": {}')
expect(content).toMatch(
'"fnWithDefault": { type: Function, ...{ default: () => null } },'
)
expect(content).toMatch('"str": {}')
expect(content).toMatch('"optional": { required: false }')
expect(content).toMatch(
'emits: ["update:modelValue", "update:fn", "update:fnWithDefault", "update:str", "update:optional"]'
)
expect(content).toMatch(
`const modelValue = _useModel(__props, "modelValue")`
)
expect(content).toMatch(`const fn = _useModel(__props, "fn")`)
expect(content).toMatch(`const str = _useModel(__props, "str")`)
expect(bindings).toStrictEqual({
modelValue: BindingTypes.SETUP_REF,
fn: BindingTypes.SETUP_REF,
fnWithDefault: BindingTypes.SETUP_REF,
str: BindingTypes.SETUP_REF,
optional: BindingTypes.SETUP_REF
})
})
})
test('runtime Enum', () => {
const { content, bindings } = compile(
`<script setup lang="ts">

View File

@ -131,17 +131,17 @@ describe('sfc props transform', () => {
`,
{ isProd: true }
)
assertCode(content)
// literals can be used as-is, non-literals are always returned from a
// function
expect(content).toMatch(`props: {
foo: { default: 1 },
bar: { default: () => ({}) },
baz: null,
baz: {},
boola: { type: Boolean },
boolb: { type: [Boolean, Number] },
func: { type: Function, default: () => {} }
}`)
assertCode(content)
})
test('aliasing', () => {

View File

@ -68,6 +68,7 @@ const DEFINE_EXPOSE = 'defineExpose'
const WITH_DEFAULTS = 'withDefaults'
const DEFINE_OPTIONS = 'defineOptions'
const DEFINE_SLOTS = 'defineSlots'
const DEFINE_MODEL = 'defineModel'
const isBuiltInDir = makeMap(
`once,memo,if,for,else,else-if,slot,text,html,on,bind,model,show,cloak,is`
@ -119,13 +120,16 @@ export interface SFCScriptCompileOptions {
* options passed to `compiler-dom`.
*/
templateOptions?: Partial<SFCTemplateCompileOptions>
/**
* Hoist <script setup> static constants.
* - Only enables when one `<script setup>` exists.
* @default true
*/
hoistStatic?: boolean
/**
* (**Experimental**) Enable macro `defineModel`
*/
defineModel?: boolean
}
export interface ImportBinding {
@ -150,6 +154,11 @@ type PropsDeclType = FromNormalScript<TSTypeLiteral | TSInterfaceBody>
type EmitsDeclType = FromNormalScript<
TSFunctionType | TSTypeLiteral | TSInterfaceBody
>
interface ModelDecl {
type: TSType | undefined
options: string | undefined
identifier: string | undefined
}
/**
* Compile `<script setup>`
@ -164,6 +173,7 @@ export function compileScript(
// feature flags
// TODO remove in 3.4
const enableReactivityTransform = !!options.reactivityTransform
const enableDefineModel = !!options.defineModel
const isProd = !!options.isProd
const genSourceMap = options.sourceMap !== false
const hoistStatic = options.hoistStatic !== false && !script
@ -314,6 +324,7 @@ export function compileScript(
let hasDefaultExportRender = false
let hasDefineOptionsCall = false
let hasDefineSlotsCall = false
let hasDefineModelCall = false
let propsRuntimeDecl: Node | undefined
let propsRuntimeDefaults: Node | undefined
let propsDestructureDecl: Node | undefined
@ -325,6 +336,7 @@ export function compileScript(
let emitsTypeDecl: EmitsDeclType | undefined
let emitIdentifier: string | undefined
let optionsRuntimeDecl: Node | undefined
let modelDecls: Record<string, ModelDecl> = {}
let hasAwait = false
let hasInlinedSsrRenderFn = false
// props/emits declared via types
@ -616,6 +628,79 @@ export function compileScript(
return true
}
function processDefineModel(node: Node, declId?: LVal): boolean {
if (!enableDefineModel || !isCallOf(node, DEFINE_MODEL)) {
return false
}
hasDefineModelCall = true
const type =
(node.typeParameters && node.typeParameters.params[0]) || undefined
let modelName: string
let options: Node | undefined
const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
if (arg0 && arg0.type === 'StringLiteral') {
modelName = arg0.value
options = node.arguments[1]
} else {
modelName = 'modelValue'
options = arg0
}
if (modelDecls[modelName]) {
error(`duplicate model name ${JSON.stringify(modelName)}`, node)
}
const optionsString = options
? s.slice(startOffset + options.start!, startOffset + options.end!)
: undefined
modelDecls[modelName] = {
type,
options: optionsString,
identifier:
declId && declId.type === 'Identifier' ? declId.name : undefined
}
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 = `{ ${s.slice(
startOffset + local.start!,
startOffset + local.end!
)} }`
} else {
for (const p of options.properties) {
if (p.type === 'SpreadElement' || p.computed) {
runtimeOptions = optionsString!
break
}
}
}
} else {
runtimeOptions = optionsString!
}
}
s.overwrite(
startOffset + node.start!,
startOffset + node.end!,
`${helper('useModel')}(__props, ${JSON.stringify(modelName)}${
runtimeOptions ? `, ${runtimeOptions}` : ``
})`
)
return true
}
function getAstBody(): Statement[] {
return scriptAst
? [...scriptSetupAst.body, ...scriptAst.body]
@ -883,18 +968,25 @@ export function compileScript(
)
}
function genRuntimeProps(props: Record<string, PropTypeData>) {
const keys = Object.keys(props)
if (!keys.length) {
return ``
}
const hasStaticDefaults = hasStaticWithDefaults()
const scriptSetupSource = scriptSetup!.content
let propsDecls = `{
function concatStrings(strs: Array<string | null | undefined | false>) {
return strs.filter((s): s is string => !!s).join(', ')
}
function genRuntimeProps() {
function genPropsFromTS() {
const keys = Object.keys(typeDeclaredProps)
if (!keys.length) return
const hasStaticDefaults = hasStaticWithDefaults()
const scriptSetupSource = scriptSetup!.content
let propsDecls = `{
${keys
.map(key => {
let defaultString: string | undefined
const destructured = genDestructuredDefaultValue(key, props[key].type)
const destructured = genDestructuredDefaultValue(
key,
typeDeclaredProps[key].type
)
if (destructured) {
defaultString = `default: ${destructured.valueString}${
destructured.needSkipFactory ? `, skipFactory: true` : ``
@ -924,13 +1016,14 @@ export function compileScript(
}
}
const { type, required, skipCheck } = props[key]
const { type, required, skipCheck } = typeDeclaredProps[key]
if (!isProd) {
return `${key}: { type: ${toRuntimeTypeString(
type
)}, required: ${required}${skipCheck ? ', skipCheck: true' : ''}${
defaultString ? `, ${defaultString}` : ``
} }`
return `${key}: { ${concatStrings([
`type: ${toRuntimeTypeString(type)}`,
`required: ${required}`,
skipCheck && 'skipCheck: true',
defaultString
])} }`
} else if (
type.some(
el =>
@ -941,24 +1034,104 @@ export function compileScript(
// #4783 for boolean, should keep the type
// #7111 for function, if default value exists or it's not static, should keep it
// in production
return `${key}: { type: ${toRuntimeTypeString(type)}${
defaultString ? `, ${defaultString}` : ``
} }`
return `${key}: { ${concatStrings([
`type: ${toRuntimeTypeString(type)}`,
defaultString
])} }`
} else {
// production: checks are useless
return `${key}: ${defaultString ? `{ ${defaultString} }` : 'null'}`
return `${key}: ${defaultString ? `{ ${defaultString} }` : `{}`}`
}
})
.join(',\n ')}\n }`
if (propsRuntimeDefaults && !hasStaticDefaults) {
propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
propsRuntimeDefaults.start! + startOffset,
propsRuntimeDefaults.end! + startOffset
)})`
if (propsRuntimeDefaults && !hasStaticDefaults) {
propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
propsRuntimeDefaults.start! + startOffset,
propsRuntimeDefaults.end! + startOffset
)})`
}
return propsDecls
}
return `\n props: ${propsDecls},`
function genModels() {
if (!hasDefineModelCall) return
let modelPropsDecl = ''
for (const [name, { type, options }] of Object.entries(modelDecls)) {
let skipCheck = false
let runtimeTypes = type && inferRuntimeType(type, declaredTypes)
if (runtimeTypes) {
const hasUnknownType = runtimeTypes.includes(UNKNOWN_TYPE)
runtimeTypes = runtimeTypes.filter(el => {
if (el === UNKNOWN_TYPE) return false
return isProd
? el === 'Boolean' || (el === 'Function' && options)
: true
})
skipCheck = !isProd && hasUnknownType && runtimeTypes.length > 0
}
let runtimeType =
(runtimeTypes &&
runtimeTypes.length > 0 &&
toRuntimeTypeString(runtimeTypes)) ||
undefined
const codegenOptions = concatStrings([
runtimeType && `type: ${runtimeType}`,
skipCheck && 'skipCheck: true'
])
let decl: string
if (runtimeType && options) {
decl = isTS
? `{ ${codegenOptions}, ...${options} }`
: `Object.assign({ ${codegenOptions} }, ${options})`
} else {
decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
}
modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
}
return `{${modelPropsDecl}\n }`
}
let propsDecls: undefined | string
if (propsRuntimeDecl) {
propsDecls = scriptSetup!.content
.slice(propsRuntimeDecl.start!, propsRuntimeDecl.end!)
.trim()
if (propsDestructureDecl) {
const defaults: string[] = []
for (const key in propsDestructuredBindings) {
const d = genDestructuredDefaultValue(key)
if (d)
defaults.push(
`${key}: ${d.valueString}${
d.needSkipFactory ? `, __skip_${key}: true` : ``
}`
)
}
if (defaults.length) {
propsDecls = `${helper(
`mergeDefaults`
)}(${propsDecls}, {\n ${defaults.join(',\n ')}\n})`
}
}
} else if (propsTypeDecl) {
propsDecls = genPropsFromTS()
}
const modelsDecls = genModels()
if (propsDecls && modelsDecls) {
return `${helper('mergeModels')}(${propsDecls}, ${modelsDecls})`
} else {
return modelsDecls || propsDecls
}
}
function genDestructuredDefaultValue(
@ -1058,6 +1231,34 @@ export function compileScript(
}
}
function genRuntimeEmits() {
function genEmitsFromTS() {
return typeDeclaredEmits.size
? `[${Array.from(typeDeclaredEmits)
.map(k => JSON.stringify(k))
.join(', ')}]`
: ``
}
let emitsDecl = ''
if (emitsRuntimeDecl) {
emitsDecl = scriptSetup!.content
.slice(emitsRuntimeDecl.start!, emitsRuntimeDecl.end!)
.trim()
} else if (emitsTypeDecl) {
emitsDecl = genEmitsFromTS()
}
if (hasDefineModelCall) {
let modelEmitsDecl = `[${Object.keys(modelDecls)
.map(n => JSON.stringify(`update:${n}`))
.join(', ')}]`
emitsDecl = emitsDecl
? `${helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
: modelEmitsDecl
}
return emitsDecl
}
// 0. parse both <script> and <script setup> blocks
const scriptAst =
script &&
@ -1345,6 +1546,8 @@ export function compileScript(
callee.end! + startOffset,
'__expose'
)
} else {
processDefineModel(expr)
}
}
@ -1370,7 +1573,9 @@ export function compileScript(
processWithDefaults(init, decl.id)
const isDefineEmits =
!isDefineProps && processDefineEmits(init, decl.id)
!isDefineEmits && processDefineSlots(init, decl.id)
!isDefineEmits &&
(processDefineSlots(init, decl.id) ||
processDefineModel(init, decl.id))
if (isDefineProps || isDefineEmits) {
if (left === 1) {
@ -1569,6 +1774,9 @@ export function compileScript(
for (const key in typeDeclaredProps) {
bindingMetadata[key] = BindingTypes.PROPS
}
for (const key in modelDecls) {
bindingMetadata[key] = BindingTypes.PROPS
}
// props aliases
if (propsDestructureDecl) {
if (propsDestructureRestId) {
@ -1787,38 +1995,12 @@ export function compileScript(
if (hasInlinedSsrRenderFn) {
runtimeOptions += `\n __ssrInlineRender: true,`
}
if (propsRuntimeDecl) {
let declCode = scriptSetup.content
.slice(propsRuntimeDecl.start!, propsRuntimeDecl.end!)
.trim()
if (propsDestructureDecl) {
const defaults: string[] = []
for (const key in propsDestructuredBindings) {
const d = genDestructuredDefaultValue(key)
if (d)
defaults.push(
`${key}: ${d.valueString}${
d.needSkipFactory ? `, __skip_${key}: true` : ``
}`
)
}
if (defaults.length) {
declCode = `${helper(
`mergeDefaults`
)}(${declCode}, {\n ${defaults.join(',\n ')}\n})`
}
}
runtimeOptions += `\n props: ${declCode},`
} else if (propsTypeDecl) {
runtimeOptions += genRuntimeProps(typeDeclaredProps)
}
if (emitsRuntimeDecl) {
runtimeOptions += `\n emits: ${scriptSetup.content
.slice(emitsRuntimeDecl.start!, emitsRuntimeDecl.end!)
.trim()},`
} else if (emitsTypeDecl) {
runtimeOptions += genRuntimeEmits(typeDeclaredEmits)
}
const propsDecl = genRuntimeProps()
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`
const emitsDecl = genRuntimeEmits()
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`
let definedOptions = ''
if (optionsRuntimeDecl) {
@ -1958,7 +2140,10 @@ function walkDeclaration(
? BindingTypes.SETUP_REACTIVE_CONST
: BindingTypes.SETUP_CONST
} else if (isConst) {
if (isCallOf(init, userImportAliases['ref'])) {
if (
isCallOf(init, userImportAliases['ref']) ||
isCallOf(init, DEFINE_MODEL)
) {
bindingType = BindingTypes.SETUP_REF
} else {
bindingType = BindingTypes.SETUP_MAYBE_REF
@ -2393,14 +2578,6 @@ function extractEventNames(
}
}
function genRuntimeEmits(emits: Set<string>) {
return emits.size
? `\n emits: [${Array.from(emits)
.map(p => JSON.stringify(p))
.join(', ')}],`
: ``
}
function canNeverBeRef(node: Node, userReactiveImport?: string): boolean {
if (isCallOf(node, userReactiveImport)) {
return true

View File

@ -6,9 +6,13 @@ import {
withDefaults,
Slots,
defineSlots,
VNode
VNode,
Ref,
defineModel
} from 'vue'
import { describe, expectType } from './utils'
import { defineComponent } from 'vue'
import { useModel } from 'vue'
describe('defineProps w/ type declaration', () => {
// type declaration
@ -202,6 +206,72 @@ describe('defineSlots', () => {
expectType<Slots>(slotsUntype)
})
describe('defineModel', () => {
// overload 1
const modelValueRequired = defineModel<boolean>({ required: true })
expectType<Ref<boolean>>(modelValueRequired)
// overload 2
const modelValue = defineModel<string>()
expectType<Ref<string | undefined>>(modelValue)
modelValue.value = 'new value'
const modelValueDefault = defineModel<boolean>({ default: true })
expectType<Ref<boolean>>(modelValueDefault)
// overload 3
const countRequired = defineModel<number>('count', { required: false })
expectType<Ref<number | undefined>>(countRequired)
// overload 4
const count = defineModel<number>('count')
expectType<Ref<number | undefined>>(count)
const countDefault = defineModel<number>('count', { default: 1 })
expectType<Ref<number>>(countDefault)
// infer type from default
const inferred = defineModel({ default: 123 })
expectType<Ref<number | undefined>>(inferred)
const inferredRequired = defineModel({ default: 123, required: true })
expectType<Ref<number>>(inferredRequired)
// @ts-expect-error type / default mismatch
defineModel<string>({ default: 123 })
// @ts-expect-error unknown props option
defineModel({ foo: 123 })
// accept defineModel-only options
defineModel({ local: true })
defineModel('foo', { local: true })
})
describe('useModel', () => {
defineComponent({
props: ['foo'],
setup(props) {
const r = useModel(props, 'foo')
expectType<Ref<any>>(r)
// @ts-expect-error
useModel(props, 'bar')
}
})
defineComponent({
props: {
foo: String,
bar: { type: Number, required: true },
baz: { type: Boolean }
},
setup(props) {
expectType<Ref<string | undefined>>(useModel(props, 'foo'))
expectType<Ref<number>>(useModel(props, 'bar'))
expectType<Ref<boolean>>(useModel(props, 'baz'))
}
})
})
describe('useAttrs', () => {
const attrs = useAttrs()
expectType<Record<string, unknown>>(attrs)

View File

@ -13,7 +13,9 @@ import {
Suspense,
computed,
ComputedRef,
shallowReactive
shallowReactive,
nextTick,
ref
} from '@vue/runtime-test'
import {
defineEmits,
@ -24,7 +26,9 @@ import {
useSlots,
mergeDefaults,
withAsyncContext,
createPropsRestProxy
createPropsRestProxy,
mergeModels,
useModel
} from '../src/apiSetupHelpers'
describe('SFC <script setup> helpers', () => {
@ -133,6 +137,149 @@ describe('SFC <script setup> helpers', () => {
})
})
describe('mergeModels', () => {
test('array syntax', () => {
expect(mergeModels(['foo', 'bar'], ['baz'])).toMatchObject([
'foo',
'bar',
'baz'
])
})
test('object syntax', () => {
expect(
mergeModels({ foo: null, bar: { required: true } }, ['baz'])
).toMatchObject({
foo: null,
bar: { required: true },
baz: {}
})
expect(
mergeModels(['baz'], { foo: null, bar: { required: true } })
).toMatchObject({
foo: null,
bar: { required: true },
baz: {}
})
})
test('overwrite', () => {
expect(
mergeModels(
{ foo: null, bar: { required: true } },
{ bar: {}, baz: {} }
)
).toMatchObject({
foo: null,
bar: {},
baz: {}
})
})
})
describe('useModel', () => {
test('basic', async () => {
let foo: any
const update = () => {
foo.value = 'bar'
}
const Comp = defineComponent({
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props) {
foo = useModel(props, 'modelValue')
},
render() {}
})
const msg = ref('')
const setValue = vi.fn(v => (msg.value = v))
const root = nodeOps.createElement('div')
createApp(() =>
h(Comp, {
modelValue: msg.value,
'onUpdate:modelValue': setValue
})
).mount(root)
expect(foo.value).toBe('')
expect(msg.value).toBe('')
expect(setValue).not.toBeCalled()
// update from child
update()
await nextTick()
expect(msg.value).toBe('bar')
expect(foo.value).toBe('bar')
expect(setValue).toBeCalledTimes(1)
// update from parent
msg.value = 'qux'
await nextTick()
expect(msg.value).toBe('qux')
expect(foo.value).toBe('qux')
expect(setValue).toBeCalledTimes(1)
})
test('local', async () => {
let foo: any
const update = () => {
foo.value = 'bar'
}
const Comp = defineComponent({
props: ['foo'],
emits: ['update:foo'],
setup(props) {
foo = useModel(props, 'foo', { local: true })
},
render() {}
})
const root = nodeOps.createElement('div')
const updateFoo = vi.fn()
render(h(Comp, { 'onUpdate:foo': updateFoo }), root)
expect(foo.value).toBeUndefined()
update()
expect(foo.value).toBe('bar')
await nextTick()
expect(updateFoo).toBeCalledTimes(1)
})
test('default value', async () => {
let count: any
const inc = () => {
count.value++
}
const Comp = defineComponent({
props: { count: { default: 0 } },
emits: ['update:count'],
setup(props) {
count = useModel(props, 'count', { local: true })
},
render() {}
})
const root = nodeOps.createElement('div')
const updateCount = vi.fn()
render(h(Comp, { 'onUpdate:count': updateCount }), root)
expect(count.value).toBe(0)
inc()
expect(count.value).toBe(1)
await nextTick()
expect(updateCount).toBeCalledTimes(1)
})
})
test('createPropsRestProxy', () => {
const original = shallowReactive({
foo: 1,

View File

@ -3,7 +3,8 @@ import {
isPromise,
isFunction,
Prettify,
UnionToIntersection
UnionToIntersection,
extend
} from '@vue/shared'
import {
getCurrentInstance,
@ -12,7 +13,7 @@ import {
createSetupContext,
unsetCurrentInstance
} from './component'
import { EmitFn, EmitsOptions } from './componentEmits'
import { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits'
import {
ComponentOptionsMixin,
ComponentOptionsWithoutProps,
@ -22,10 +23,14 @@ import {
import {
ComponentPropsOptions,
ComponentObjectPropsOptions,
ExtractPropTypes
ExtractPropTypes,
NormalizedProps,
PropOptions
} from './componentProps'
import { warn } from './warning'
import { SlotsType, TypedSlots } from './componentSlots'
import { Ref, ref } from '@vue/reactivity'
import { watch } from './apiWatch'
// dev only
const warnRuntimeUsage = (method: string) =>
@ -200,11 +205,77 @@ export function defineOptions<
export function defineSlots<
S extends Record<string, any> = Record<string, any>
>(): // @ts-expect-error
TypedSlots<SlotsType<S>> {
>(): TypedSlots<SlotsType<S>> {
if (__DEV__) {
warnRuntimeUsage(`defineSlots`)
}
return null as any
}
/**
* (**Experimental**) Vue `<script setup>` compiler macro for declaring a
* two-way binding prop that can be consumed via `v-model` from the parent
* component. This will declare a prop with the same name and a corresponding
* `update:propName` event.
*
* If the first argument is a string, it will be used as the prop name;
* 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.
*
* The options object can also specify an additional option, `local`. When set
* to `true`, the ref can be locally mutated even if the parent did not pass
* the matching `v-model`.
*
* @example
* ```ts
* // default model (consumed via `v-model`)
* const modelValue = defineModel<string>()
* modelValue.value = "hello"
*
* // default model with options
* const modelValue = defineModel<stirng>({ required: true })
*
* // with specified name (consumed via `v-model:count`)
* const count = defineModel<number>('count')
* count.value++
*
* // with specified name and default value
* 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>(
options: { required: true } & PropOptions<T> & DefineModelOptions
): Ref<T>
export function defineModel<T>(
options: { default: any } & PropOptions<T> & DefineModelOptions
): Ref<T>
export function defineModel<T>(
options?: PropOptions<T> & DefineModelOptions
): Ref<T | undefined>
export function defineModel<T>(
name: string,
options: { required: true } & PropOptions<T> & DefineModelOptions
): Ref<T>
export function defineModel<T>(
name: string,
options: { default: any } & PropOptions<T> & DefineModelOptions
): Ref<T>
export function defineModel<T>(
name: string,
options?: PropOptions<T> & DefineModelOptions
): Ref<T | undefined>
export function defineModel(): any {
if (__DEV__) {
warnRuntimeUsage('defineModel')
}
}
interface DefineModelOptions {
local?: boolean
}
type NotUndefined<T> = T extends undefined ? never : T
@ -268,6 +339,55 @@ export function useAttrs(): SetupContext['attrs'] {
return getContext().attrs
}
export function useModel<T extends Record<string, any>, K extends keyof T>(
props: T,
name: K,
options?: { local?: boolean }
): Ref<T[K]>
export function useModel(
props: Record<string, any>,
name: string,
options?: { local?: boolean }
): Ref {
const i = getCurrentInstance()!
if (__DEV__ && !i) {
warn(`useModel() called without active instance.`)
return ref() as any
}
if (__DEV__ && !(i.propsOptions[0] as NormalizedProps)[name]) {
warn(`useModel() called with prop "${name}" which is not declared.`)
return ref() as any
}
if (options && options.local) {
const proxy = ref<any>(props[name])
watch(
() => props[name],
v => (proxy.value = v)
)
watch(proxy, value => {
if (value !== props[name]) {
i.emit(`update:${name}`, value)
}
})
return proxy
} else {
return {
__v_isRef: true,
get value() {
return props[name]
},
set value(value) {
i.emit(`update:${name}`, value)
}
} as any
}
}
function getContext(): SetupContext {
const i = getCurrentInstance()!
if (__DEV__ && !i) {
@ -276,6 +396,15 @@ function getContext(): SetupContext {
return i.setupContext || (i.setupContext = createSetupContext(i))
}
function normalizePropsOrEmits(props: ComponentPropsOptions | EmitsOptions) {
return isArray(props)
? props.reduce(
(normalized, p) => ((normalized[p] = {}), normalized),
{} as ComponentObjectPropsOptions | ObjectEmitsOptions
)
: props
}
/**
* Runtime helper for merging default declarations. Imported by compiled code
* only.
@ -285,12 +414,7 @@ export function mergeDefaults(
raw: ComponentPropsOptions,
defaults: Record<string, any>
): ComponentObjectPropsOptions {
const props = isArray(raw)
? raw.reduce(
(normalized, p) => ((normalized[p] = {}), normalized),
{} as ComponentObjectPropsOptions
)
: raw
const props = normalizePropsOrEmits(raw)
for (const key in defaults) {
if (key.startsWith('__skip')) continue
let opt = props[key]
@ -312,6 +436,20 @@ export function mergeDefaults(
return props
}
/**
* Runtime helper for merging model declarations.
* Imported by compiled code only.
* @internal
*/
export function mergeModels(
a: ComponentPropsOptions | EmitsOptions,
b: ComponentPropsOptions | EmitsOptions
) {
if (!a || !b) return a || b
if (isArray(a) && isArray(b)) return a.concat(b)
return extend({}, normalizePropsOrEmits(a), normalizePropsOrEmits(b))
}
/**
* Used to create a proxy for the rest element when destructuring props with
* defineProps().

View File

@ -627,7 +627,7 @@ function validateProp(
return
}
// missing but optional
if (value == null && !prop.required) {
if (value == null && !required) {
return
}
// type check

View File

@ -71,9 +71,12 @@ export {
defineExpose,
defineOptions,
defineSlots,
defineModel,
withDefaults,
useModel,
// internal
mergeDefaults,
mergeModels,
createPropsRestProxy,
withAsyncContext
} from './apiSetupHelpers'

View File

@ -5,6 +5,7 @@ type _defineEmits = typeof defineEmits
type _defineExpose = typeof defineExpose
type _defineOptions = typeof defineOptions
type _defineSlots = typeof defineSlots
type _defineModel = typeof defineModel
type _withDefaults = typeof withDefaults
declare global {
@ -13,5 +14,6 @@ declare global {
const defineExpose: _defineExpose
const defineOptions: _defineOptions
const defineSlots: _defineSlots
const defineModel: _defineModel
const withDefaults: _withDefaults
}

View File

@ -37,7 +37,8 @@ const sfcOptions = {
script: {
inlineTemplate: !useDevMode.value,
isProd: !useDevMode.value,
reactivityTransform: true
reactivityTransform: true,
defineModel: true
},
style: {
isProd: !useDevMode.value