Skip to content

Commit 90a6b75

Browse files
keithamuskoddsson
andcommitted
refactor: ground up rewrite of bind/listenForBind
This is a rewrite from first-principles of `bind` and `listenForBind` to balance readability and performance, in light of the current requirements (as encoded by tests). Co-authored-by: Kristján Oddsson <koddsson@gmail.com>
1 parent 6b1c723 commit 90a6b75

1 file changed

Lines changed: 77 additions & 82 deletions

File tree

src/bind.ts

Lines changed: 77 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,12 @@
1-
const bound = new Set<string>()
1+
const controllers = new Set<string>()
2+
23
/*
34
* Bind `[data-action]` elements from the DOM to their actions.
45
*
56
*/
67
export function bind(controller: HTMLElement): void {
7-
const tag = controller.tagName.toLowerCase()
8-
bound.add(tag)
9-
const actionAttributeMatcher = `[data-action*=":${tag}#"]`
10-
11-
for (const el of controller.querySelectorAll(actionAttributeMatcher)) {
12-
// Ignore nested elements
13-
if (el.closest(tag) !== controller) continue
14-
bindActionsToController(controller, el)
15-
}
16-
17-
// Also bind the controller to itself
18-
if (controller.matches(actionAttributeMatcher)) {
19-
bindActionsToController(controller, controller)
20-
}
21-
}
22-
23-
// Match the pattern of `eventName:constructor#method`.
24-
function* getActions(el: Element): Generator<[string, string, string]> {
25-
for (const binding of (el.getAttribute('data-action') || '').split(' ')) {
26-
const [rest, methodName] = binding.split('#')
27-
if (!methodName) continue
28-
29-
// eventName may contain `:` so account for that
30-
// by splitting by the last instance of `:`
31-
const colonIndex = rest.lastIndexOf(':')
32-
if (colonIndex < 0) continue
33-
34-
yield [rest.slice(0, colonIndex), rest.slice(colonIndex + 1), methodName]
35-
}
36-
}
37-
38-
function bindActionToController(controller: HTMLElement, el: Element, methodName: string, eventName: string) {
39-
// Check the `method` is present on the prototype
40-
const methodDescriptor =
41-
Object.getOwnPropertyDescriptor(controller, methodName) ||
42-
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), methodName)
43-
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
44-
el.addEventListener(eventName, (event: Event) => {
45-
methodDescriptor.value.call(controller, event)
46-
})
47-
}
48-
}
49-
50-
// Bind the data-action attribute of a single element to the controller
51-
function bindActionsToController(controller: HTMLElement, el: Element) {
52-
const tag = controller.tagName.toLowerCase()
53-
54-
for (const [eventName, tagName, methodName] of getActions(el)) {
55-
if (tagName === tag) {
56-
bindActionToController(controller, el, methodName, eventName)
57-
}
58-
}
59-
}
60-
61-
interface Subscription {
62-
closed: boolean
63-
unsubscribe(): void
8+
controllers.add(controller.tagName.toLowerCase())
9+
findElementsToBind(controller)
6410
}
6511

