├── .gitignore ├── .babelrc ├── .flowconfig ├── .prettierrc ├── test ├── .eslintrc ├── test-flow.js ├── test-ts.ts ├── bench.html ├── test.html ├── bench.js └── test.js ├── rollup.config.test.js ├── rollup.config.bench.js ├── .gitmodules ├── karma.conf.js ├── .github └── workflows │ └── nodejs.yml ├── rollup.config.js ├── .eslintrc ├── index.d.ts ├── delegated-events.js.flow ├── LICENSE ├── package.json ├── delegated-events.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [options] 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jquery": true, 4 | "mocha": true 5 | }, 6 | "globals": { 7 | "assert": true, 8 | "Zepto": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /rollup.config.test.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import nodeResolve from 'rollup-plugin-node-resolve'; 3 | 4 | export default { 5 | input: 'test/test.js', 6 | output: { 7 | file: 'build/test.js', 8 | format: 'iife' 9 | }, 10 | plugins: [babel(), nodeResolve()] 11 | }; 12 | -------------------------------------------------------------------------------- /rollup.config.bench.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import nodeResolve from 'rollup-plugin-node-resolve'; 3 | 4 | export default { 5 | input: 'test/bench.js', 6 | output: { 7 | file: 'build/bench.js', 8 | format: 'iife' 9 | }, 10 | plugins: [babel(), nodeResolve()] 11 | }; 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/jquery-selector-set"] 2 | path = vendor/jquery-selector-set 3 | url = https://github.com/josh/jquery-selector-set 4 | [submodule "vendor/WeakMap"] 5 | path = vendor/WeakMap 6 | url = https://github.com/Polymer/WeakMap 7 | [submodule "vendor/classList.js"] 8 | path = vendor/classList.js 9 | url = https://github.com/components/classList.js 10 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | frameworks: ['mocha', 'chai'], 4 | files: ['build/test.js'], 5 | reporters: ['progress'], 6 | port: 9876, 7 | colors: true, 8 | logLevel: config.LOG_INFO, 9 | autoWatch: false, 10 | browsers: ['ChromeHeadless'], 11 | singleRun: false, 12 | concurrency: Infinity 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Use Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: '14.x' 12 | - run: npm install 13 | - run: npm run build 14 | - run: npm test 15 | env: 16 | CI: true 17 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | export default { 4 | input: 'delegated-events.js', 5 | output: [ 6 | { 7 | file: 'dist/index.js', 8 | format: 'es' 9 | }, 10 | { 11 | file: 'dist/index.umd.js', 12 | name: 'delegated-events', 13 | format: 'umd', 14 | globals: { 15 | 'selector-set': 'SelectorSet' 16 | } 17 | } 18 | ], 19 | external: 'selector-set', 20 | plugins: [babel()] 21 | }; 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parser": "babel-eslint", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "plugins": [ 12 | "prettier" 13 | ], 14 | "rules": { 15 | "prettier/prettier": 2, 16 | "camelcase": 2, 17 | "no-cond-assign": 0, 18 | "no-else-return": 2, 19 | "no-eval": 2, 20 | "no-unused-vars": ["error", { 21 | "argsIgnorePattern": "^_" 22 | }], 23 | "no-var": 2, 24 | "prefer-const": 2 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/test-flow.js: -------------------------------------------------------------------------------- 1 | /* @flow strict */ 2 | 3 | import {on, off, fire} from '../delegated-events'; 4 | 5 | function onButtonClick(event) { 6 | event.target; 7 | event.target.closest('.foo'); 8 | } 9 | 10 | on('click', '.js-button', onButtonClick); 11 | off('click', 'js-button', onButtonClick); 12 | 13 | on('robot:singularity', '.js-robot-image', function (event) { 14 | event.target; 15 | event.target.closest('.foo'); 16 | // event.detail.name 17 | // this.src 18 | }); 19 | 20 | const image = document.querySelector('.js-robot-image'); 21 | if (image) { 22 | fire(image, 'robot:singularity', {name: 'Hubot'}); 23 | } 24 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | type DelegatedEventListener = (this: Element, ev: CustomEvent & {currentTarget: Element}) => any 2 | 3 | export function on(name: K, selector: string, listener: (this: GlobalEventHandlers & Element, ev: GlobalEventHandlersEventMap[K] & {currentTarget: Element}) => any, options?: EventListenerOptions): void; 4 | export function on(name: string, selector: string, listener: DelegatedEventListener, options?: EventListenerOptions): void; 5 | export function off(name: string, selector: string, listener: DelegatedEventListener, options?: EventListenerOptions): void; 6 | export function fire(target: Document | Element, name: string, detail?: any): boolean; 7 | -------------------------------------------------------------------------------- /test/test-ts.ts: -------------------------------------------------------------------------------- 1 | import {on} from '../index'; 2 | 3 | on('click', '.hubot', function(event) { 4 | const e: MouseEvent = event; 5 | event.button; 6 | 7 | const self: Element = this; 8 | this.matches; 9 | 10 | const t: Element = event.currentTarget; 11 | event.currentTarget.matches; 12 | }); 13 | 14 | on('custom:event', '.hubot', function(event) { 15 | const e: CustomEvent = event; 16 | event.detail; 17 | 18 | const self: Element = this; 19 | this.matches; 20 | 21 | const t: Element = event.currentTarget; 22 | event.currentTarget.matches; 23 | }); 24 | 25 | document.addEventListener('click', handleEvent); 26 | on('click', '.hubot', handleEvent); 27 | 28 | function handleEvent(event: MouseEvent) { 29 | event.button; 30 | event.target; 31 | } 32 | -------------------------------------------------------------------------------- /test/bench.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Delegated Benchmark 6 | 7 | 8 | 9 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Delegated Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 19 | 20 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /delegated-events.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow strict */ 2 | 3 | type Event = { 4 | bubbles: boolean; 5 | cancelable: boolean; 6 | currentTarget: Element; 7 | deepPath?: () => EventTarget[]; 8 | defaultPrevented: boolean; 9 | eventPhase: number; 10 | isTrusted: boolean; 11 | scoped: boolean; 12 | srcElement: Element; 13 | target: Element; 14 | timeStamp: number; 15 | type: string; 16 | preventDefault(): void; 17 | stopImmediatePropagation(): void; 18 | stopPropagation(): void; 19 | } 20 | 21 | type EventHandler = (event: Event) => mixed 22 | 23 | type EventListenerOptions = { 24 | capture?: boolean 25 | }; 26 | 27 | declare module.exports: { 28 | on(name: string, selector: string, handler: EventHandler, options?: EventListenerOptions): void; 29 | off(name: string, selector: string, handler: EventHandler, options?: EventListenerOptions): void; 30 | fire(target: EventTarget, name: string, detail?: any): boolean; 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2019 David Graham 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delegated-events", 3 | "version": "1.1.2", 4 | "description": "A small, fast delegated event library.", 5 | "license": "MIT", 6 | "repository": "dgraham/delegated-events", 7 | "main": "dist/index.umd.js", 8 | "module": "dist/index.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "clean": "rm -rf build dist", 12 | "flow": "flow check", 13 | "ts": "tsc --noEmit test/test-ts.ts", 14 | "lint": "eslint delegated-events.js test", 15 | "bootstrap": "git submodule update --init && npm install", 16 | "prebuild": "npm run clean && npm run flow && npm run ts && npm run lint", 17 | "build": "rollup -c && cp delegated-events.js.flow dist/index.js.flow && cp delegated-events.js.flow dist/index.umd.js.flow", 18 | "pretest": "npm run clean && npm run flow && npm run ts && npm run lint && rollup -c rollup.config.test.js", 19 | "test": "karma start --single-run --browsers ChromeHeadless karma.conf.js", 20 | "prebrowser": "npm run pretest", 21 | "browser": "open \"file://$(pwd)/test/test.html\"", 22 | "prebench": "npm run clean && rollup -c rollup.config.bench.js", 23 | "bench": "open \"file://$(pwd)/test/bench.html\"", 24 | "prepublishOnly": "npm run build" 25 | }, 26 | "dependencies": { 27 | "selector-set": "^1.1.5" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.10.5", 31 | "@babel/preset-env": "^7.10.4", 32 | "babel-eslint": "^10.1.0", 33 | "chai": "^4.2.0", 34 | "custom-event-polyfill": "^1.0.7", 35 | "eslint": "^7.4.0", 36 | "eslint-plugin-prettier": "^3.1.4", 37 | "flow-bin": "^0.129.0", 38 | "karma": "^5.1.0", 39 | "karma-chai": "^0.1.0", 40 | "karma-chrome-launcher": "^3.1.0", 41 | "karma-mocha": "^2.0.1", 42 | "mocha": "^8.0.1", 43 | "prettier": "^2.0.5", 44 | "rollup": "^2.21.0", 45 | "rollup-plugin-babel": "^4.4.0", 46 | "rollup-plugin-node-resolve": "^5.2.0", 47 | "typescript": "^3.9.6" 48 | }, 49 | "files": [ 50 | "dist", 51 | "index.d.ts" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /delegated-events.js: -------------------------------------------------------------------------------- 1 | import SelectorSet from 'selector-set'; 2 | 3 | const bubbleEvents = {}; 4 | const captureEvents = {}; 5 | const propagationStopped = new WeakMap(); 6 | const immediatePropagationStopped = new WeakMap(); 7 | const currentTargets = new WeakMap(); 8 | const currentTargetDesc = Object.getOwnPropertyDescriptor( 9 | Event.prototype, 10 | 'currentTarget' 11 | ); 12 | 13 | function before(subject, verb, fn) { 14 | const source = subject[verb]; 15 | subject[verb] = function () { 16 | fn.apply(subject, arguments); 17 | return source.apply(subject, arguments); 18 | }; 19 | return subject; 20 | } 21 | 22 | function matches(selectors, target, reverse) { 23 | const queue = []; 24 | let node = target; 25 | 26 | do { 27 | if (node.nodeType !== 1) break; 28 | const matches = selectors.matches(node); 29 | if (matches.length) { 30 | const matched = {node: node, observers: matches}; 31 | if (reverse) { 32 | queue.unshift(matched); 33 | } else { 34 | queue.push(matched); 35 | } 36 | } 37 | } while ((node = node.parentElement)); 38 | 39 | return queue; 40 | } 41 | 42 | function trackPropagation() { 43 | propagationStopped.set(this, true); 44 | } 45 | 46 | function trackImmediate() { 47 | propagationStopped.set(this, true); 48 | immediatePropagationStopped.set(this, true); 49 | } 50 | 51 | function getCurrentTarget() { 52 | return currentTargets.get(this) || null; 53 | } 54 | 55 | function defineCurrentTarget(event, getter) { 56 | if (!currentTargetDesc) return; 57 | 58 | Object.defineProperty(event, 'currentTarget', { 59 | configurable: true, 60 | enumerable: true, 61 | get: getter || currentTargetDesc.get 62 | }); 63 | } 64 | 65 | function canDispatch(event) { 66 | try { 67 | event.eventPhase; 68 | return true; 69 | } catch (_) { 70 | return false; 71 | } 72 | } 73 | 74 | function dispatch(event) { 75 | if (!canDispatch(event)) return; 76 | 77 | const events = event.eventPhase === 1 ? captureEvents : bubbleEvents; 78 | const selectors = events[event.type]; 79 | if (!selectors) return; 80 | 81 | const queue = matches(selectors, event.target, event.eventPhase === 1); 82 | if (!queue.length) return; 83 | 84 | before(event, 'stopPropagation', trackPropagation); 85 | before(event, 'stopImmediatePropagation', trackImmediate); 86 | defineCurrentTarget(event, getCurrentTarget); 87 | 88 | for (let i = 0, len1 = queue.length; i < len1; i++) { 89 | if (propagationStopped.get(event)) break; 90 | const matched = queue[i]; 91 | currentTargets.set(event, matched.node); 92 | 93 | for (let j = 0, len2 = matched.observers.length; j < len2; j++) { 94 | if (immediatePropagationStopped.get(event)) break; 95 | matched.observers[j].data.call(matched.node, event); 96 | } 97 | } 98 | 99 | currentTargets.delete(event); 100 | defineCurrentTarget(event); 101 | } 102 | 103 | export function on(name, selector, fn, options = {}) { 104 | const capture = options.capture ? true : false; 105 | const events = capture ? captureEvents : bubbleEvents; 106 | 107 | let selectors = events[name]; 108 | if (!selectors) { 109 | selectors = new SelectorSet(); 110 | events[name] = selectors; 111 | document.addEventListener(name, dispatch, capture); 112 | } 113 | selectors.add(selector, fn); 114 | } 115 | 116 | export function off(name, selector, fn, options = {}) { 117 | const capture = options.capture ? true : false; 118 | const events = capture ? captureEvents : bubbleEvents; 119 | 120 | const selectors = events[name]; 121 | if (!selectors) return; 122 | selectors.remove(selector, fn); 123 | 124 | if (selectors.size) return; 125 | delete events[name]; 126 | document.removeEventListener(name, dispatch, capture); 127 | } 128 | 129 | export function fire(target, name, detail) { 130 | return target.dispatchEvent( 131 | new CustomEvent(name, { 132 | bubbles: true, 133 | cancelable: true, 134 | detail: detail 135 | }) 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /test/bench.js: -------------------------------------------------------------------------------- 1 | import {on, off} from '../delegated-events'; 2 | 3 | (function () { 4 | const DEPTH = 25; 5 | const DISPATCHES = 1500; 6 | 7 | function build(depth) { 8 | const selectors = []; 9 | let parent = document.body; 10 | for (let i = 0; i < depth; i++) { 11 | const name = 'js-div-' + i; 12 | selectors.push('.' + name); 13 | const child = document.createElement('div'); 14 | child.classList.add(name, 'a', 'b', 'c'); 15 | parent.appendChild(child); 16 | parent = child; 17 | } 18 | return selectors; 19 | } 20 | 21 | function handler(event) { 22 | if (event.type !== 'test:bench') { 23 | return 'test'; 24 | } 25 | } 26 | 27 | function matchHandler(event) { 28 | if (event.target.matches(this)) { 29 | if (event.type !== 'test:bench') { 30 | return 'test'; 31 | } 32 | } 33 | } 34 | 35 | function native() { 36 | const handlers = new Map(); 37 | return { 38 | name: 'native', 39 | on: function (selector) { 40 | const clone = matchHandler.bind(selector); 41 | handlers.set(selector, clone); 42 | document.addEventListener('test:bench', clone); 43 | }, 44 | off: function (selector) { 45 | const handler = handlers.get(selector); 46 | handlers.delete(selector); 47 | document.removeEventListener('test:bench', handler); 48 | } 49 | }; 50 | } 51 | 52 | function delegated() { 53 | return { 54 | name: 'delegated', 55 | on: function (selector) { 56 | on('test:bench', selector, handler); 57 | }, 58 | off: function (selector) { 59 | off('test:bench', selector, handler); 60 | } 61 | }; 62 | } 63 | 64 | function jquery() { 65 | return { 66 | name: 'jQuery', 67 | on: function (selector) { 68 | $(document).on('test:bench', selector, handler); 69 | }, 70 | off: function (selector) { 71 | $(document).off('test:bench', selector, handler); 72 | } 73 | }; 74 | } 75 | 76 | function jqueryss() { 77 | return { 78 | name: 'jQuery + SelectorSet', 79 | setup: function () { 80 | return new Promise(function (resolve) { 81 | const script = document.createElement('script'); 82 | script.addEventListener('load', resolve); 83 | script.src = '../vendor/jquery-selector-set/jquery.selector-set.js'; 84 | document.head.appendChild(script); 85 | }); 86 | }, 87 | on: function (selector) { 88 | $(document).on('test:bench', selector, handler); 89 | }, 90 | off: function (selector) { 91 | $(document).off('test:bench', selector, handler); 92 | } 93 | }; 94 | } 95 | 96 | function zepto() { 97 | return { 98 | name: 'zepto', 99 | on: function (selector) { 100 | Zepto(document).on('test:bench', selector, handler); 101 | }, 102 | off: function (selector) { 103 | Zepto(document).off('test:bench', selector, handler); 104 | } 105 | }; 106 | } 107 | 108 | function dispatch(node) { 109 | for (let i = 0; i < DISPATCHES; i++) { 110 | node.dispatchEvent( 111 | new CustomEvent('test:bench', { 112 | bubbles: true, 113 | cancelable: true, 114 | detail: {index: i} 115 | }) 116 | ); 117 | } 118 | } 119 | 120 | function report(results) { 121 | const colors = '#54c7fc #ffcd00 #ff9600 #ff2851 #0076ff #44db5e #ff3824 #8e8e93'.split( 122 | ' ' 123 | ); 124 | 125 | const max = results.reduce((a, b) => (a.value > b.value ? a : b)); 126 | 127 | results = results 128 | .map(function (result, ix) { 129 | const percent = (100 * result.value) / max.value; 130 | return { 131 | name: result.name, 132 | value: result.value, 133 | percent: Math.ceil(percent * 10) / 10, 134 | color: colors[ix] 135 | }; 136 | }) 137 | .sort((a, b) => (a.value < b.value ? -1 : 1)); 138 | 139 | const svg = document.querySelector('.js-results'); 140 | const ns = 'http://www.w3.org/2000/svg'; 141 | results.forEach(function (result, ix) { 142 | const row = document.createElementNS(ns, 'rect'); 143 | row.setAttribute('fill', result.color); 144 | row.setAttribute('width', result.percent + '%'); 145 | row.setAttribute('height', 60); 146 | 147 | const text = document.createElementNS(ns, 'text'); 148 | text.textContent = 149 | result.name + ': ' + result.value + 'ms ' + result.percent + '%'; 150 | text.setAttribute('x', 10); 151 | text.setAttribute('y', 35); 152 | 153 | const group = document.createElementNS(ns, 'g'); 154 | group.setAttribute('transform', 'translate(0, ' + 60 * ix + ')'); 155 | 156 | group.appendChild(row); 157 | group.appendChild(text); 158 | svg.appendChild(group); 159 | }); 160 | } 161 | 162 | function benchmark() { 163 | const fn = arguments[0]; 164 | const args = Array.prototype.slice.call(arguments, 1); 165 | const start = window.performance.now(); 166 | fn.apply(null, args); 167 | return Math.round(window.performance.now() - start); 168 | } 169 | 170 | function run() { 171 | const selectors = build(DEPTH); 172 | const deepest = document.querySelector(selectors[selectors.length - 1]); 173 | const results = [native, delegated, jquery, zepto, jqueryss].map(function ( 174 | test 175 | ) { 176 | const harness = test(); 177 | const ready = harness.setup ? harness.setup() : Promise.resolve(); 178 | return ready.then(function () { 179 | selectors.forEach(harness.on); 180 | const duration = benchmark(dispatch, deepest); 181 | selectors.forEach(harness.off); 182 | return {name: harness.name, value: duration}; 183 | }); 184 | }); 185 | Promise.all(results).then(report); 186 | } 187 | 188 | run(); 189 | })(); 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Delegated event listeners 2 | 3 | A small, fast delegated event library for JavaScript. 4 | 5 | ## Usage 6 | 7 | ```js 8 | import {on, off, fire} from 'delegated-events'; 9 | 10 | // Listen for browser-generated events. 11 | on('click', '.js-button', function(event) { 12 | console.log('clicked', this); 13 | }); 14 | 15 | // Listen for custom events triggered by your app. 16 | on('robot:singularity', '.js-robot-image', function(event) { 17 | console.log('robot', event.detail.name, this.src); 18 | }); 19 | 20 | // Dispatch a custom event on an element. 21 | var image = document.querySelector('.js-robot-image'); 22 | fire(image, 'robot:singularity', {name: 'Hubot'}); 23 | ``` 24 | 25 | ## Directly-bound events 26 | 27 | The standard method of registering event handler functions is to directly bind 28 | the listener to the element. 29 | 30 | ```js 31 | // Find an element and bind a function directly to it. 32 | var button = document.querySelector('.js-button'); 33 | button.addEventListener('click', function(event) { 34 | console.log('clicked', event.target); 35 | }); 36 | ``` 37 | 38 | If we have several clickable elements, listeners can be directly registered 39 | on them in a loop. 40 | 41 | ```js 42 | // Find all the buttons and attach a function to each of them. 43 | var buttons = document.querySelectorAll('.js-button'); 44 | buttons.forEach(function(button) { 45 | button.addEventListener('click', function(event) { 46 | console.log('clicked', event.target); 47 | }); 48 | }); 49 | ``` 50 | 51 | Directly binding event listeners to elements works great if the page doesn't 52 | change after it's initially loaded. However, if we dynamically add another 53 | button to the document, it won't receive a click event. 54 | 55 | ```js 56 | // No click handler is registered on the new element. 57 | var button = document.createElement('button'); 58 | button.textContent = 'Push'; 59 | 60 | var list = document.querySelector('.js-button-list'); 61 | list.appendChild(button); 62 | ``` 63 | 64 | ## Delegated events 65 | 66 | A solution to this is to *delegate* the event handler up the tree to the parent 67 | element that contains all of the button children. When a button is clicked, the 68 | event bubbles up the tree until it reaches the parent, at which point 69 | the handler is invoked. 70 | 71 | ```js 72 | // Event handling delegated to a parent element. 73 | var list = document.querySelector('.js-button-list'); 74 | list.addEventListener('click', function(event) { 75 | console.log('clicked', event.target); 76 | }); 77 | ``` 78 | 79 | However, now *anything* clicked inside the list will trigger this event 80 | handler, not just clicks on buttons. So we add a selector check to determine 81 | if a `button` generated the click event, rather than a `span` element, text, etc. 82 | 83 | ```js 84 | // Filter events by matching the element with a selector. 85 | var list = document.querySelector('.js-button-list'); 86 | list.addEventListener('click', function(event) { 87 | if (event.target.matches('.js-button')) { 88 | console.log('clicked', event.target); 89 | } 90 | }); 91 | ``` 92 | 93 | Now we have something that works for any button element inside the list 94 | whether it was included in the initial HTML page or added dynamically to the 95 | document sometime later. 96 | 97 | But what if the list element is added to the page dynamically? 98 | 99 | If we delegate *most events* up to the global `document`, we no longer worry 100 | about when elements are appended to the page—they will receive event listeners 101 | automatically. 102 | 103 | ```js 104 | // Delegated click handling up to global document. 105 | document.addEventListener('click', function(event) { 106 | if (event.target.matches('.js-button')) { 107 | console.log('clicked', event.target); 108 | } 109 | }); 110 | ``` 111 | 112 | ## Globally delegated events 113 | 114 | Now that we've covered how browsers handle directly-bound and delegated events 115 | natively, let's look at what this library actually does. 116 | 117 | The goals of this library are to: 118 | 119 | 1. Provide shortcuts that make this common delegation pattern easy to use 120 | in web applications with hundreds of event listeners. 121 | 2. Use the browser's native event system. 122 | 3. Speed :racehorse: 123 | 124 | ### Shortcuts 125 | 126 | Delegated event handling shortcuts (`on`, `off`, `fire`) are provided 127 | so event handlers aren't required to test for matching elements 128 | themselves. jQuery has great documentation on [event delegation with selectors][jq] too. 129 | 130 | [jq]: http://api.jquery.com/on/ 131 | 132 | Here's the same globally delegated handler as above but using `on`. 133 | 134 | ```js 135 | // Easy :) 136 | on('click', '.js-button', function(event) { 137 | console.log('clicked', event.target); 138 | }); 139 | ``` 140 | 141 | ### Native events 142 | 143 | To provide compatibility with older browsers, jQuery uses "synthetic" events. 144 | jQuery listens for the native browser event, wraps it inside a new event 145 | object, and proxies all function calls, with modifications, through to the 146 | native object. 147 | 148 | All browsers now share a standard event system, so we can remove the extra 149 | layer of event handling to recover performance. 150 | 151 | ### Performance 152 | 153 | The delegated event system is written in [vanilla JavaScript](delegated-events.js), 154 | so it won't significantly increase download times (minified + gzip = 640 bytes). 155 | It relies on a small [`SelectorSet`](https://github.com/josh/selector-set) 156 | data structure to optimize selector matching against the delegated events. 157 | 158 | A micro-benchmark to compare relative event handling performance is included 159 | and can be run with `npm run bench`. 160 | 161 | ## Triggering custom events 162 | 163 | A `fire` shortcut function is provided to trigger custom events with 164 | attached data objects. 165 | 166 | ```js 167 | on('validation:success', '.js-comment-input', function(event) { 168 | console.log('succeeded for', event.detail.login); 169 | }); 170 | 171 | var input = document.querySelector('.js-comment-input'); 172 | fire(input, 'validation:success', {login: 'hubot'}); 173 | ``` 174 | 175 | The standard way of doing this works well but is more verbose. 176 | 177 | ```js 178 | document.addEventListener('validation:success', function(event) { 179 | if (event.target.matches('.js-comment-input')) { 180 | console.log('succeeded for', event.detail.login); 181 | } 182 | }); 183 | 184 | var input = document.querySelector('.js-comment-input'); 185 | input.dispatchEvent( 186 | new CustomEvent('validation:success', { 187 | bubbles: true, 188 | cancelable: true, 189 | detail: {login: 'hubot'} 190 | }) 191 | ); 192 | ``` 193 | 194 | ## Adding TypeScript typed events 195 | 196 | If you're using TypeScript, you can maintain a list of custom event names that map to their specific types, making it easier to write type-safe code while using delegated events. Add the following snippet to a `.d.ts` file in your local project and alter the contents of `CustomEventMap` to list all the well-known events in your project: 197 | 198 | ```typescript 199 | // events.d.ts 200 | interface CustomEventMap { 201 | 'my-event:foo': { 202 | something: boolean 203 | } 204 | // When adding a new custom event to your code, add the event.name + event.detail type to this map! 205 | } 206 | 207 | // Do not change code below this line! 208 | type CustomDelegatedEventListener = (this: Element, ev: CustomEvent & {currentTarget: Element}) => any 209 | 210 | declare module 'delegated-events' { 211 | export function fire(target: Element, name: K, detail: CustomEventMap[K]): boolean 212 | export function on( 213 | name: K, 214 | selector: string, 215 | listener: CustomDelegatedEventListener 216 | ): void 217 | } 218 | 219 | declare global { 220 | interface Document { 221 | addEventListener( 222 | type: K, 223 | listener: (this: Document, ev: CustomEvent) => unknown, 224 | options?: boolean | AddEventListenerOptions 225 | ): void 226 | } 227 | interface HTMLElement { 228 | addEventListener( 229 | type: K, 230 | listener: (this: HTMLElement, ev: CustomEvent) => unknown, 231 | options?: boolean | AddEventListenerOptions 232 | ): void 233 | } 234 | } 235 | ``` 236 | 237 | Now TypeScript is able to type-check your events in both `delegated-events` callsites and the standard `addEventListener` callsites: 238 | 239 | ```typescript 240 | fire(document.body, 'my-event:foo', {something: true}) 241 | on('my-event:foo', 'body', event => { 242 | event.detail.something // typescript knows this is a boolean 243 | }) 244 | document.addEventListener('my-event:foo', event => { 245 | event.detail.something // typescript knows this is a boolean 246 | }) 247 | document.body.addEventListener('my-event:foo', event => { 248 | event.detail.something // typescript knows this is a boolean 249 | }) 250 | ``` 251 | 252 | 253 | ## Browser support 254 | 255 | - Chrome 256 | - Firefox 257 | - Safari 6+ 258 | - Internet Explorer 9+ 259 | - Microsoft Edge 260 | 261 | Internet Explorer requires polyfills for [`CustomEvent`][custom-event] 262 | and [`WeakMap`][weakmap]. 263 | 264 | [custom-event]: https://github.com/krambuhl/custom-event-polyfill 265 | [weakmap]: https://github.com/Polymer/WeakMap 266 | 267 | ## Development 268 | 269 | ``` 270 | npm run bootstrap 271 | npm test 272 | npm run bench 273 | npm run browser 274 | ``` 275 | 276 | ## License 277 | 278 | Distributed under the MIT license. See LICENSE for details. 279 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import {on, off, fire} from '../delegated-events'; 2 | 3 | describe('delegated event listeners', function () { 4 | before(function () { 5 | const container = document.createElement('div'); 6 | container.innerHTML = ` 7 |
8 |
9 |
`; 10 | document.body.appendChild(container); 11 | }); 12 | 13 | describe('custom event dispatch', function () { 14 | it('fires custom events with detail', function () { 15 | const observer = function (event) { 16 | assert(event.bubbles); 17 | assert(event.cancelable); 18 | assert.equal(event.type, 'test:detail'); 19 | assert.deepEqual(event.detail, {id: 42, login: 'hubot'}); 20 | assert.strictEqual(document.body, event.target); 21 | assert.instanceOf(event, CustomEvent); 22 | }; 23 | document.addEventListener('test:detail', observer); 24 | fire(document.body, 'test:detail', {id: 42, login: 'hubot'}); 25 | document.removeEventListener('test:detail', observer); 26 | }); 27 | 28 | it('fires custom events without detail', function () { 29 | const observer = function (event) { 30 | assert(event.detail === undefined || event.detail === null); 31 | assert.instanceOf(event, CustomEvent); 32 | }; 33 | document.addEventListener('test:fire', observer); 34 | fire(document.body, 'test:fire'); 35 | document.removeEventListener('test:fire', observer); 36 | }); 37 | 38 | it('returns canceled when default prevented', function () { 39 | const observer = event => event.preventDefault(); 40 | document.addEventListener('test:cancel', observer); 41 | const canceled = !fire(document.body, 'test:cancel'); 42 | assert.equal(canceled, true); 43 | document.removeEventListener('test:cancel', observer); 44 | }); 45 | 46 | it('returns not canceled when default is not prevented', function () { 47 | const [observer, trace] = spy(event => assert.ok(event)); 48 | document.addEventListener('test:event', observer); 49 | const canceled = !fire(document.body, 'test:event'); 50 | assert.equal(canceled, false); 51 | assert.equal(trace.calls, 1); 52 | document.removeEventListener('test:event', observer); 53 | }); 54 | }); 55 | 56 | describe('event observer registration', function () { 57 | it('observes custom events', function () { 58 | const observer = function (event) { 59 | assert(event.bubbles); 60 | assert(event.cancelable); 61 | assert.equal(event.type, 'test:on'); 62 | assert.deepEqual({id: 42, login: 'hubot'}, event.detail); 63 | assert.strictEqual(document.body, event.target); 64 | assert.strictEqual(document.body, event.currentTarget); 65 | assert.strictEqual(document.body, this); 66 | assert.strictEqual(this, event.currentTarget); 67 | assert.instanceOf(event, CustomEvent); 68 | }; 69 | on('test:on', 'body', observer); 70 | fire(document.body, 'test:on', {id: 42, login: 'hubot'}); 71 | off('test:on', 'body', observer); 72 | }); 73 | 74 | it('removes bubble event observers', function () { 75 | const observer = event => assert.fail(event); 76 | on('test:off', '*', observer); 77 | off('test:off', '*', observer); 78 | fire(document.body, 'test:off'); 79 | }); 80 | 81 | it('removes capture event observers', function () { 82 | const observer = event => assert.fail(event); 83 | on('test:off', '*', observer, {capture: true}); 84 | off('test:off', '*', observer, {capture: true}); 85 | fire(document.body, 'test:off'); 86 | }); 87 | 88 | it('can reregister after removing', function () { 89 | const [observer, trace] = spy(event => assert.ok(event)); 90 | on('test:register', 'body', observer); 91 | off('test:register', 'body', observer); 92 | on('test:register', 'body', observer); 93 | fire(document.body, 'test:register'); 94 | off('test:register', 'body', observer); 95 | assert.equal(trace.calls, 1); 96 | }); 97 | }); 98 | 99 | describe('event propagation', function () { 100 | before(function () { 101 | this.parent = document.querySelector('.js-test-parent'); 102 | this.child = document.querySelector('.js-test-child'); 103 | }); 104 | 105 | it('fires observers in tree order', function () { 106 | const order = []; 107 | 108 | const parent = this.parent; 109 | const child = this.child; 110 | 111 | const one = function (event) { 112 | assert.strictEqual(child, event.target); 113 | assert.strictEqual(parent, event.currentTarget); 114 | assert.strictEqual(this, event.currentTarget); 115 | assert.strictEqual(this, parent); 116 | order.push(1); 117 | }; 118 | 119 | const two = function (event) { 120 | assert.strictEqual(child, event.target); 121 | assert.strictEqual(child, event.currentTarget); 122 | assert.strictEqual(this, event.currentTarget); 123 | assert.strictEqual(this, child); 124 | order.push(2); 125 | }; 126 | 127 | const three = function (event) { 128 | assert.strictEqual(child, event.target); 129 | assert.strictEqual(parent, event.currentTarget); 130 | assert.strictEqual(this, event.currentTarget); 131 | assert.strictEqual(this, parent); 132 | order.push(3); 133 | }; 134 | 135 | const four = function (event) { 136 | assert.strictEqual(child, event.target); 137 | assert.strictEqual(child, event.currentTarget); 138 | assert.strictEqual(this, event.currentTarget); 139 | assert.strictEqual(this, child); 140 | order.push(4); 141 | }; 142 | 143 | on('test:order', '.js-test-parent', one, {capture: true}); 144 | on('test:order', '.js-test-child', two, {capture: true}); 145 | on('test:order', '.js-test-parent', three); 146 | on('test:order', '.js-test-child', four); 147 | fire(this.child, 'test:order'); 148 | off('test:order', '.js-test-parent', one, {capture: true}); 149 | off('test:order', '.js-test-child', two, {capture: true}); 150 | off('test:order', '.js-test-parent', three); 151 | off('test:order', '.js-test-child', four); 152 | 153 | assert.deepEqual([1, 2, 4, 3], order); 154 | }); 155 | 156 | it('clears currentTarget after propagation', function () { 157 | const [observer, trace] = spy(event => assert.ok(event.currentTarget)); 158 | const event = new CustomEvent('test:clear', {bubbles: true}); 159 | 160 | on('test:clear', 'body', observer); 161 | document.body.dispatchEvent(event); 162 | assert.equal(trace.calls, 1); 163 | assert.equal(event.currentTarget, null); 164 | off('test:clear', 'body', observer); 165 | }); 166 | 167 | it('does not interfere with currentTarget when selectors match', function () { 168 | const [observer, trace] = spy(event => 169 | assert.strictEqual(document.body, event.currentTarget) 170 | ); 171 | const [observer2, trace2] = spy(event => 172 | assert.strictEqual(this.parent, event.currentTarget) 173 | ); 174 | const event = new CustomEvent('test:target:capture', {bubbles: true}); 175 | 176 | on('test:target:capture', 'body', observer, {capture: true}); 177 | this.parent.addEventListener('test:target:capture', observer2); 178 | 179 | this.child.dispatchEvent(event); 180 | assert.equal(trace.calls, 1); 181 | assert.equal(trace2.calls, 1); 182 | assert.equal(event.currentTarget, null); 183 | 184 | off('test:target:capture', 'body', observer, {capture: true}); 185 | this.parent.removeEventListener('test:target:capture', observer2); 186 | }); 187 | 188 | it('does not interfere with currentTarget when no selectors match', function () { 189 | const [observer, trace] = spy(event => assert.ok(event.currentTarget)); 190 | const event = new CustomEvent('test:currentTarget', {bubbles: true}); 191 | 192 | const one = event => assert.fail(event); 193 | on('test:currentTarget', '.not-body', one); 194 | 195 | document.addEventListener('test:currentTarget', observer); 196 | 197 | document.body.dispatchEvent(event); 198 | assert.equal(trace.calls, 1); 199 | assert.equal(event.currentTarget, null); 200 | 201 | off('test:currentTarget', '.not-body', one); 202 | document.removeEventListener('test:currentTarget', observer); 203 | }); 204 | 205 | it('prevents redispatch after propagation is stopped', function () { 206 | const [observer, trace] = spy(event => event.stopPropagation()); 207 | const event = new CustomEvent('test:redispatch', {bubbles: true}); 208 | 209 | on('test:redispatch', 'body', observer); 210 | 211 | document.body.dispatchEvent(event); 212 | assert.equal(trace.calls, 1); 213 | 214 | document.body.dispatchEvent(event); 215 | assert.equal(trace.calls, 1); 216 | 217 | off('test:redispatch', 'body', observer); 218 | }); 219 | 220 | it('stops propagation bubbling to parent', function () { 221 | const one = event => assert.fail(event); 222 | const two = event => event.stopPropagation(); 223 | on('test:bubble', '.js-test-parent', one); 224 | on('test:bubble', '.js-test-child', two); 225 | fire(this.child, 'test:bubble'); 226 | off('test:bubble', '.js-test-parent', one); 227 | off('test:bubble', '.js-test-child', two); 228 | }); 229 | 230 | it('stops immediate propagation', function () { 231 | const one = event => event.stopImmediatePropagation(); 232 | const two = event => assert.fail(event); 233 | on('test:immediate', '.js-test-child', one); 234 | on('test:immediate', '.js-test-child', two); 235 | fire(this.child, 'test:immediate'); 236 | off('test:immediate', '.js-test-child', one); 237 | off('test:immediate', '.js-test-child', two); 238 | }); 239 | 240 | it('stops immediate propagation and bubbling', function () { 241 | const one = event => assert.fail(event); 242 | const two = event => event.stopImmediatePropagation(); 243 | on('test:stop', '.js-test-parent', one); 244 | on('test:stop', '.js-test-child', two); 245 | fire(this.child, 'test:stop'); 246 | off('test:stop', '.js-test-parent', one); 247 | off('test:stop', '.js-test-child', two); 248 | }); 249 | 250 | it('calculates selector matches before dispatching event', function () { 251 | this.child.classList.add('inactive'); 252 | 253 | const one = function (event) { 254 | event.target.classList.remove('inactive'); 255 | event.target.classList.add('active'); 256 | assert.ok(event); 257 | }; 258 | 259 | const two = event => assert.fail(event); 260 | 261 | on('test:match', '.js-test-child.inactive', one); 262 | on('test:match', '.js-test-child.active', two); 263 | fire(this.child, 'test:match'); 264 | off('test:match', '.js-test-child.inactive', one); 265 | off('test:match', '.js-test-child.active', two); 266 | 267 | this.child.classList.remove('active'); 268 | }); 269 | }); 270 | 271 | function spy(fn) { 272 | const tracker = {calls: 0}; 273 | const capture = function () { 274 | tracker.calls++; 275 | return fn.apply(this, arguments); 276 | }; 277 | return [capture, tracker]; 278 | } 279 | }); 280 | --------------------------------------------------------------------------------