feat: compound expression for `v-on` (#60)

This commit is contained in:
Rizumu Ayaka 2024-01-08 14:07:49 +08:00 committed by GitHub
parent fb4d9a1443
commit 26fee414ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 532 additions and 35 deletions

View File

@ -12,6 +12,18 @@ export function render(_ctx) {
}"
`;
exports[`v-on > complex member expression w/ prefixIdentifiers: true 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))
return n0
}"
`;
exports[`v-on > dynamic arg 1`] = `
"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
@ -26,6 +38,34 @@ export function render(_ctx) {
}"
`;
exports[`v-on > dynamic arg with complex exp prefixing 1`] = `
"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_renderEffect(() => {
_on(n1, _ctx.event(_ctx.foo), (...args) => (_ctx.handler && _ctx.handler(...args)))
})
return n0
}"
`;
exports[`v-on > dynamic arg with prefixing 1`] = `
"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_renderEffect(() => {
_on(n1, _ctx.event, (...args) => (_ctx.handler && _ctx.handler(...args)))
})
return n0
}"
`;
exports[`v-on > event modifier 1`] = `
"import { template as _template, children as _children, on as _on, withModifiers as _withModifiers, withKeys as _withKeys } from 'vue/vapor';
@ -59,6 +99,133 @@ export function render(_ctx) {
}"
`;
exports[`v-on > function expression w/ prefixIdentifiers: true 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", e => _ctx.foo(e))
return n0
}"
`;
exports[`v-on > inline statement w/ prefixIdentifiers: true 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => (_ctx.foo($event)))
return n0
}"
`;
exports[`v-on > multiple inline statements w/ prefixIdentifiers: true 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()})
return n0
}"
`;
exports[`v-on > should NOT add a prefix to $event if the expression is a function expression 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => {_ctx.i++;_ctx.foo($event)})
return n0
}"
`;
exports[`v-on > should NOT wrap as function if expression is already function expression (with Typescript) 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", (e: any): any => _ctx.foo(e))
return n0
}"
`;
exports[`v-on > should NOT wrap as function if expression is already function expression (with newlines) 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click",
$event => {
_ctx.foo($event)
}
)
return n0
}"
`;
exports[`v-on > should NOT wrap as function if expression is already function expression 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => _ctx.foo($event))
return n0
}"
`;
exports[`v-on > should NOT wrap as function if expression is complex member expression 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))
return n0
}"
`;
exports[`v-on > should handle multi-line statement 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => {
_ctx.foo();
_ctx.bar()
})
return n0
}"
`;
exports[`v-on > should handle multiple inline statement 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => {_ctx.foo();_ctx.bar()})
return n0
}"
`;
exports[`v-on > should not wrap keys guard if no key modifier is present 1`] = `
"import { template as _template, children as _children, on as _on, withModifiers as _withModifiers } from 'vue/vapor';
@ -148,6 +315,32 @@ export function render(_ctx) {
}"
`;
exports[`v-on > should wrap as function if expression is inline statement 1`] = `
"import { template as _template, children as _children, on as _on } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_on(n1, "click", $event => (_ctx.i++))
return n0
}"
`;
exports[`v-on > should wrap both for dynamic key event w/ left/right modifiers 1`] = `
"import { template as _template, children as _children, renderEffect as _renderEffect, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';
export function render(_ctx) {
const t0 = _template("<div></div>")
const n0 = t0()
const { 0: [n1],} = _children(n0)
_renderEffect(() => {
_on(n1, _ctx.e, _withKeys(_withModifiers((...args) => (_ctx.test && _ctx.test(...args)), ["left"]), ["left"]))
})
return n0
}"
`;
exports[`v-on > should wrap keys guard for keyboard events or dynamic events 1`] = `
"import { template as _template, children as _children, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';

View File

