From b7f7f2756928f409950186c5d641034f362b392a Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 19 Dec 2018 19:26:58 -0500 Subject: [PATCH] feat: use event delegation when possible This also fixes async edge case #6566 where events propagate too slow and incorrectly trigger handlers post-patch. --- src/core/util/env.js | 1 + src/platforms/web/runtime/modules/events.js | 128 ++++++++++++++++-- test/e2e/specs/async-edge-cases.js | 22 +-- test/helpers/trigger-event.js | 3 + .../features/component/component-slot.spec.js | 3 + test/unit/features/directives/bind.spec.js | 4 + test/unit/features/directives/on.spec.js | 5 +- test/unit/features/options/functional.spec.js | 2 + 8 files changed, 140 insertions(+), 28 deletions(-) diff --git a/src/core/util/env.js b/src/core/util/env.js index a79cb8d533..e89a2fc90d 100644 --- a/src/core/util/env.js +++ b/src/core/util/env.js @@ -14,6 +14,7 @@ export const isEdge = UA && UA.indexOf('edge/') > 0 export const isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android') export const isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios') export const isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge +export const isPhantomJS = UA && /phantomjs/.test(UA) // Firefox has a "watch" function on Object.prototype... export const nativeWatch = ({}).watch diff --git a/src/platforms/web/runtime/modules/events.js b/src/platforms/web/runtime/modules/events.js index 1f44a10641..c0b0306370 100644 --- a/src/platforms/web/runtime/modules/events.js +++ b/src/platforms/web/runtime/modules/events.js @@ -2,7 +2,7 @@ import { isDef, isUndef } from 'shared/util' 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' // 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 ( - event: string, + name: string, handler: Function, capture: boolean, passive: boolean ) { - target.addEventListener( - event, - handler, - supportsPassive - ? { capture, passive } - : capture - ) + if (!capture && !passive && delegateRE.test(name)) { + const count = eventCounts[name] + let store = target.__events + if (!count) { + attachGlobalHandler(name) + } + 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 ( - event: string, + name: string, handler: Function, capture: boolean, _target?: HTMLElement ) { - (_target || target).removeEventListener( - event, - handler._withTask || handler, - capture - ) + const el: any = _target || target + if (!capture && delegateRE.test(name)) { + el.__events[name] = null + if (--eventCounts[name] === 0) { + removeGlobalHandler(name) + } + } else { + el.removeEventListener( + name, + handler._withTask || handler, + capture + ) + } } function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) { diff --git a/test/e2e/specs/async-edge-cases.js b/test/e2e/specs/async-edge-cases.js index 077387293c..27212b76d6 100644 --- a/test/e2e/specs/async-edge-cases.js +++ b/test/e2e/specs/async-edge-cases.js @@ -15,19 +15,19 @@ module.exports = { .assert.checked('#case-1 input', false) // // #6566 - // .assert.containsText('#case-2 button', 'Expand is True') - // .assert.containsText('.count-a', 'countA: 0') - // .assert.containsText('.count-b', 'countB: 0') + .assert.containsText('#case-2 button', 'Expand is True') + .assert.containsText('.count-a', 'countA: 0') + .assert.containsText('.count-b', 'countB: 0') - // .click('#case-2 button') - // .assert.containsText('#case-2 button', 'Expand is False') - // .assert.containsText('.count-a', 'countA: 1') - // .assert.containsText('.count-b', 'countB: 0') + .click('#case-2 button') + .assert.containsText('#case-2 button', 'Expand is False') + .assert.containsText('.count-a', 'countA: 1') + .assert.containsText('.count-b', 'countB: 0') - // .click('#case-2 button') - // .assert.containsText('#case-2 button', 'Expand is True') - // .assert.containsText('.count-a', 'countA: 1') - // .assert.containsText('.count-b', 'countB: 1') + .click('#case-2 button') + .assert.containsText('#case-2 button', 'Expand is True') + .assert.containsText('.count-a', 'countA: 1') + .assert.containsText('.count-b', 'countB: 1') .end() } diff --git a/test/helpers/trigger-event.js b/test/helpers/trigger-event.js index 4cd5d79db5..e282513573 100644 --- a/test/helpers/trigger-event.js +++ b/test/helpers/trigger-event.js @@ -1,6 +1,9 @@ window.triggerEvent = function triggerEvent (target, event, process) { const e = document.createEvent('HTMLEvents') e.initEvent(event, true, true) + if (event === 'click') { + e.button = 0 + } if (process) process(e) target.dispatchEvent(e) } diff --git a/test/unit/features/component/component-slot.spec.js b/test/unit/features/component/component-slot.spec.js index 28dafad437..e9ed31a00c 100644 --- a/test/unit/features/component/component-slot.spec.js +++ b/test/unit/features/component/component-slot.spec.js @@ -542,6 +542,7 @@ describe('Component slot', () => { } }).$mount() + document.body.appendChild(vm.$el) expect(vm.$el.textContent).toBe('hi') vm.$children[0].toggle = false waitForUpdate(() => { @@ -549,6 +550,8 @@ describe('Component slot', () => { }).then(() => { triggerEvent(vm.$el.querySelector('.click'), 'click') expect(spy).toHaveBeenCalled() + }).then(() => { + document.body.removeChild(vm.$el) }).then(done) }) diff --git a/test/unit/features/directives/bind.spec.js b/test/unit/features/directives/bind.spec.js index 07d0704bf2..52bc8168da 100644 --- a/test/unit/features/directives/bind.spec.js +++ b/test/unit/features/directives/bind.spec.js @@ -157,10 +157,12 @@ describe('Directive v-bind', () => { } }).$mount() + document.body.appendChild(vm.$el) expect(vm.$el.textContent).toBe('1') triggerEvent(vm.$el, 'click') waitForUpdate(() => { expect(vm.$el.textContent).toBe('2') + document.body.removeChild(vm.$el) }).then(done) }) @@ -227,6 +229,7 @@ describe('Directive v-bind', () => { } } }).$mount() + document.body.appendChild(vm.$el) expect(vm.$el.textContent).toBe('1') triggerEvent(vm.$el, 'click') waitForUpdate(() => { @@ -234,6 +237,7 @@ describe('Directive v-bind', () => { vm.test.fooBar = 3 }).then(() => { expect(vm.$el.textContent).toBe('3') + document.body.removeChild(vm.$el) }).then(done) }) diff --git a/test/unit/features/directives/on.spec.js b/test/unit/features/directives/on.spec.js index 807ad5777c..33de85d1be 100644 --- a/test/unit/features/directives/on.spec.js +++ b/test/unit/features/directives/on.spec.js @@ -735,10 +735,11 @@ describe('Directive v-on', () => { it('should transform click.middle to mouseup', () => { const spy = jasmine.createSpy('click.middle') - const vm = new Vue({ + vm = new Vue({ + el, template: `
`, methods: { foo: spy } - }).$mount() + }) triggerEvent(vm.$el, 'mouseup', e => { e.button = 0 }) expect(spy).not.toHaveBeenCalled() triggerEvent(vm.$el, 'mouseup', e => { e.button = 1 }) diff --git a/test/unit/features/options/functional.spec.js b/test/unit/features/options/functional.spec.js index 8e06d3e12e..b3446ac599 100644 --- a/test/unit/features/options/functional.spec.js +++ b/test/unit/features/options/functional.spec.js @@ -70,11 +70,13 @@ describe('Options functional', () => { } }).$mount() + document.body.appendChild(vm.$el) triggerEvent(vm.$el.children[0], 'click') expect(foo).toHaveBeenCalled() expect(foo.calls.argsFor(0)[0].type).toBe('click') // should have click event triggerEvent(vm.$el.children[0], 'mousedown') expect(bar).toHaveBeenCalledWith('bar') + document.body.removeChild(vm.$el) }) it('should support returning more than one root node', () => {