瀏覽代碼

feat: use event delegation when possible

This also fixes async edge case #6566 where events propagate too slow
and incorrectly trigger handlers post-patch.
Evan You 7 年之前
父節點
當前提交
b7f7f27569

+ 1 - 0
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

+ 113 - 15
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) {

+ 11 - 11
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()
   }

+ 3 - 0
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)
 }

+ 3 - 0
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)
   })
 

+ 4 - 0
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)
   })
 

+ 3 - 2
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: `<div @click.middle="foo"></div>`,
       methods: { foo: spy }
-    }).$mount()
+    })
     triggerEvent(vm.$el, 'mouseup', e => { e.button = 0 })
     expect(spy).not.toHaveBeenCalled()
     triggerEvent(vm.$el, 'mouseup', e => { e.button = 1 })

+ 2 - 0
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', () => {