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

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