From a772031ea8431bd732ffeaeaac09bd76a0daec9b Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 26 Dec 2023 22:13:04 +0800 Subject: [PATCH] feat(defineModel): support modifiers and transformers --- .../__snapshots__/defineModel.spec.ts.snap | 91 ++++++++++++------- .../compileScript/defineModel.spec.ts | 64 ++++++++----- .../compiler-sfc/src/script/defineModel.ts | 70 ++++++++------ packages/dts-test/setupHelpers.test-d.ts | 31 +++++++ .../__tests__/apiSetupHelpers.spec.ts | 67 ++++++++++++++ packages/runtime-core/src/apiSetupHelpers.ts | 83 ++++++++++++----- packages/runtime-core/src/index.ts | 4 +- 7 files changed, 303 insertions(+), 107 deletions(-) diff --git a/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap index 323fb8688d5..1163a4c02ba 100644 --- a/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap +++ b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap @@ -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 }) { diff --git a/packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts b/packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts index eea19fc9cbd..6feff4500f1 100644 --- a/packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts @@ -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( - ``, - ) - 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( + ` + + `, + ) + 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( + ` + + `, + ) + 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 }, })`, + ) + }) }) diff --git a/packages/compiler-sfc/src/script/defineModel.ts b/packages/compiler-sfc/src/script/defineModel.ts index 183529d3585..9411fa460b9 100644 --- a/packages/compiler-sfc/src/script/defineModel.ts +++ b/packages/compiler-sfc/src/script/defineModel.ts @@ -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,42 +45,52 @@ export function processDefineModel( ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node) } - const optionsString = options && ctx.getString(options) - - ctx.modelDecls[modelName] = { - type, - options: optionsString, - identifier: - declId && declId.type === 'Identifier' ? declId.name : undefined, - } - // register binding type - ctx.bindingMetadata[modelName] = BindingTypes.PROPS - + let optionsString = options && ctx.getString(options) let runtimeOptions = '' + let transformOptions = '' + 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 - } + 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, + options: optionsString, + identifier: + declId && declId.type === 'Identifier' ? declId.name : undefined, + } + // register binding type + ctx.bindingMetadata[modelName] = BindingTypes.PROPS + 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 }` } diff --git a/packages/dts-test/setupHelpers.test-d.ts b/packages/dts-test/setupHelpers.test-d.ts index 7f67c62943d..9588cb9b209 100644 --- a/packages/dts-test/setupHelpers.test-d.ts +++ b/packages/dts-test/setupHelpers.test-d.ts @@ -314,6 +314,37 @@ describe('defineModel', () => { const inferredRequired = defineModel({ default: 123, required: true }) expectType>(inferredRequired) + // modifiers + const [_, modifiers] = defineModel() + expectType(modifiers.foo) + + // limit supported modifiers + const [__, typedModifiers] = defineModel() + expectType(typedModifiers.trim) + expectType(typedModifiers.capitalize) + // @ts-expect-error + typedModifiers.foo + + // transformers with type + defineModel({ + 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({ default: 123 }) // @ts-expect-error unknown props option diff --git a/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts b/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts index ef4fcd09e27..0528a14577a 100644 --- a/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts +++ b/packages/runtime-core/__tests__/apiSetupHelpers.spec.ts @@ -513,6 +513,73 @@ describe('SFC