6612
/**
@@ -70,26 +16,22 @@ interface Subscription {
7016
* This returns a Subscription object which you can call `unsubscribe()` on to
7117
* stop further live updates.
7218
*/
73-
export function listenForBind(el: Node = document, batchSize = 30): Subscription {
19+
export function listenForBind(el: Node = document): Subscription {
7420
let closed = false
75-
7621
const observer = new MutationObserver(mutations => {
77-
const queue = new Set<Element>()
7822
for (const mutation of mutations) {
79-
if (mutation.type === 'childList' && mutation.addedNodes.length) {
23+
if (mutation.type === 'attributes' && mutation.target instanceof Element) {
24+
bindActions(mutation.target)
25+
} else if (mutation.type === 'childList' && mutation.addedNodes.length) {
8026
for (const node of mutation.addedNodes) {
81-
if (!(node instanceof Element)) continue
82-
if (node.hasAttribute('data-action')) {
83-
queue.add(node)
27+
if (node instanceof Element) {
28+
findElementsToBind(node)
8429
}
8530
}
8631
}
8732
}
88-
if (queue.size) requestAnimationFrame(() => processQueue(queue, batchSize))
8933
})
90-
91-
observer.observe(el, {childList: true, subtree: true})
92-
34+
observer.observe(el, {childList: true, subtree: true, attributes: true, attributeFilter: ['data-action']})
9335
return {
9436
get closed() {
9537
return closed
@@ -101,22 +43,75 @@ export function listenForBind(el: Node = document, batchSize = 30): Subscription
10143
}
10244
}
10345

104-
function processQueue(queue: Set<Element>, batchSize: number) {
105-
let counter = batchSize
106-
for (const el of queue) {
107-
for (const [eventName, controllerTag, methodName] of getActions(el)) {
108-
if (!bound.has(controllerTag)) continue
109-
const controller = el.closest(controllerTag)
110-
if (!(controller instanceof HTMLElement)) continue
46+
interface Subscription {
47+
closed: boolean
48+
unsubscribe(): void
49+
}
11150

112-
bindActionToController(controller, el, methodName, eventName)
51+
function findElementsToBind(root: Element) {
52+
for (const el of root.querySelectorAll('[data-action]')) {
53+
bindActions(el)
54+
}
55+
// Also bind the controller to itself
56+
if (root.hasAttribute('data-action')) {
57+
bindActions(root)
58+
}
59+
}
60+
61+
function getActionEventName(action: string): string {
62+
return action.slice(0, action.lastIndexOf(':'))
63+
}
64+
65+
function getActionControllerName(action: string): string {
66+
return action.slice(action.lastIndexOf(':') + 1, action.lastIndexOf('#'))
67+
}
68+
69+
function getActionMethodName(action: string): string {
70+
return action.slice(action.lastIndexOf('#') + 1)
71+
}
72+
73+
// ControllerEventHandler is a global event handler that dispatches events to
74+
// controllers. We use a global event handler over bindings functions because
75+
// this is far more performant; creating functions for each `addEventListener`
76+
// would be very costly for CPU performance (and memory), while registering a
77+
// single handler for every event keeps things relatively performant.
78+
const ControllerEventHandler = {
79+
handleEvent(event: Event) {
80+
const el = event.currentTarget
81+
if (!(el instanceof Element)) return
82+
for (const action of (el.getAttribute('data-action') || '').split(' ')) {
83+
// We want to dispatch this event, only to the subscribers; we filter by
84+
// event.type to find which actions should fire
85+
const eventType = getActionEventName(action)
86+
if (event.type !== eventType) continue
87+
// We need to find the closest controller to dispatch the event to.
88+
const tagName = getActionControllerName(action)
89+
// The controller should be "well known" in that `bind()` should have
90+
// been called on it.
91+
if (!controllers.has(tagName)) continue
92+
const controller = el.closest(tagName) as Element & Record<string, (ev: Event) => unknown>
93+
if (!controller) continue
94+
// Finally we need to get the right method to call on the controller.
95+
// The method also needs to exist!
96+
const method = getActionMethodName(action)
97+
if (typeof controller[method] === 'function') {
98+
controller[method](event)
99+
}
113100
}
114-
queue.delete(el)
101+
}
102+
}
115103

116-
counter -= 1
117-
if (counter === 0) break
104+
const bindings = new WeakMap<Element, Set<string>>()
105+
function bindActions(el: Element) {
106+
if (!bindings.has(el)) {
107+
bindings.set(el, new Set())
118108
}
119-
if (queue.size !== 0) {
120-
requestAnimationFrame(() => processQueue(queue, batchSize))
109+
const elementBindings = bindings.get(el)!
110+
for (const action of (el.getAttribute('data-action') || '').split(' ')) {
111+
const event = getActionEventName(action)
112+
if (!elementBindings.has(event)) {
113+
el.addEventListener(event, ControllerEventHandler)
114+
}
115+
elementBindings.add(event)
121116
}
122117
}

0 commit comments

Comments
 (0)