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
})
```
This commit is contained in:
Evan You 2021-07-13 15:58:18 -04:00
parent 00f0b3c465
commit 1c7d737cc8
9 changed files with 279 additions and 60 deletions

View File

@ -1276,6 +1276,54 @@ describe('compiler: parse', () => {
}) })
}) })
test('v-bind .prop shorthand', () => {
const ast = baseParse('<div .a=b />')
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', () => { test('v-bind shorthand with modifier', () => {
const ast = baseParse('<div :a.sync=b />') const ast = baseParse('<div :a.sync=b />')
const directive = (ast.children[0] as ElementNode).props[0] const directive = (ast.children[0] as ElementNode).props[0]

View File

@ -172,22 +172,140 @@ describe('compiler: transform v-bind', () => {
const node = parseWithVBind(`<div v-bind:[foo(bar)].camel="id"/>`, { const node = parseWithVBind(`<div v-bind:[foo(bar)].camel="id"/>`, {
prefixIdentifiers: true 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(`<div v-bind:fooBar.prop="id"/>`)
const props = (node.codegenNode as VNodeCall).props as ObjectExpression const props = (node.codegenNode as VNodeCall).props as ObjectExpression
expect(props.properties[0]).toMatchObject({ expect(props.properties[0]).toMatchObject({
key: { key: {
children: [ content: `.fooBar`,
`_${helperNameMap[CAMELIZE]}(`, isStatic: true
`(`,
{ content: `_ctx.foo` },
`(`,
{ content: `_ctx.bar` },
`)`,
`) || ""`,
`)`
]
}, },
value: { value: {
content: `_ctx.id`, content: `id`,
isStatic: false
}
})
})
test('.prop modifier w/ dynamic arg', () => {
const node = parseWithVBind(`<div v-bind:[fooBar].prop="id"/>`)
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(`<div v-bind:[foo(bar)].prop="id"/>`, {
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(`<div .fooBar="id"/>`)
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(`<div v-bind:foo-bar.attr="id"/>`)
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 isStatic: false
} }
}) })

View File

@ -221,6 +221,7 @@ export interface SimpleExpressionNode extends Node {
* the identifiers declared inside the function body. * the identifiers declared inside the function body.
*/ */
identifiers?: string[] identifiers?: string[]
isHandlerKey?: boolean
} }
export interface InterpolationNode extends Node { export interface InterpolationNode extends Node {
@ -243,6 +244,7 @@ export interface CompoundExpressionNode extends Node {
* the identifiers declared inside the function body. * the identifiers declared inside the function body.
*/ */
identifiers?: string[] identifiers?: string[]
isHandlerKey?: boolean
} }
export interface IfNode extends Node { export interface IfNode extends Node {

View File

@ -772,14 +772,19 @@ function parseAttribute(
} }
const loc = getSelection(context, start) const loc = getSelection(context, start)
if (!context.inVPre && /^(v-|:|@|#)/.test(name)) { if (!context.inVPre && /^(v-|:|\.|@|#)/.test(name)) {
const match = /(?:^v-([a-z0-9-]+))?(?:(?::|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec( const match = /(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec(
name name
)! )!
let isPropShorthand = startsWith(name, '.')
let dirName = let dirName =
match[1] || match[1] ||
(startsWith(name, ':') ? 'bind' : startsWith(name, '@') ? 'on' : 'slot') (isPropShorthand || startsWith(name, ':')
? 'bind'
: startsWith(name, '@')
? 'on'
: 'slot')
let arg: ExpressionNode | undefined let arg: ExpressionNode | undefined
if (match[2]) { if (match[2]) {
@ -835,6 +840,7 @@ function parseAttribute(
} }
const modifiers = match[3] ? match[3].substr(1).split('.') : [] const modifiers = match[3] ? match[3].substr(1).split('.') : []
if (isPropShorthand) modifiers.push('prop')
// 2.x compat v-bind:foo.sync -> v-model:foo // 2.x compat v-bind:foo.sync -> v-model:foo
if (__COMPAT__ && dirName === 'bind' && arg) { if (__COMPAT__ && dirName === 'bind' && arg) {

View File

@ -700,21 +700,26 @@ export function buildProps(
// but still need to deal with dynamic key binding // but still need to deal with dynamic key binding
let classKeyIndex = -1 let classKeyIndex = -1
let styleKeyIndex = -1 let styleKeyIndex = -1
let dynamicKeyIndex = -1 let hasDynamicKey = false
for (let i = 0; i < propsExpression.properties.length; i++) { for (let i = 0; i < propsExpression.properties.length; i++) {
const p = propsExpression.properties[i] const key = propsExpression.properties[i].key
if (p.key.type !== NodeTypes.SIMPLE_EXPRESSION) continue if (isStaticExp(key)) {
if (!isStaticExp(p.key)) dynamicKeyIndex = i if (key.content === 'class') {
if (isStaticExp(p.key) && p.key.content === 'class') classKeyIndex = i classKeyIndex = i
if (isStaticExp(p.key) && p.key.content === 'style') styleKeyIndex = i } else if (key.content === 'style') {
styleKeyIndex = i
}
} else if (!key.isHandlerKey) {
hasDynamicKey = true
}
} }
const classProp = propsExpression.properties[classKeyIndex] const classProp = propsExpression.properties[classKeyIndex]
const styleProp = propsExpression.properties[styleKeyIndex] const styleProp = propsExpression.properties[styleKeyIndex]
// no dynamic key // no dynamic key
if (dynamicKeyIndex === -1) { if (!hasDynamicKey) {
if (classProp && !isStaticExp(classProp.value)) { if (classProp && !isStaticExp(classProp.value)) {
classProp.value = createCallExpression( classProp.value = createCallExpression(
context.helper(NORMALIZE_CLASS), context.helper(NORMALIZE_CLASS),

View File

@ -1,5 +1,10 @@
import { DirectiveTransform } from '../transform' import { DirectiveTransform } from '../transform'
import { createObjectProperty, createSimpleExpression, NodeTypes } from '../ast' import {
createObjectProperty,
createSimpleExpression,
ExpressionNode,
NodeTypes
} from '../ast'
import { createCompilerError, ErrorCodes } from '../errors' import { createCompilerError, ErrorCodes } from '../errors'
import { camelize } from '@vue/shared' import { camelize } from '@vue/shared'
import { CAMELIZE } from '../runtimeHelpers' import { CAMELIZE } from '../runtimeHelpers'
@ -18,7 +23,6 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => {
arg.content = `${arg.content} || ""` arg.content = `${arg.content} || ""`
} }
// .prop is no longer necessary due to new patch behavior
// .sync is replaced by v-model:arg // .sync is replaced by v-model:arg
if (modifiers.includes('camel')) { if (modifiers.includes('camel')) {
if (arg.type === NodeTypes.SIMPLE_EXPRESSION) { 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 ( if (
!exp || !exp ||
(exp.type === NodeTypes.SIMPLE_EXPRESSION && !exp.content.trim()) (exp.type === NodeTypes.SIMPLE_EXPRESSION && !exp.content.trim())
@ -47,3 +59,16 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => {
props: [createObjectProperty(arg!, exp)] 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(`)`)
}
}

View File

@ -163,5 +163,7 @@ export const transformOn: DirectiveTransform = (
ret.props[0].value = context.cache(ret.props[0].value) 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 return ret
} }

View File

@ -171,6 +171,20 @@ describe('runtime-dom: props patching', () => {
patchProp(el, 'type', 'text', null) 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', () => { test('input with size', () => {
const el = document.createElement('input') const el = document.createElement('input')
patchProp(el, 'size', null, 100) patchProp(el, 'size', null, 100)

View File

@ -24,43 +24,42 @@ export const patchProp: DOMRendererOptions['patchProp'] = (
parentSuspense, parentSuspense,
unmountChildren unmountChildren
) => { ) => {
switch (key) { if (key === 'class') {
// special patchClass(el, nextValue, isSVG)
case 'class': } else if (key === 'style') {
patchClass(el, nextValue, isSVG) patchStyle(el, prevValue, nextValue)
break } else if (isOn(key)) {
case 'style': // ignore v-model listeners
patchStyle(el, prevValue, nextValue) if (!isModelListener(key)) {
break patchEvent(el, key, prevValue, nextValue, parentComponent)
default: }
if (isOn(key)) { } else if (
// ignore v-model listeners key[0] === '.'
if (!isModelListener(key)) { ? ((key = key.slice(1)), true)
patchEvent(el, key, prevValue, nextValue, parentComponent) : key[0] === '^'
} ? ((key = key.slice(1)), false)
} else if (shouldSetAsProp(el, key, nextValue, isSVG)) { : shouldSetAsProp(el, key, nextValue, isSVG)
patchDOMProp( ) {
el, patchDOMProp(
key, el,
nextValue, key,
prevChildren, nextValue,
parentComponent, prevChildren,
parentSuspense, parentComponent,
unmountChildren parentSuspense,
) unmountChildren
} else { )
// special case for <input v-model type="checkbox"> with } else {
// :true-value & :false-value // special case for <input v-model type="checkbox"> with
// store value as dom properties since non-string values will be // :true-value & :false-value
// stringified. // store value as dom properties since non-string values will be
if (key === 'true-value') { // stringified.
;(el as any)._trueValue = nextValue if (key === 'true-value') {
} else if (key === 'false-value') { ;(el as any)._trueValue = nextValue
;(el as any)._falseValue = nextValue } else if (key === 'false-value') {
} ;(el as any)._falseValue = nextValue
patchAttr(el, key, nextValue, isSVG, parentComponent) }
} patchAttr(el, key, nextValue, isSVG, parentComponent)
break
} }
} }