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', () => {
const ast = baseParse('<div :a.sync=b />')
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"/>`, {
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
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(`<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
}
})

View File

@ -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 {

View File

@ -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) {

View File

@ -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),

View File

@ -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(`)`)
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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 <input v-model type="checkbox"> 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 <input v-model type="checkbox"> 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)
}
}