feat: use event delegation when possible

This also fixes async edge case #6566 where events propagate too slow
and incorrectly trigger handlers post-patch.
This commit is contained in:
Evan You 2018-12-19 19:26:58 -05:00
parent 080dd971f7
commit b7f7f27569
8 changed files with 140 additions and 28 deletions

View File

@ -14,6 +14,7 @@ export const isEdge = UA && UA.indexOf('edge/') > 0
export const isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android') export const isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android')
export const isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios') export const isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios')
export const isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge export const isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge
export const isPhantomJS = UA && /phantomjs/.test(UA)
// Firefox has a "watch" function on Object.prototype... // Firefox has a "watch" function on Object.prototype...
export const nativeWatch = ({}).watch export const nativeWatch = ({}).watch

View File

@ -2,7 +2,7 @@
import { isDef, isUndef } from 'shared/util' import { isDef, isUndef } from 'shared/util'
import { updateListeners } from 'core/vdom/helpers/index' import { updateListeners } from 'core/vdom/helpers/index'
import { isIE, supportsPassive } from 'core/util/index' import { isIE, isPhantomJS, supportsPassive } from 'core/util/index'
import { RANGE_TOKEN, CHECKBOX_RADIO_TOKEN } from 'web/compiler/directives/model' import { RANGE_TOKEN, CHECKBOX_RADIO_TOKEN } from 'web/compiler/directives/model'
// normalize v-model event tokens that can only be determined at runtime. // normalize v-model event tokens that can only be determined at runtime.
@ -38,32 +38,130 @@ function createOnceHandler (event, handler, capture) {
} }
} }
const delegateRE = /^(?:click|dblclick|submit|(?:key|mouse|touch|pointer).*)$/
const eventCounts = {}
const attachedGlobalHandlers = {}
type TargetRef = { el: Element | Document }
function add ( function add (
event: string, name: string,
handler: Function, handler: Function,
capture: boolean, capture: boolean,
passive: boolean passive: boolean
) { ) {
target.addEventListener( if (!capture && !passive && delegateRE.test(name)) {
event, const count = eventCounts[name]
handler, let store = target.__events
supportsPassive if (!count) {
? { capture, passive } attachGlobalHandler(name)
: capture }
) if (!store) {
store = target.__events = {}
}
if (!store[name]) {
eventCounts[name]++
}
store[name] = handler
} else {
target.addEventListener(
name,
handler,
supportsPassive
? { capture, passive }
: capture
)
}
}
function attachGlobalHandler(name: string) {
const handler = (attachedGlobalHandlers[name] = (e: any) => {
const isClick = e.type === 'click' || e.type === 'dblclick'
if (isClick && e.button !== 0) {
e.stopPropagation()
return false
}
const targetRef: TargetRef = { el: document }
dispatchEvent(e, name, isClick, targetRef)
})
document.addEventListener(name, handler)
eventCounts[name] = 0
}
function stopPropagation() {
this.cancelBubble = true
if (!this.immediatePropagationStopped) {
this.stopImmediatePropagation()
}
}
function dispatchEvent(
e: Event,
name: string,
isClick: boolean,
targetRef: TargetRef
) {
let el: any = e.target
let userEvent
if (isPhantomJS) {
// in PhantomJS it throws if we try to re-define currentTarget,
// so instead we create a wrapped event to the user
userEvent = Object.create((e: any))
userEvent.stopPropagation = stopPropagation.bind((e: any))
userEvent.preventDefault = e.preventDefault.bind(e)
} else {
userEvent = e
}
Object.defineProperty(userEvent, 'currentTarget', ({
configurable: true,
get() {
return targetRef.el
}
}: any))
while (el != null) {
// Don't process clicks on disabled elements
if (isClick && el.disabled) {
break
}
const store = el.__events
if (store) {
const handler = store[name]
if (handler) {
targetRef.el = el
handler(userEvent)
if (e.cancelBubble) {
break
}
}
}
el = el.parentNode
}
}
function removeGlobalHandler(name: string) {
document.removeEventListener(name, attachedGlobalHandlers[name])
attachedGlobalHandlers[name] = null
} }
function remove ( function remove (
event: string, name: string,
handler: Function, handler: Function,
capture: boolean, capture: boolean,
_target?: HTMLElement _target?: HTMLElement
) { ) {
(_target || target).removeEventListener( const el: any = _target || target
event, if (!capture && delegateRE.test(name)) {
handler._withTask || handler, el.__events[name] = null
capture if (--eventCounts[name] === 0) {
) removeGlobalHandler(name)
}
} else {
el.removeEventListener(
name,
handler._withTask || handler,
capture
)
}
} }
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) { function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {

View File

@ -15,19 +15,19 @@ module.exports = {
.assert.checked('#case-1 input', false) .assert.checked('#case-1 input', false)
// // #6566 // // #6566
// .assert.containsText('#case-2 button', 'Expand is True') .assert.containsText('#case-2 button', 'Expand is True')
// .assert.containsText('.count-a', 'countA: 0') .assert.containsText('.count-a', 'countA: 0')
// .assert.containsText('.count-b', 'countB: 0') .assert.containsText('.count-b', 'countB: 0')
// .click('#case-2 button') .click('#case-2 button')
// .assert.containsText('#case-2 button', 'Expand is False') .assert.containsText('#case-2 button', 'Expand is False')
// .assert.containsText('.count-a', 'countA: 1') .assert.containsText('.count-a', 'countA: 1')
// .assert.containsText('.count-b', 'countB: 0') .assert.containsText('.count-b', 'countB: 0')
// .click('#case-2 button') .click('#case-2 button')
// .assert.containsText('#case-2 button', 'Expand is True') .assert.containsText('#case-2 button', 'Expand is True')
// .assert.containsText('.count-a', 'countA: 1') .assert.containsText('.count-a', 'countA: 1')
// .assert.containsText('.count-b', 'countB: 1') .assert.containsText('.count-b', 'countB: 1')
.end() .end()
} }

View File

@ -1,6 +1,9 @@
window.triggerEvent = function triggerEvent (target, event, process) { window.triggerEvent = function triggerEvent (target, event, process) {
const e = document.createEvent('HTMLEvents') const e = document.createEvent('HTMLEvents')
e.initEvent(event, true, true) e.initEvent(event, true, true)
if (event === 'click') {
e.button = 0
}
if (process) process(e) if (process) process(e)
target.dispatchEvent(e) target.dispatchEvent(e)
} }

View File

@ -542,6 +542,7 @@ describe('Component slot', () => {
} }
}).$mount() }).$mount()
document.body.appendChild(vm.$el)
expect(vm.$el.textContent).toBe('hi') expect(vm.$el.textContent).toBe('hi')
vm.$children[0].toggle = false vm.$children[0].toggle = false
waitForUpdate(() => { waitForUpdate(() => {
@ -549,6 +550,8 @@ describe('Component slot', () => {
}).then(() => { }).then(() => {
triggerEvent(vm.$el.querySelector('.click'), 'click') triggerEvent(vm.$el.querySelector('.click'), 'click')
expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
}).then(() => {
document.body.removeChild(vm.$el)
}).then(done) }).then(done)
}) })

