fix(v-on): refactor DOM event options modifer handling

fix #1567

Previously multiple `v-on` handlers with different event attach option
modifers (`.once`, `.capture` and `.passive`) are generated as an array
of objects in the form of `[{ handler, options }]` - however, this
makes it pretty complex for `runtime-dom` to properly handle all
possible value permutations, as each handler may need to be attached
with different options.

With this commit, they are now generated as event props with different
keys - e.g. `v-on:click.capture` is now generated as a prop named
`onClick.capture`. This allows them to be patched as separate props
which makes the runtime handling much simpler.
This commit is contained in:
Evan You 2020-07-14 11:48:05 -04:00
parent 9152a89016
commit 380c6792d8
8 changed files with 200 additions and 189 deletions

View File

@ -6,7 +6,9 @@ import {
CompilerOptions,
ErrorCodes,
NodeTypes,
VNodeCall
VNodeCall,
helperNameMap,
CAPITALIZE
} from '../../src'
import { transformOn } from '../../src/transforms/vOn'
import { transformElement } from '../../src/transforms/transformElement'
@ -73,7 +75,11 @@ describe('compiler: transform v-on', () => {
{
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [`"on" + (`, { content: `event` }, `)`]
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: `event` },
`)`
]
},
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
@ -94,7 +100,11 @@ describe('compiler: transform v-on', () => {
{
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [`"on" + (`, { content: `_ctx.event` }, `)`]
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: `_ctx.event` },
`)`
]
},
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
@ -116,7 +126,7 @@ describe('compiler: transform v-on', () => {
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
`"on" + (`,
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: `_ctx.event` },
`(`,
{ content: `_ctx.foo` },

View File

@ -5,16 +5,15 @@ import {
ElementNode,
ObjectExpression,
NodeTypes,
VNodeCall
VNodeCall,
helperNameMap,
CAPITALIZE
} from '@vue/compiler-core'
import { transformOn } from '../../src/transforms/vOn'
import { V_ON_WITH_MODIFIERS, V_ON_WITH_KEYS } from '../../src/runtimeHelpers'
import { transformElement } from '../../../compiler-core/src/transforms/transformElement'
import { transformExpression } from '../../../compiler-core/src/transforms/transformExpression'
import {
createObjectMatcher,
genFlagText
} from '../../../compiler-core/__tests__/testUtils'
import { genFlagText } from '../../../compiler-core/__tests__/testUtils'
import { PatchFlags } from '@vue/shared'
function parseWithVOn(template: string, options: CompilerOptions = {}) {
@ -83,42 +82,37 @@ describe('compiler-dom: transform v-on', () => {
})
expect(prop).toMatchObject({
type: NodeTypes.JS_PROPERTY,
value: createObjectMatcher({
handler: {
callee: V_ON_WITH_MODIFIERS,
arguments: [{ content: '_ctx.test' }, '["stop"]']
},
options: createObjectMatcher({
capture: { content: 'true', isStatic: false },
passive: { content: 'true', isStatic: false }
})
})
key: {
content: `onClick.capture.passive`
},
value: {
callee: V_ON_WITH_MODIFIERS,
arguments: [{ content: '_ctx.test' }, '["stop"]']
}
})
})
it('should wrap keys guard for keyboard events or dynamic events', () => {
const {
props: [prop]
} = parseWithVOn(`<div @keyDown.stop.capture.ctrl.a="test"/>`, {
} = parseWithVOn(`<div @keydown.stop.capture.ctrl.a="test"/>`, {
prefixIdentifiers: true
})
expect(prop).toMatchObject({
type: NodeTypes.JS_PROPERTY,
value: createObjectMatcher({
handler: {
callee: V_ON_WITH_KEYS,
arguments: [
{
callee: V_ON_WITH_MODIFIERS,
arguments: [{ content: '_ctx.test' }, '["stop","ctrl"]']
},
'["a"]'
]
},
options: createObjectMatcher({
capture: { content: 'true', isStatic: false }
})
})
key: {
content: `onKeydown.capture`
},
value: {
callee: V_ON_WITH_KEYS,
arguments: [
{
callee: V_ON_WITH_MODIFIERS,
arguments: [{ content: '_ctx.test' }, '["stop","ctrl"]']
},
'["a"]'
]
}
})
})
@ -206,9 +200,21 @@ describe('compiler-dom: transform v-on', () => {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
`(`,
{ children: [`"on" + (`, { content: 'event' }, `)`] },
`).toLowerCase() === "onclick" ? "onContextmenu" : (`,
{ children: [`"on" + (`, { content: 'event' }, `)`] },
{
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: 'event' },
`)`
]
},
`) === "onClick" ? "onContextmenu" : (`,
{
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: 'event' },
`)`
]
},
`)`
]
})
@ -232,9 +238,21 @@ describe('compiler-dom: transform v-on', () => {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
`(`,
{ children: [`"on" + (`, { content: 'event' }, `)`] },
`).toLowerCase() === "onclick" ? "onMouseup" : (`,
{ children: [`"on" + (`, { content: 'event' }, `)`] },
{
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: 'event' },
`)`
]
},
`) === "onClick" ? "onMouseup" : (`,
{
children: [
`"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: 'event' },
`)`
]
},
`)`
]
})
@ -254,24 +272,17 @@ describe('compiler-dom: transform v-on', () => {
expect((root as any).children[0].codegenNode.patchFlag).toBe(
genFlagText(PatchFlags.HYDRATE_EVENTS)
)
expect(prop.value).toMatchObject({
type: NodeTypes.JS_CACHE_EXPRESSION,
index: 1,
expect(prop).toMatchObject({
key: {
content: `onKeyup.capture`
},
value: {
type: NodeTypes.JS_OBJECT_EXPRESSION,
properties: [
{
key: { content: 'handler' },
value: {
type: NodeTypes.JS_CALL_EXPRESSION,
callee: V_ON_WITH_KEYS
}
},
{
key: { content: 'options' },
value: { type: NodeTypes.JS_OBJECT_EXPRESSION }
}
]
type: NodeTypes.JS_CACHE_EXPRESSION,
index: 1,
value: {
type: NodeTypes.JS_CALL_EXPRESSION,
callee: V_ON_WITH_KEYS
}
}
})
})

View File

@ -3,7 +3,6 @@ import {
DirectiveTransform,
createObjectProperty,
createCallExpression,
createObjectExpression,
createSimpleExpression,
NodeTypes,
createCompoundExpression,
@ -80,7 +79,7 @@ const transformClick = (key: ExpressionNode, event: string) => {
? createCompoundExpression([
`(`,
key,
`).toLowerCase() === "onclick" ? "${event}" : (`,
`) === "onClick" ? "${event}" : (`,
key,
`)`
])
@ -126,20 +125,16 @@ export const transformOn: DirectiveTransform = (dir, node, context) => {
}
if (eventOptionModifiers.length) {
handlerExp = createObjectExpression([
createObjectProperty('handler', handlerExp),
createObjectProperty(
'options',
createObjectExpression(
eventOptionModifiers.map(modifier =>
createObjectProperty(
modifier,
createSimpleExpression('true', false)
)
)
key = isStaticExp(key)
? createSimpleExpression(
`${key.content}.${eventOptionModifiers.join(`.`)}`,
true
)
)
])
: createCompoundExpression([
`(`,
key,
`) + ".${eventOptionModifiers.join(`.`)}"`
])
}
return {

View File

@ -160,30 +160,61 @@ describe('component: emit', () => {
expect(`event validation failed for event "foo"`).toHaveBeenWarned()
})
test('isEmitListener', () => {
const def1 = { emits: ['click'] }
expect(isEmitListener(def1, 'onClick')).toBe(true)
expect(isEmitListener(def1, 'onclick')).toBe(false)
expect(isEmitListener(def1, 'onBlick')).toBe(false)
test('.once', () => {
const Foo = defineComponent({
render() {},
emits: {
foo: null
},
created() {
this.$emit('foo')
this.$emit('foo')
}
})
const fn = jest.fn()
render(
h(Foo, {
'onFoo.once': fn
}),
nodeOps.createElement('div')
)
expect(fn).toHaveBeenCalledTimes(1)
})
const def2 = { emits: { click: null } }
expect(isEmitListener(def2, 'onClick')).toBe(true)
expect(isEmitListener(def2, 'onclick')).toBe(false)
expect(isEmitListener(def2, 'onBlick')).toBe(false)
describe('isEmitListener', () => {
test('array option', () => {
const def1 = { emits: ['click'] }
expect(isEmitListener(def1, 'onClick')).toBe(true)
expect(isEmitListener(def1, 'onclick')).toBe(false)
expect(isEmitListener(def1, 'onBlick')).toBe(false)
})
const mixin1 = { emits: ['foo'] }
const mixin2 = { emits: ['bar'] }
const extend = { emits: ['baz'] }
const def3 = {
emits: { click: null },
mixins: [mixin1, mixin2],
extends: extend
}
expect(isEmitListener(def3, 'onClick')).toBe(true)
expect(isEmitListener(def3, 'onFoo')).toBe(true)
expect(isEmitListener(def3, 'onBar')).toBe(true)
expect(isEmitListener(def3, 'onBaz')).toBe(true)
expect(isEmitListener(def3, 'onclick')).toBe(false)
expect(isEmitListener(def3, 'onBlick')).toBe(false)
test('object option', () => {
const def2 = { emits: { click: null } }
expect(isEmitListener(def2, 'onClick')).toBe(true)
expect(isEmitListener(def2, 'onclick')).toBe(false)
expect(isEmitListener(def2, 'onBlick')).toBe(false)
})
test('with mixins and extends', () => {
const mixin1 = { emits: ['foo'] }
const mixin2 = { emits: ['bar'] }
const extend = { emits: ['baz'] }
const def3 = {
mixins: [mixin1, mixin2],
extends: extend
}
expect(isEmitListener(def3, 'onFoo')).toBe(true)
expect(isEmitListener(def3, 'onBar')).toBe(true)
expect(isEmitListener(def3, 'onBaz')).toBe(true)
expect(isEmitListener(def3, 'onclick')).toBe(false)
expect(isEmitListener(def3, 'onBlick')).toBe(false)
})
test('.once listeners', () => {
const def2 = { emits: { click: null } }
expect(isEmitListener(def2, 'onClick.once')).toBe(true)
expect(isEmitListener(def2, 'onclick.once')).toBe(false)
})
})
})

View File

@ -246,6 +246,8 @@ export interface ComponentInternalInstance {
slots: InternalSlots
refs: Data
emit: EmitFn
// used for keeping track of .once event handlers on components
emitted: Record<string, boolean> | null
/**
* setup related
@ -396,7 +398,8 @@ export function createComponentInstance(
rtg: null,
rtc: null,
ec: null,
emit: null as any // to be set immediately
emit: null as any, // to be set immediately
emitted: null
}
if (__DEV__) {
instance.ctx = createRenderContext(instance)

View File

@ -67,12 +67,21 @@ export function emit(
}
}
let handler = props[`on${capitalize(event)}`]
let handlerName = `on${capitalize(event)}`
let handler = props[handlerName]
// for v-model update:xxx events, also trigger kebab-case equivalent
// for props passed via kebab-case
if (!handler && event.startsWith('update:')) {
event = hyphenate(event)
handler = props[`on${capitalize(event)}`]
handlerName = `on${capitalize(hyphenate(event))}`
handler = props[handlerName]
}
if (!handler) {
handler = props[handlerName + `.once`]
if (!instance.emitted) {
;(instance.emitted = {} as Record<string, boolean>)[handlerName] = true
} else if (instance.emitted[handlerName]) {
return
}
}
if (handler) {
callWithAsyncErrorHandling(
@ -123,13 +132,13 @@ function normalizeEmitsOptions(
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
// both considered matched listeners.
export function isEmitListener(comp: Component, key: string): boolean {
if (!isOn(key)) {
let emits: ObjectEmitsOptions | undefined
if (!isOn(key) || !(emits = normalizeEmitsOptions(comp))) {
return false
}
const emits = normalizeEmitsOptions(comp)
key = key.replace(/\.once$/, '')
return (
!!emits &&
(hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
hasOwn(emits, key.slice(2)))
hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
hasOwn(emits, key.slice(2))
)
}

View File

@ -57,17 +57,11 @@ describe(`runtime-dom: events patching`, () => {
expect(fn).not.toHaveBeenCalled()
})
it('should support event options', async () => {
it('should support event option modifiers', async () => {
const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn()
const nextValue = {
handler: fn,
options: {
once: true
}
}
patchProp(el, 'onClick', null, nextValue)
patchProp(el, 'onClick.once.capture', null, fn)
el.dispatchEvent(event)
await timeout()
el.dispatchEvent(event)
@ -75,39 +69,12 @@ describe(`runtime-dom: events patching`, () => {
expect(fn).toHaveBeenCalledTimes(1)
})
it('should support varying event options', async () => {
const el = document.createElement('div')
const event = new Event('click')
const prevFn = jest.fn()
const nextFn = jest.fn()
const nextValue = {
handler: nextFn,
options: {
once: true
}
}
patchProp(el, 'onClick', null, prevFn)
patchProp(el, 'onClick', prevFn, nextValue)
el.dispatchEvent(event)
await timeout()
el.dispatchEvent(event)
await timeout()
expect(prevFn).not.toHaveBeenCalled()
expect(nextFn).toHaveBeenCalledTimes(1)
})
it('should unassign event handler with options', async () => {
const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn()
const nextValue = {
handler: fn,
options: {
once: true
}
}
patchProp(el, 'onClick', null, nextValue)
patchProp(el, 'onClick', nextValue, null)
patchProp(el, 'onClick.capture', null, fn)
patchProp(el, 'onClick.capture', fn, null)
el.dispatchEvent(event)
await timeout()
el.dispatchEvent(event)

View File

@ -1,4 +1,4 @@
import { EMPTY_OBJ, isArray } from '@vue/shared'
import { isArray } from '@vue/shared'
import {
ComponentInternalInstance,
callWithAsyncErrorHandling
@ -14,12 +14,6 @@ type EventValue = (Function | Function[]) & {
invoker?: Invoker | null
}
type EventValueWithOptions = {
handler: EventValue
options: AddEventListenerOptions
invoker?: Invoker | null
}
// Async edge case fix requires storing an event listener's attach timestamp.
let _getNow: () => number = Date.now
@ -67,52 +61,43 @@ export function removeEventListener(
export function patchEvent(
el: Element,
rawName: string,
prevValue: EventValueWithOptions | EventValue | null,
nextValue: EventValueWithOptions | EventValue | null,
prevValue: EventValue | null,
nextValue: EventValue | null,
instance: ComponentInternalInstance | null = null
) {
const name = rawName.slice(2).toLowerCase()
const prevOptions = prevValue && 'options' in prevValue && prevValue.options
const nextOptions = nextValue && 'options' in nextValue && nextValue.options
const invoker = prevValue && prevValue.invoker
const value =
nextValue && 'handler' in nextValue ? nextValue.handler : nextValue
if (prevOptions || nextOptions) {
const prev = prevOptions || EMPTY_OBJ
const next = nextOptions || EMPTY_OBJ
if (
prev.capture !== next.capture ||
prev.passive !== next.passive ||
prev.once !== next.once
) {
if (invoker) {
removeEventListener(el, name, invoker, prev)
}
if (nextValue && value) {
const invoker = createInvoker(value, instance)
nextValue.invoker = invoker
addEventListener(el, name, invoker, next)
}
return
if (nextValue && invoker) {
// patch
;(prevValue as EventValue).invoker = null
invoker.value = nextValue
nextValue.invoker = invoker
} else {
const [name, options] = parseName(rawName)
if (nextValue) {
addEventListener(el, name, createInvoker(nextValue, instance), options)
} else if (invoker) {
// remove
removeEventListener(el, name, invoker, options)
}
}
}
if (nextValue && value) {
if (invoker) {
;(prevValue as EventValue).invoker = null
invoker.value = value
nextValue.invoker = invoker
} else {
addEventListener(
el,
name,
createInvoker(value, instance),
nextOptions || void 0
)
}
} else if (invoker) {
removeEventListener(el, name, invoker, prevOptions || void 0)
const optionsModifierRE = /\.(once|passive|capture)\b/g
function parseName(name: string): [string, EventListenerOptions | undefined] {
name = name.slice(2).toLowerCase()
if (optionsModifierRE.test(name)) {
const options: EventListenerOptions = {}
name = name.replace(
optionsModifierRE,
(_, key: keyof EventListenerOptions) => {
options[key] = true
return ''
}
)
return [name, options]
} else {
return [name, undefined]
}
}