@ -124,30 +124,254 @@ describe('v-on', () => {
expect(code).matchSnapshot()
})
test.todo('dynamic arg with prefixing')
test.todo('dynamic arg with complex exp prefixing')
test.todo('should wrap as function if expression is inline statement')
test.todo('should handle multiple inline statement')
test.todo('should handle multi-line statement')
test.todo('inline statement w/ prefixIdentifiers: true')
test.todo('multiple inline statements w/ prefixIdentifiers: true')
test.todo(
'should NOT wrap as function if expression is already function expression',
)
test.todo(
test('dynamic arg with prefixing', () => {
const { code } = compileWithVOn(`<div v-on:[event]="handler"/>`, {
prefixIdentifiers: true,
})
expect(code).matchSnapshot()
})
test('dynamic arg with complex exp prefixing', () => {
const { ir, code } = compileWithVOn(`<div v-on:[event(foo)]="handler"/>`, {
prefixIdentifiers: true,
})
expect(ir.vaporHelpers).contains('on')
expect(ir.vaporHelpers).contains('renderEffect')
expect(ir.helpers.size).toBe(0)
expect(ir.operation).toEqual([])
expect(ir.effect[0].operations[0]).toMatchObject({
type: IRNodeTypes.SET_EVENT,
element: 1,
key: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'event(foo)',
isStatic: false,
},
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'handler',
isStatic: false,
},
})
expect(code).matchSnapshot()
})
test('should wrap as function if expression is inline statement', () => {
const { code, ir } = compileWithVOn(`<div @click="i++"/>`)
expect(ir.vaporHelpers).contains('on')
expect(ir.helpers.size).toBe(0)
expect(ir.effect).toEqual([])
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
element: 1,
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'i++',
isStatic: false,
},
},
])
expect(code).matchSnapshot()
expect(code).contains('_on(n1, "click", $event => (_ctx.i++))')
})
test('should handle multiple inline statement', () => {
const { ir, code } = compileWithVOn(`<div @click="foo();bar()"/>`)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: 'foo();bar()' },
},
])
expect(code).matchSnapshot()
// should wrap with `{` for multiple statements
// in this case the return value is discarded and the behavior is
// consistent with 2.x
expect(code).contains('_on(n1, "click", $event => {_ctx.foo();_ctx.bar()})')
})
test('should handle multi-line statement', () => {
const { code, ir } = compileWithVOn(`<div @click="\nfoo();\nbar()\n"/>`)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: '\nfoo();\nbar()\n' },
},
])
expect(code).matchSnapshot()
// should wrap with `{` for multiple statements
// in this case the return value is discarded and the behavior is
// consistent with 2.x
expect(code).contains(
'_on(n1, "click", $event => {\n_ctx.foo();\n_ctx.bar()\n})',
)
})
test('inline statement w/ prefixIdentifiers: true', () => {
const { code, ir } = compileWithVOn(`<div @click="foo($event)"/>`, {
prefixIdentifiers: true,
})
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: 'foo($event)' },
},
])
expect(code).matchSnapshot()
// should NOT prefix $event
expect(code).contains('_on(n1, "click", $event => (_ctx.foo($event)))')
})
test('multiple inline statements w/ prefixIdentifiers: true', () => {
const { ir, code } = compileWithVOn(`<div @click="foo($event);bar()"/>`, {
prefixIdentifiers: true,
})
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: 'foo($event);bar()' },
},
])
expect(code).matchSnapshot()
// should NOT prefix $event
expect(code).contains(
'_on(n1, "click", $event => {_ctx.foo($event);_ctx.bar()})',
)
})
test('should NOT wrap as function if expression is already function expression', () => {
const { code, ir } = compileWithVOn(`<div @click="$event => foo($event)"/>`)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: '$event => foo($event)' },
},
])
expect(code).matchSnapshot()
expect(code).contains('_on(n1, "click", $event => _ctx.foo($event))')
})
test.fails(
'should NOT wrap as function if expression is already function expression (with Typescript)',
() => {
const { ir, code } = compileWithVOn(
`<div @click="(e: any): any => foo(e)"/>`,
{ expressionPlugins: ['typescript'] },
)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: '(e: any): any => foo(e)' },
},
])
expect(code).matchSnapshot()
expect(code).contains('_on(n1, "click", e => _ctx.foo(e))')
},
)
test.todo(
'should NOT wrap as function if expression is already function expression (with newlines)',
)
test.todo(
'should NOT wrap as function if expression is already function expression (with newlines + function keyword)',
)
test.todo(
'should NOT wrap as function if expression is complex member expression',
)
test.todo('complex member expression w/ prefixIdentifiers: true')
test.todo('function expression w/ prefixIdentifiers: true')
test('should NOT wrap as function if expression is already function expression (with newlines)', () => {
const { ir, code } = compileWithVOn(
`<div @click="
$event => {
foo($event)
}
"/>`,
)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: {
content: `
$event => {
foo($event)
}
`,
},
},
])
expect(code).matchSnapshot()
})
test('should NOT add a prefix to $event if the expression is a function expression', () => {
const { ir, code } = compileWithVOn(
`<div @click="$event => {i++;foo($event)}"></div>`,
{
prefixIdentifiers: true,
},
)
expect(ir.operation[0]).toMatchObject({
type: IRNodeTypes.SET_EVENT,
value: { content: '$event => {i++;foo($event)}' },
})
expect(code).matchSnapshot()
})
test('should NOT wrap as function if expression is complex member expression', () => {
const { ir, code } = compileWithVOn(`<div @click="a['b' + c]"/>`)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: `a['b' + c]` },
},
])
expect(code).matchSnapshot()
})
test('complex member expression w/ prefixIdentifiers: true', () => {
const { ir, code } = compileWithVOn(`<div @click="a['b' + c]"/>`)
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: `a['b' + c]` },
},
])
expect(code).matchSnapshot()
expect(code).contains(
`_on(n1, "click", (...args) => (_ctx.a['b' + _ctx.c] && _ctx.a['b' + _ctx.c](...args)))`,
)
})
test('function expression w/ prefixIdentifiers: true', () => {
const { code, ir } = compileWithVOn(`<div @click="e => foo(e)"/>`, {
prefixIdentifiers: true,
})
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
value: { content: `e => foo(e)` },
},
])
expect(code).matchSnapshot()
expect(code).contains('_on(n1, "click", e => _ctx.foo(e))')
})
test('should error if no expression AND no modifier', () => {
const onError = vi.fn()
@ -366,14 +590,40 @@ describe('v-on', () => {
expect(ir.operation).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
modifiers: { keys: ['left'] },
modifiers: {
keys: ['left'],
nonKeys: [],
options: [],
},
},
])
expect(code).matchSnapshot()
})
test.todo('should wrap both for dynamic key event w/ left/right modifiers')
test('should wrap both for dynamic key event w/ left/right modifiers', () => {
const { code, ir } = compileWithVOn(`<div @[e].left="test"/>`, {
prefixIdentifiers: true,
})
expect(ir.effect[0].operations).toMatchObject([
{
type: IRNodeTypes.SET_EVENT,
key: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'e',
isStatic: false,
},
modifiers: {
keys: ['left'],
nonKeys: ['left'],
options: [],
},
},
])
expect(code).matchSnapshot()
})
test('should transform click.right', () => {
const { code, ir } = compileWithVOn(`<div @click.right="test"/>`)

View File

@ -8,6 +8,7 @@ import {
advancePositionWithClone,
advancePositionWithMutation,
createSimpleExpression,
isMemberExpression,
isSimpleIdentifier,
locStub,
walkIdentifiers,
@ -33,6 +34,10 @@ import { SourceMapGenerator } from 'source-map-js'
import { camelize, isGloballyAllowed, isString, makeMap } from '@vue/shared'
import type { Identifier } from '@babel/types'
// TODO: share this with compiler-core
const fnExpRE =
/^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/
// remove when stable
// @ts-expect-error
function checkNever(x: never): never {}
@ -508,15 +513,7 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) {
;(keys.length ? pushWithKeys : pushNoop)(() =>
(nonKeys.length ? pushWithModifiers : pushNoop)(() => {
if (oper.value && oper.value.content.trim()) {
push('(...args) => (')
genExpression(oper.value, context)
push(' && ')
genExpression(oper.value, context)
push('(...args))')
} else {
push('() => {}')
}
genEventHandler()
}),
)
},
@ -524,6 +521,37 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) {
!!options.length &&
(() => push(`{ ${options.map((v) => `${v}: true`).join(', ')} }`)),
)
function genEventHandler() {
const exp = oper.value
if (exp && exp.content.trim()) {
const isMemberExp = isMemberExpression(exp.content, {
// TODO: expression plugins
expressionPlugins: [],
})
const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
const hasMultipleStatements = exp.content.includes(`;`)
if (isInlineStatement) {
push('$event => ')
push(hasMultipleStatements ? '{' : '(')
const knownIds = Object.create(null)
knownIds['$event'] = 1
genExpression(exp, context, knownIds)
push(hasMultipleStatements ? '}' : ')')
} else if (isMemberExp) {
push('(...args) => (')
genExpression(exp, context)
push(' && ')
genExpression(exp, context)
push('(...args))')
} else {
genExpression(exp, context)
}
} else {
push('() => {}')
}
}
}
function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) {
@ -588,7 +616,11 @@ function genArrayExpression(elements: string[]) {
const isLiteralWhitelisted = /*#__PURE__*/ makeMap('true,false,null,this')
function genExpression(node: IRExpression, context: CodegenContext): void {
function genExpression(
node: IRExpression,
context: CodegenContext,
knownIds: Record<string, number> = Object.create(null),
): void {
const { push } = context
if (isString(node)) return push(node)
@ -616,10 +648,13 @@ function genExpression(node: IRExpression, context: CodegenContext): void {
const ids: Identifier[] = []
walkIdentifiers(
ast!,
(id) => {
(id, parent, parentStack, isReference, isLocal) => {
if (isLocal) return
ids.push(id)
},
true,
[],
knownIds,
)
if (ids.length) {
ids.sort((a, b) => a.start! - b.start!)

View File

@ -71,6 +71,7 @@ export {
export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants'
export {
baseWatch,
getCurrentEffect,
onEffectCleanup,
traverse,
BaseWatchErrorCodes,

View File

@ -29,6 +29,7 @@ export {
// effect
stop,
ReactiveEffect,
getCurrentEffect,
onEffectCleanup,
// effect scope
effectScope,

View File

@ -1,3 +1,5 @@
import { getCurrentEffect, onEffectCleanup } from '@vue/reactivity'
export function on(
el: HTMLElement,
event: string,
@ -5,4 +7,7 @@ export function on(
options?: AddEventListenerOptions,
) {
el.addEventListener(event, handler, options)
if (getCurrentEffect()) {
onEffectCleanup(() => el.removeEventListener(event, handler, options))
}
}

View File

@ -1,7 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue/vapor'
const handleClick = () => {
console.log('Hello, Vapor!')
}
const i = ref(0)
const event = ref('click')
</script>
<template>
@ -12,4 +16,12 @@ const handleClick = () => {
<form>
<button @click.prevent="handleClick">no submit</button>
</form>
<div>
{{ i }}
<button @[event].prevent="i++">Add by {{ event }}</button>
<button @click="event = event === 'click' ? 'contextmenu' : 'click'">
Change Event
</button>
</div>
</template>