View File

@ -157,10 +157,12 @@ describe('Directive v-bind', () => {
} }
}).$mount() }).$mount()
document.body.appendChild(vm.$el)
expect(vm.$el.textContent).toBe('1') expect(vm.$el.textContent).toBe('1')
triggerEvent(vm.$el, 'click') triggerEvent(vm.$el, 'click')
waitForUpdate(() => { waitForUpdate(() => {
expect(vm.$el.textContent).toBe('2') expect(vm.$el.textContent).toBe('2')
document.body.removeChild(vm.$el)
}).then(done) }).then(done)
}) })
@ -227,6 +229,7 @@ describe('Directive v-bind', () => {
} }
} }
}).$mount() }).$mount()
document.body.appendChild(vm.$el)
expect(vm.$el.textContent).toBe('1') expect(vm.$el.textContent).toBe('1')
triggerEvent(vm.$el, 'click') triggerEvent(vm.$el, 'click')
waitForUpdate(() => { waitForUpdate(() => {
@ -234,6 +237,7 @@ describe('Directive v-bind', () => {
vm.test.fooBar = 3 vm.test.fooBar = 3
}).then(() => { }).then(() => {
expect(vm.$el.textContent).toBe('3') expect(vm.$el.textContent).toBe('3')
document.body.removeChild(vm.$el)
}).then(done) }).then(done)
}) })

View File

@ -735,10 +735,11 @@ describe('Directive v-on', () => {
it('should transform click.middle to mouseup', () => { it('should transform click.middle to mouseup', () => {
const spy = jasmine.createSpy('click.middle') const spy = jasmine.createSpy('click.middle')
const vm = new Vue({ vm = new Vue({
el,
template: `<div @click.middle="foo"></div>`, template: `<div @click.middle="foo"></div>`,
methods: { foo: spy } methods: { foo: spy }
}).$mount() })
triggerEvent(vm.$el, 'mouseup', e => { e.button = 0 }) triggerEvent(vm.$el, 'mouseup', e => { e.button = 0 })
expect(spy).not.toHaveBeenCalled() expect(spy).not.toHaveBeenCalled()
triggerEvent(vm.$el, 'mouseup', e => { e.button = 1 }) triggerEvent(vm.$el, 'mouseup', e => { e.button = 1 })

View File

@ -70,11 +70,13 @@ describe('Options functional', () => {
} }
}).$mount() }).$mount()
document.body.appendChild(vm.$el)
triggerEvent(vm.$el.children[0], 'click') triggerEvent(vm.$el.children[0], 'click')
expect(foo).toHaveBeenCalled() expect(foo).toHaveBeenCalled()
expect(foo.calls.argsFor(0)[0].type).toBe('click') // should have click event expect(foo.calls.argsFor(0)[0].type).toBe('click') // should have click event
triggerEvent(vm.$el.children[0], 'mousedown') triggerEvent(vm.$el.children[0], 'mousedown')
expect(bar).toHaveBeenCalledWith('bar') expect(bar).toHaveBeenCalledWith('bar')
document.body.removeChild(vm.$el)
}) })
it('should support returning more than one root node', () => { it('should support returning more than one root node', () => {