Skip to content

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.

vue
<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.

js
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.

js
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

js
_delegateEvents("click");

and

js
_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.

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.

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:

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

js
// 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.)