v-on Directive
Now, let's take a look at the v-on
directive.
There are two types of v-on
: one for native elements and one for components. Since I don't know how to handle components yet, I will explain the one for native elements here.
(By the way, when it comes to components, it's mostly props, so there's not much to explain.)
Let's consider a component like the following.
<script setup>
import { ref } from "vue";
const count = ref(0);
function increment() {
count.value++;
}
</script>
<template>
<button type="button" @click="increment">{{ count }}</button>
</template>
This is a common counter component.
Compilation Result
The compilation result looks like the following.
const _sfc_main = {
vapor: true,
name: "App",
setup(props, { expose: expose }) {
expose();
const count = ref(0);
function increment() {
count.value++;
}
const __returned__ = { count, increment, ref };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
};
import {
delegate as _delegate,
renderEffect as _renderEffect,
setText as _setText,
delegateEvents as _delegateEvents,
template as _template,
} from "vue/vapor";
const t0 = _template('<button type="button"></button>');
_delegateEvents("click");
function _sfc_render(_ctx) {
const n0 = t0();
_delegate(n0, "click", () => _ctx.increment);
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
As usual, the script part is not significant, so let's focus on the following part.
import {
delegate as _delegate,
renderEffect as _renderEffect,
setText as _setText,
delegateEvents as _delegateEvents,
template as _template,
} from "vue/vapor";
const t0 = _template('<button type="button"></button>');
_delegateEvents("click");
function _sfc_render(_ctx) {
const n0 = t0();
_delegate(n0, "click", () => _ctx.increment);
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
Understanding the Overview
The generation of the template and the renderEffect
~ setText
are as usual.
This time, the main parts are
_delegateEvents("click");
and
_delegate(n0, "click", () => _ctx.increment);
As expected, the latter probably adds a click
event to n0
.
However, I don't understand what "delegate" means or what the former _delegateEvents
is doing.
For now, let's leave this as a mystery and look at the compiler's implementation.
We will understand the mystery as we proceed to read the runtime.
Reading the Compiler
IR
As usual, let's take a peek at the IR
.
There is something suspicious called SET_EVENT
, but I don't see anything else.
22 SET_EVENT,
Let's have a look.
115export interface SetEventIRNode extends BaseIRNode {
116 type: IRNodeTypes.SET_EVENT
117 element: number
118 key: SimpleExpressionNode
119 value?: SimpleExpressionNode
120 modifiers: {
121 // modifiers for addEventListener() options, e.g. .passive & .capture
122 options: string[]
123 // modifiers that needs runtime guards, withKeys
124 keys: string[]
125 // modifiers that needs runtime guards, withModifiers
126 nonKeys: string[]
127 }
128 keyOverride?: KeyOverride
129 delegate: boolean
130 /** Whether it's in effect */
131 effect: boolean
132}
It seems this Node has a delegate
flag.
129 delegate: boolean
Then, let's look for the transformer that generates this Node.
Found it. It is packages/compiler-vapor/src/transforms/vOn.ts .
73 const operation: SetEventIRNode = {
74 type: IRNodeTypes.SET_EVENT,
75 element: context.reference(),
76 key: arg,
77 value: exp,
78 modifiers: {
79 keys: keyModifiers,
80 nonKeys: nonKeyModifiers,
81 options: eventOptionModifiers,
82 },
83 keyOverride,
84 delegate,
85 effect: !arg.isStatic,
86 }
DirectiveTransform
Since it's the first time DirectiveTransform appears, let's see how it is called.
DirectiveTransform is called from transformElement
.
Specifically, it is called during the processing of element attributes.
42export const transformElement: NodeTransform = (node, context) => {
↓
56 const propsResult = buildProps(
57 node,
58 context as TransformContext<ElementNode>,
59 isComponent,
60 )
↓
188export function buildProps(
189 node: ElementNode,
190 context: TransformContext<ElementNode>,
191 isComponent: boolean,
192): PropsResult {
↓
255 const result = transformProp(prop, node, context)
↓
284function transformProp(
285 prop: VaporDirectiveNode | AttributeNode,
286 node: ElementNode,
287 context: TransformContext<ElementNode>,
288): DirectiveTransformResult | void {
↓
301 const directiveTransform = context.options.directiveTransforms[name]
302 if (directiveTransform) {
303 return directiveTransform(prop, node, context)
304 }
In this case, it gets the v-on transformer from the name like on
, and calls transformVOn
.
Then, in transformVOn
, context.registerEffect
is called at the end, registering the effect.
88 context.registerEffect([arg], operation)
transformVOn
Now, let's take a look at transformVOn
.
dir
is the AST of the directive. This is implemented in runtime-core
and is created at the parse stage.
From here, we extract arg
, expr
, modifiers
, and so on.
21 let { arg, exp, loc, modifiers } = dir
To explain briefly, in v-on:click.stop="handler"
, click
corresponds to arg
, stop
corresponds to modifiers
, and handler
corresponds to expr
.
First, we resolve and organize modifiers
by type.
32 const { keyModifiers, nonKeyModifiers, eventOptionModifiers } =
33 resolveModifiers(
34 arg.isStatic ? `on${arg.content}` : arg,
35 modifiers,
36 null,
37 loc,
38 )
resolveModifiers
is a function implemented in compiler-dom
that categorizes modifiers.
35export const resolveModifiers = (
36 key: ExpressionNode | string,
37 modifiers: string[],
38 context: TransformContext | null,
39 loc: SourceLocation,
40): {
41 keyModifiers: string[]
42 nonKeyModifiers: string[]
43 eventOptionModifiers: string[]
44} => {
Next, we determine whether to enable delegate
. (For now, let's leave aside what delegate
is.)
42 const delegate =
43 arg.isStatic && !eventOptionModifiers.length && delegatedEvents(arg.content)
It is enabled when all of the following conditions are met:
arg
is static
This is when it is not something likev-on[eventName]="handler"
.modifiers
are empty.- It is a delegate target
This is a determination of whether it is an event defined here. https://github.com/vuejs/core-vapor/blob/30583b9ee1c696d3cb836f0bfd969793e57e849d/packages/compiler-vapor/src/transforms/vOn.ts#L13-L18
After that, based on the information obtained so far, registering the effect with registerEffect
completes the process.
73 const operation: SetEventIRNode = {
74 type: IRNodeTypes.SET_EVENT,
75 element: context.reference(),
76 key: arg,
77 value: exp,
78 modifiers: {
79 keys: keyModifiers,
80 nonKeys: nonKeyModifiers,
81 options: eventOptionModifiers,
82 },
83 keyOverride,
84 delegate,
85 effect: !arg.isStatic,
86 }
87
88 context.registerEffect([arg], operation)
Reading Codegen
Let's focus mainly on how the delegate
flag affects things, and skim through the rest.
33export function genOperation(
34 oper: OperationNode,
35 context: CodegenContext,
36): CodeFragment[] {
44 case IRNodeTypes.SET_EVENT:
45 return genSetEvent(oper, context)
17export function genSetEvent(
18 oper: SetEventIRNode,
19 context: CodegenContext,
20): CodeFragment[] {
21 const { vaporHelper } = context
22 const { element, key, keyOverride, value, modifiers, delegate, effect } = oper
23
24 const name = genName()
25 const handler = genEventHandler(context, value)
26 const eventOptions = genEventOptions()
27
28 if (delegate) {
29 // key is static
30 context.delegates.add(key.content)
31 }
32
33 return [
34 NEWLINE,
35 ...genCall(
36 vaporHelper(delegate ? 'delegate' : 'on'),
37 `n${element}`,
38 name,
39 handler,
40 eventOptions,
41 ),
42 ]
This flow is now familiar, and there should be no difficult parts.
What I particularly want to highlight is here.
36 vaporHelper(delegate ? 'delegate' : 'on'),
When the delegate
in IR
is enabled, it generates a delegate
helper; otherwise, it generates an on
helper.
In other words, when reading the runtime next, comparing these two should help you grasp the role of delegate
.
You can also see that events are registered in context.delegates
just before this.
You can also understand that this is probably the hoisted _delegateEvents("click");
part.
28 if (delegate) {
29 // key is static
30 context.delegates.add(key.content)
31 }
Reading the Runtime
Now, there are three functions I want to read.
They are delegateEvents
, delegate
, and on
.
First, let's look at delegateEvents
in the order of execution.
delegateEvents
The implementation is as follows.
83/**
84 * Event delegation borrowed from solid
85 */
86const delegatedEvents = Object.create(null)
87
88export const delegateEvents = (...names: string[]): void => {
89 for (const name of names) {
90 if (!delegatedEvents[name]) {
91 delegatedEvents[name] = true
92 // eslint-disable-next-line no-restricted-globals
93 document.addEventListener(name, delegatedEventHandler)
94 }
95 }
96}
As you can see from the comments, this concept seems to be borrowed from Solid.
Looking at Solid's documentation, there is an explanation of delegate.
https://docs.solidjs.com/concepts/components/event-handlers
Solid provides two ways to add event listeners to the browser:
on:__
: adds an event listener to the element. This is also known as a native event.
on__
: adds an event listener to the document and dispatches it to the element. This can be referred to as a delegated event.Delegated events flow through the component tree, and save some resources by performing better on commonly used events. Native events, however, flow through the DOM tree, and provide more control over the behavior of the event.
delegateEvents
listens to delegatedEventHandler
on the document with the passed events.
Let's look at delegatedEventHandler
.
98const delegatedEventHandler = (e: Event) => {
99 let node = ((e.composedPath && e.composedPath()[0]) || e.target) as any
100 if (e.target !== node) {
101 Object.defineProperty(e, 'target', {
102 configurable: true,
103 value: node,
104 })
105 }
106 Object.defineProperty(e, 'currentTarget', {
107 configurable: true,
108 get() {
109 // eslint-disable-next-line no-restricted-globals
110 return node || document
111 },
112 })
113 while (node !== null) {
114 const handlers = getMetadata(node)[MetadataKind.event][e.type]
115 if (handlers) {
116 for (const handler of handlers) {
117 if (handler.delegate && !node.disabled) {
118 handler(e)
119 if (e.cancelBubble) return
120 }
121 }
122 }
123 node =
124 node.host && node.host !== node && node.host instanceof Node
125 ? node.host
126 : node.parentNode
127 }
128}
e.composedPath
is a method that returns the event's path (EventTarget) as an array.
https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath
// Example
e.composedPath(); // [button, div, body, html, document]
First, using a function called getMetadata
, it retrieves metadata from node
and obtains handlers from the event information there.\
114 const handlers = getMetadata(node)[MetadataKind.event][e.type]
Then, it executes all those handlers.
115 if (handlers) {
116 for (const handler of handlers) {
117 if (handler.delegate && !node.disabled) {
118 handler(e)
After that, it propagates this flow by traversing up through the host and parent.
123 node =
124 node.host && node.host !== node && node.host instanceof Node
125 ? node.host
126 : node.parentNode
113 while (node !== null) {
Let's also read delegate
in this flow.
delegate
58export function delegate(
59 el: HTMLElement,
60 event: string,
61 handlerGetter: () => undefined | ((...args: any[]) => any),
62 options: ModifierOptions = {},
63): void {
64 const handler: DelegatedHandler = eventHandler(handlerGetter, options)
65 handler.delegate = true
66 recordEventMetadata(el, event, handler)
67}
delegate
creates a handler and registers it in the metadata with the delegate flag set.
recordEventMetadata
is implemented in another file, packages/runtime-vapor/src/componentMetadata.ts.
21export function recordPropMetadata(el: Node, key: string, value: any): any {
22 const metadata = getMetadata(el)[MetadataKind.prop]
23 const prev = metadata[key]
24 metadata[key] = value
25 return prev
26}
As you can see from this, metadata is directly registered to the element in a property called $$metadata
. It has the following type.
5export enum MetadataKind {
6 prop,
7 event,
8}
9
10export type ComponentMetadata = [
11 props: Data,
12 events: Record<string, DelegatedHandler[]>,
13]
It seems to have event handlers and Props.
In other words, instead of directly registering event handlers here, it only holds the handlers, and actually, when the handler registered in document
by delegateEvents
is called, it refers to this metadata to execute the handlers.
on
Now, when the delegate
flag in IR
is not set, on
is called.
This is very simple, as it calls addEventListener
with queuePostFlushCb
.
29export function on(
30 el: Element,
31 event: string,
32 handlerGetter: () => undefined | ((...args: any[]) => any),
33 options: AddEventListenerOptions &
34 ModifierOptions & { effect?: boolean } = {},
35): void {
36 const handler: DelegatedHandler = eventHandler(handlerGetter, options)
37 let cleanupEvent: (() => void) | undefined
38 queuePostFlushCb(() => {
39 cleanupEvent = addEventListener(el, event, handler, options)
40 })
41
42 if (options.effect) {
43 onEffectCleanup(cleanup)
44 } else if (getCurrentScope()) {
45 onScopeDispose(cleanup)
46 }
47
48 function cleanup() {
49 cleanupEvent && cleanupEvent()
50 }
51}
14export function addEventListener(
15 el: Element,
16 event: string,
17 handler: (...args: any) => any,
18 options?: AddEventListenerOptions,
19) {
20 el.addEventListener(event, handler, options)
21 return (): void => el.removeEventListener(event, handler, options)
22}
Cleanup processing is also implemented.
delegate vs on
Now, we understand the differences in each implementation, but why are they used differently?
As can be seen from Solid's documentation, in the case of delegate
, it seems to save resources.
To explain a bit more specifically, the act of "attaching events to elements" incurs costs (time and memory).
While on
attaches events to each element individually, delegate
attaches events only to the document and, when an event occurs, determines which element the event originated from and triggers the event on the corresponding element.
This seems to contribute to performance. (I haven't benchmarked it myself, so I don't know how effective it is in Vapor. If you know, please let me know.)