From 1c7d737cc8ed0384b334d0b3e2dc8ede44906dc4 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 13 Jul 2021 15:58:18 -0400 Subject: [PATCH] feat: support v-bind .prop & .attr modifiers Also allows render function usage like the following: ```js h({ '.prop': 1, // force set as property '^attr': 'foo' // force set as attribute }) ``` --- .../compiler-core/__tests__/parse.spec.ts | 48 ++++++ .../__tests__/transforms/vBind.spec.ts | 140 ++++++++++++++++-- packages/compiler-core/src/ast.ts | 2 + packages/compiler-core/src/parse.ts | 12 +- .../src/transforms/transformElement.ts | 19 ++- .../compiler-core/src/transforms/vBind.ts | 29 +++- packages/compiler-core/src/transforms/vOn.ts | 2 + .../runtime-dom/__tests__/patchProps.spec.ts | 14 ++ packages/runtime-dom/src/patchProp.ts | 73 +++++---- 9 files changed, 279 insertions(+), 60 deletions(-) diff --git a/packages/compiler-core/__tests__/parse.spec.ts b/packages/compiler-core/__tests__/parse.spec.ts index 1c7c87853..d677f75dd 100644 --- a/packages/compiler-core/__tests__/parse.spec.ts +++ b/packages/compiler-core/__tests__/parse.spec.ts @@ -1276,6 +1276,54 @@ describe('compiler: parse', () => { }) }) + test('v-bind .prop shorthand', () => { + const ast = baseParse('
') + const directive = (ast.children[0] as ElementNode).props[0] + + expect(directive).toStrictEqual({ + type: NodeTypes.DIRECTIVE, + name: 'bind', + arg: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'a', + isStatic: true, + constType: ConstantTypes.CAN_STRINGIFY, + + loc: { + source: 'a', + start: { + column: 7, + line: 1, + offset: 6 + }, + end: { + column: 8, + line: 1, + offset: 7 + } + } + }, + modifiers: ['prop'], + exp: { + type: NodeTypes.SIMPLE_EXPRESSION, + content: 'b', + isStatic: false, + constType: ConstantTypes.NOT_CONSTANT, + + loc: { + start: { offset: 8, line: 1, column: 9 }, + end: { offset: 9, line: 1, column: 10 }, + source: 'b' + } + }, + loc: { + start: { offset: 5, line: 1, column: 6 }, + end: { offset: 9, line: 1, column: 10 }, + source: '.a=b' + } + }) + }) + test('v-bind shorthand with modifier', () => { const ast = baseParse('
') const directive = (ast.children[0] as ElementNode).props[0] diff --git a/packages/compiler-core/__tests__/transforms/vBind.spec.ts b/packages/compiler-core/__tests__/transforms/vBind.spec.ts index 287be477b..27e0ae10c 100644 --- a/packages/compiler-core/__tests__/transforms/vBind.spec.ts +++ b/packages/compiler-core/__tests__/transforms/vBind.spec.ts @@ -172,22 +172,140 @@ describe('compiler: transform v-bind', () => { const node = parseWithVBind(`
`, { prefixIdentifiers: true }) + const props = (node.codegenNode as VNodeCall).props as CallExpression + expect(props).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: NORMALIZE_PROPS, + arguments: [ + { + type: NodeTypes.JS_OBJECT_EXPRESSION, + properties: [ + { + key: { + children: [ + `_${helperNameMap[CAMELIZE]}(`, + `(`, + { content: `_ctx.foo` }, + `(`, + { content: `_ctx.bar` }, + `)`, + `) || ""`, + `)` + ] + }, + value: { + content: `_ctx.id`, + isStatic: false + } + } + ] + } + ] + }) + }) + + test('.prop modifier', () => { + const node = parseWithVBind(`
`) const props = (node.codegenNode as VNodeCall).props as ObjectExpression expect(props.properties[0]).toMatchObject({ key: { - children: [ - `_${helperNameMap[CAMELIZE]}(`, - `(`, - { content: `_ctx.foo` }, - `(`, - { content: `_ctx.bar` }, - `)`, - `) || ""`, - `)` - ] + content: `.fooBar`, + isStatic: true }, value: { - content: `_ctx.id`, + content: `id`, + isStatic: false + } + }) + }) + + test('.prop modifier w/ dynamic arg', () => { + const node = parseWithVBind(`
`) + const props = (node.codegenNode as VNodeCall).props as CallExpression + expect(props).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: NORMALIZE_PROPS, + arguments: [ + { + type: NodeTypes.JS_OBJECT_EXPRESSION, + properties: [ + { + key: { + content: '`.${fooBar || ""}`', + isStatic: false + }, + value: { + content: `id`, + isStatic: false + } + } + ] + } + ] + }) + }) + + test('.prop modifier w/ dynamic arg + prefixIdentifiers', () => { + const node = parseWithVBind(`
`, { + prefixIdentifiers: true + }) + const props = (node.codegenNode as VNodeCall).props as CallExpression + expect(props).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: NORMALIZE_PROPS, + arguments: [ + { + type: NodeTypes.JS_OBJECT_EXPRESSION, + properties: [ + { + key: { + children: [ + `'.' + (`, + `(`, + { content: `_ctx.foo` }, + `(`, + { content: `_ctx.bar` }, + `)`, + `) || ""`, + `)` + ] + }, + value: { + content: `_ctx.id`, + isStatic: false + } + } + ] + } + ] + }) + }) + + test('.prop modifier (shorthand)', () => { + const node = parseWithVBind(`
`) + const props = (node.codegenNode as VNodeCall).props as ObjectExpression + expect(props.properties[0]).toMatchObject({ + key: { + content: `.fooBar`, + isStatic: true + }, + value: { + content: `id`, + isStatic: false + } + }) + }) + + test('.attr modifier', () => { + const node = parseWithVBind(`
`) + const props = (node.codegenNode as VNodeCall).props as ObjectExpression + expect(props.properties[0]).toMatchObject({ + key: { + content: `^foo-bar`, + isStatic: true + }, + value: { + content: `id`, isStatic: false } }) diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts index b087a9845..7bd714694 100644 --- a/packages/compiler-core/src/ast.ts +++ b/packages/compiler-core/src/ast.ts @@ -221,6 +221,7 @@ export interface SimpleExpressionNode extends Node { * the identifiers declared inside the function body. */ identifiers?: string[] + isHandlerKey?: boolean } export interface InterpolationNode extends Node { @@ -243,6 +244,7 @@ export interface CompoundExpressionNode extends Node { * the identifiers declared inside the function body. */ identifiers?: string[] + isHandlerKey?: boolean } export interface IfNode extends Node { diff --git a/packages/compiler-core/src/parse.ts b/packages/compiler-core/src/parse.ts index 9e6ee3260..6c1863f21 100644 --- a/packages/compiler-core/src/parse.ts +++ b/packages/compiler-core/src/parse.ts @@ -772,14 +772,19 @@ function parseAttribute( } const loc = getSelection(context, start) - if (!context.inVPre && /^(v-|:|@|#)/.test(name)) { - const match = /(?:^v-([a-z0-9-]+))?(?:(?::|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec( + if (!context.inVPre && /^(v-|:|\.|@|#)/.test(name)) { + const match = /(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec( name )! + let isPropShorthand = startsWith(name, '.') let dirName = match[1] || - (startsWith(name, ':') ? 'bind' : startsWith(name, '@') ? 'on' : 'slot') + (isPropShorthand || startsWith(name, ':') + ? 'bind' + : startsWith(name, '@') + ? 'on' + : 'slot') let arg: ExpressionNode | undefined if (match[2]) { @@ -835,6 +840,7 @@ function parseAttribute( } const modifiers = match[3] ? match[3].substr(1).split('.') : [] + if (isPropShorthand) modifiers.push('prop') // 2.x compat v-bind:foo.sync -> v-model:foo if (__COMPAT__ && dirName === 'bind' && arg) { diff --git a/packages/compiler-core/src/transforms/transformElement.ts b/packages/compiler-core/src/transforms/transformElement.ts index 697e8e294..469670fc1 100644 --- a/packages/compiler-core/src/transforms/transformElement.ts +++ b/packages/compiler-core/src/transforms/transformElement.ts @@ -700,21 +700,26 @@ export function buildProps( // but still need to deal with dynamic key binding let classKeyIndex = -1 let styleKeyIndex = -1 - let dynamicKeyIndex = -1 + let hasDynamicKey = false for (let i = 0; i < propsExpression.properties.length; i++) { - const p = propsExpression.properties[i] - if (p.key.type !== NodeTypes.SIMPLE_EXPRESSION) continue - if (!isStaticExp(p.key)) dynamicKeyIndex = i - if (isStaticExp(p.key) && p.key.content === 'class') classKeyIndex = i - if (isStaticExp(p.key) && p.key.content === 'style') styleKeyIndex = i + const key = propsExpression.properties[i].key + if (isStaticExp(key)) { + if (key.content === 'class') { + classKeyIndex = i + } else if (key.content === 'style') { + styleKeyIndex = i + } + } else if (!key.isHandlerKey) { + hasDynamicKey = true + } } const classProp = propsExpression.properties[classKeyIndex] const styleProp = propsExpression.properties[styleKeyIndex] // no dynamic key - if (dynamicKeyIndex === -1) { + if (!hasDynamicKey) { if (classProp && !isStaticExp(classProp.value)) { classProp.value = createCallExpression( context.helper(NORMALIZE_CLASS), diff --git a/packages/compiler-core/src/transforms/vBind.ts b/packages/compiler-core/src/transforms/vBind.ts index c1faed1a0..e3ffed425 100644 --- a/packages/compiler-core/src/transforms/vBind.ts +++ b/packages/compiler-core/src/transforms/vBind.ts @@ -1,5 +1,10 @@ import { DirectiveTransform } from '../transform' -import { createObjectProperty, createSimpleExpression, NodeTypes } from '../ast' +import { + createObjectProperty, + createSimpleExpression, + ExpressionNode, + NodeTypes +} from '../ast' import { createCompilerError, ErrorCodes } from '../errors' import { camelize } from '@vue/shared' import { CAMELIZE } from '../runtimeHelpers' @@ -18,7 +23,6 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => { arg.content = `${arg.content} || ""` } - // .prop is no longer necessary due to new patch behavior // .sync is replaced by v-model:arg if (modifiers.includes('camel')) { if (arg.type === NodeTypes.SIMPLE_EXPRESSION) { @@ -33,6 +37,14 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => { } } + if (modifiers.includes('prop')) { + injectPrefix(arg, '.') + } + + if (modifiers.includes('attr')) { + injectPrefix(arg, '^') + } + if ( !exp || (exp.type === NodeTypes.SIMPLE_EXPRESSION && !exp.content.trim()) @@ -47,3 +59,16 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => { props: [createObjectProperty(arg!, exp)] } } + +const injectPrefix = (arg: ExpressionNode, prefix: string) => { + if (arg.type === NodeTypes.SIMPLE_EXPRESSION) { + if (arg.isStatic) { + arg.content = prefix + arg.content + } else { + arg.content = `\`${prefix}\${${arg.content}}\`` + } + } else { + arg.children.unshift(`'${prefix}' + (`) + arg.children.push(`)`) + } +} diff --git a/packages/compiler-core/src/transforms/vOn.ts b/packages/compiler-core/src/transforms/vOn.ts index bf51a5f62..68fd77b49 100644 --- a/packages/compiler-core/src/transforms/vOn.ts +++ b/packages/compiler-core/src/transforms/vOn.ts @@ -163,5 +163,7 @@ export const transformOn: DirectiveTransform = ( ret.props[0].value = context.cache(ret.props[0].value) } + // mark the key as handler for props normalization check + ret.props.forEach(p => (p.key.isHandlerKey = true)) return ret } diff --git a/packages/runtime-dom/__tests__/patchProps.spec.ts b/packages/runtime-dom/__tests__/patchProps.spec.ts index 46cd8dc1e..abcfb2c71 100644 --- a/packages/runtime-dom/__tests__/patchProps.spec.ts +++ b/packages/runtime-dom/__tests__/patchProps.spec.ts @@ -171,6 +171,20 @@ describe('runtime-dom: props patching', () => { patchProp(el, 'type', 'text', null) }) + test('force patch as prop', () => { + const el = document.createElement('div') as any + patchProp(el, '.x', null, 1) + expect(el.x).toBe(1) + }) + + test('force patch as attribute', () => { + const el = document.createElement('div') as any + el.x = 1 + patchProp(el, '^x', null, 2) + expect(el.x).toBe(1) + expect(el.getAttribute('x')).toBe('2') + }) + test('input with size', () => { const el = document.createElement('input') patchProp(el, 'size', null, 100) diff --git a/packages/runtime-dom/src/patchProp.ts b/packages/runtime-dom/src/patchProp.ts index 2754f7426..125b64d0e 100644 --- a/packages/runtime-dom/src/patchProp.ts +++ b/packages/runtime-dom/src/patchProp.ts @@ -24,43 +24,42 @@ export const patchProp: DOMRendererOptions['patchProp'] = ( parentSuspense, unmountChildren ) => { - switch (key) { - // special - case 'class': - patchClass(el, nextValue, isSVG) - break - case 'style': - patchStyle(el, prevValue, nextValue) - break - default: - if (isOn(key)) { - // ignore v-model listeners - if (!isModelListener(key)) { - patchEvent(el, key, prevValue, nextValue, parentComponent) - } - } else if (shouldSetAsProp(el, key, nextValue, isSVG)) { - patchDOMProp( - el, - key, - nextValue, - prevChildren, - parentComponent, - parentSuspense, - unmountChildren - ) - } else { - // special case for with - // :true-value & :false-value - // store value as dom properties since non-string values will be - // stringified. - if (key === 'true-value') { - ;(el as any)._trueValue = nextValue - } else if (key === 'false-value') { - ;(el as any)._falseValue = nextValue - } - patchAttr(el, key, nextValue, isSVG, parentComponent) - } - break + if (key === 'class') { + patchClass(el, nextValue, isSVG) + } else if (key === 'style') { + patchStyle(el, prevValue, nextValue) + } else if (isOn(key)) { + // ignore v-model listeners + if (!isModelListener(key)) { + patchEvent(el, key, prevValue, nextValue, parentComponent) + } + } else if ( + key[0] === '.' + ? ((key = key.slice(1)), true) + : key[0] === '^' + ? ((key = key.slice(1)), false) + : shouldSetAsProp(el, key, nextValue, isSVG) + ) { + patchDOMProp( + el, + key, + nextValue, + prevChildren, + parentComponent, + parentSuspense, + unmountChildren + ) + } else { + // special case for with + // :true-value & :false-value + // store value as dom properties since non-string values will be + // stringified. + if (key === 'true-value') { + ;(el as any)._trueValue = nextValue + } else if (key === 'false-value') { + ;(el as any)._falseValue = nextValue + } + patchAttr(el, key, nextValue, isSVG, parentComponent) } }