DEV Community

Cover image for Unlocking the Puzzle: Investigating Multiple Event Listeners in Vue.js
Denis Gonchar
Denis Gonchar

Posted on

Unlocking the Puzzle: Investigating Multiple Event Listeners in Vue.js

In this article, we will tackle a question: Does Vue.js support multiple event listeners? Our journey will take us deep into the mechanics of Vue.js, unraveling some intriguing undocumented behaviors along the way.

Let's begin with a closer look at the official documentation on "Event Handling" in Vue.js. The primary method of attaching an event listener is through the v-on:click="handler" syntax, which can also be simplified as @click="handler". In this syntax, the handler refers to a reference to a function. Additionally, in the "Inline Handlers" section, it's highlighted that you can employ arbitrary JavaScript code directly within the attribute. For instance, you can use @click="count++" to increment a variable. An important note is provided in the "Method vs. Inline Detection" section, which indicates that

The template compiler detects method handlers by checking whether the v-on value string is a valid JavaScript identifier or a property access path.

So, does Vue support multiple listeners? The answer seems to lean towards no, but it's not entirely clear-cut.

Let recap it with the code:

<script setup>
import { ref } from 'vue';

const count = ref(0);
function inc() { count.value += 1; }
</script>

<template>
  <h3>{{ count }}</h3>
  <button @click="count++">Incremenet by count++</button>
  <button @click="inc">Incremenet by ref</button>
  <button @click="inc()">Incremenet by call</button>
  <button @click="() => inc()">Incremenet by lambda</button>
</template>
Enter fullscreen mode Exit fullscreen mode

Now, let's take a plunge into the JS tab within the Vue SFC Playground to closely examine how the Vue.js compiler has compiled these listeners.

We will encounter the following code snippet (I've omitted the _cache[0] || (_cache[0] = $event => (count.value++)) portions for the sake of readability):

_createElementVNode("h3", null, _toDisplayString(count.value), 1 /* TEXT */);

// @click="count++" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: ($event) => count.value++,
  },
  "Incremenet by count++",
);

// @click="inc" will be compiled to...
_createElementVNode("button", { onClick: inc }, "Incremenet by ref");

// @click="inc()" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: ($event) => inc(),
  },
  "Incremenet by call",
);

// @click="() => inc()" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: () => inc(),
  },
  "Incremenet by lambda",
);
Enter fullscreen mode Exit fullscreen mode

The behavior is indeed intriguing. When a reference to inc is passed, the compiler simplifies it to { onClick: inc }. However, for count++, inc() and () => inc(), the compiler follows a distinct route. It encapsulates the code enclosed within the template's " into a lambda function and then proceeds to execute it exactly as it's written. This observation offers valuable insight: if the compiler wraps code within a lambda, we can leverage native JavaScript capabilities to call multiple functions within a single expression using fn1(); fn2() or fn1(), fn2(). Let's try it.

We will introduce another function, showAlert(), which will invoke the native alert() function and pass count.value into it. You can access the updated Playground here. Here is the code:

// @click="count++, showAlert()" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: ($event) => (count.value++, showAlert()),
  },
  "Increment by count++",
);

// How to pass multiple refs?
_createElementVNode("button", { onClick: inc }, "Increment by ref");

// @click="inc(); showAlert()" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: ($event) => {
      inc();
      showAlert();
    },
  },
  "Increment by call",
);

// @click="() => (inc(), showAlert())" will be compiled to...
_createElementVNode(
  "button",
  {
    onClick: () => (inc(), showAlert()),
  },
  "Increment by lambda",
);
Enter fullscreen mode Exit fullscreen mode

For @click="count++, showAlert()", @click="inc(); showAlert()", and @click="() => (inc(), showAlert())", everything works fine, allowing us to call multiple functions for a single event.

The issue arises when dealing with the ref case. How can we pass multiple refs into the @click="..." handler? The official documentation is notably silent on the topic of passing multiple references. It appears that this feature might not be supported, leaving us unable to achieve this behavior directly.

To explore this further, let's experiment with the two initial approaches that come to mind: fn1, fn2 and [fn1, f2], and observe how they are compiled by Vue.js.

// @click="inc, showAlert" will be complied to...
_createElementVNode("button", {
  onClick: $event => (inc, showAlert)
}, "Multiple refs 1");

// @click="[inc, showAlert]" will be complied to...
_createElementVNode("button", {
  onClick: $event => ([inc, showAlert])
}, "Multiple refs 2")
Enter fullscreen mode Exit fullscreen mode

Unfortunately, both attempts do not yield success. Vue.js compiles these expressions in a manner that involves encapsulating the code enclosed within the template's ". This approach is consistent with the behavior we previously uncovered.

Let's take a step back and examine the scenario where we simply pass a function identifier without any accompanying () braces in the event handler.

// @click="inc" will be compiled to...
_createElementVNode(
  "button",
  { onClick: inc },
  "Incremenet by ref",
);

Enter fullscreen mode Exit fullscreen mode

Vue simply maps inc to onClick. Now, let's recap the rule we extracted from the documentation.

The template compiler detects method handlers by checking whether the v-on value string is a valid JavaScript identifier or a property access path.

Incorporating the insights gained from the above, we can rephrase this rule as follows:

If the string within the template's v-on or @event, is recognized as a valid JavaScript identifier, Vue.js compiler will directly map it to { onEvent: <Valid JS Identifier> }.

Or like this:

Using only the name of a variable or a function will result in direct mapping.

Our revised definition omits any reference to a "method" handler; it purely states that when a valid identifier is used, it is directly passed as is. This implies that you can even pass a numeric value like 1337 to an onClick handler, provided you first create an identifier (in other words, a variable) that's bound to the value.

Passing a number as a handler clearly won't yield the desired results. However, as we recall, our aim is to pass multiple handlers as an array of refs. Given our newly established understanding, this is achievable. However, the prerequisite is to create a "valid JS Identifier (variable)" to store the reference to the array. Let's put this into action and see the results.

Take a look here. An interesting observation unfolds.

Firstly, using a "valid JS Identifier (variable)" named multiple, we successfully pass an array to onClick, and it gets mapped accordingly.

However, TypeScript expresses its discontent. It raises an error stating:

Type '(() => void)[]' is not assignable to type '(payload: MouseEvent) => void'.

Type '(() => void)[]' provides no match for the signature '(payload: MouseEvent): void'.ts(2322)

In essence, the types within Vue.js prevent us from passing an array of functions as a click listener.

Let's set this aside for now and simply click on the button to observe whether both listeners will be invoked. And yes, they are. We witness the counter value incrementing, followed by the appearance of the alert. But hold on, there's a puzzle to solve. Why is this functioning? What's going on behind the scenes?

To comprehend this, we must delve deeper and grasp the mechanics underlying the translation of Vue's VNode, created by the _createElementVNode function, into a native DOM element. The key lies in exploring the source code of Vue.js itself!

When we call the createApp() function in our main app.js or index.js, it triggers a chain of events that leads to the execution of the createRenderer() function (look for createApp function here). This sequence results in the formation of an app instance, complete with a mount() method. This method establishes an association with the renderer (see ensureRenderer() here). This renderer's primary task is to convert our VNodes into the native DOM elements we interact with.

Here's an overview of the key steps:

  1. We compile our template, resulting in a series of _createElementVNode() calls.
  2. These calls build our Virtual DOM, generating VNodes.
  3. The renderer then traverses these nodes, converting them into native DOM elements.

As the renderer transforms VNodes into native DOM elements, it performs additional tasks using the props object of a VNode through the patchProp method.

Additionally, note that the createRenderer(rendererOptions) function is invoked with extended rendererOptions, encompassing a "patched" patchProp method. Let's delve into this for further understanding.

export const patchProp: DOMRendererOptions['patchProp'] = (
  // Omitted params...
) => {
  if (key === 'class') {
    patchClass(el, nextValue, isSVG)
  } else if (key === 'style') {
    patchStyle(el, prevValue, nextValue)
    // Keep in mind that we provide an object containing on<EventName> keys.
    // `isOn(key)` will return true for these keys.
  } else if (isOn(key)) {
    if (!isModelListener(key)) {
      // If the listener isn't intended for `v-model`, we utilize the `patchEvent` mechanism.
      patchEvent(el, key, prevValue, nextValue, parentComponent)
    }
  } // ...
Enter fullscreen mode Exit fullscreen mode

We can interpret the code as follows: "If a prop is class, apply special handling based on class values. If a prop is style, implement special handling based on style values. And if a prop begins with on, perform specific actions using patchEvent."

Let's direct our attention to the patchEvent method. We've reached the bottom where Vue establishes native event bindings through the browser's addEventListener() method. However, prior to this step, there are additional operations in play. The high-level call chain is as follows:

  1. patchEvent() is invoked.
  2. It proceeds to call createInvoker() in order to generate an invoker function.
  3. Inside the invoker, we invoke callWithAsyncErrorHandling, passing a wrapped version (altered by patchStopImmediatePropagation) of the value provided in the @click="..." event handler.

Now, let's examine patchStopImmediatePropagation to uncover the answer to the question: "Why does passing multiple refs to a function work?"

function patchStopImmediatePropagation(
  e: Event,
  value: EventValue
): EventValue {

  // If the value is an array, there's even more to explore! 
  // We can call $event.stopImmediatePropagation()
  // and other functions within the array won't be invoked.
  if (isArray(value)) {
    const originalStop = e.stopImmediatePropagation
    e.stopImmediatePropagation = () => {
      originalStop.call(e)
      ;(e as any)._stopped = true
    }

    // This is where the actual function calls occur.
    return value.map(fn => (e: Event) => !(e as any)._stopped && fn && fn(e))
  } else {
    return value
  }
}
Enter fullscreen mode Exit fullscreen mode

And here we are, fully informed. Even though the official documentation and TypeScript might not explicitly endorse it, we've found a code segment that effectively allows us to pass event listeners using an array of function references.

There is the commit that introduced this functionality. It appears that at some point in the past, there might have been an intention to enable the capability of passing multiple listeners. However, as it stands now, this remains an undocumented feature.

Lastly, let's address the question we initially posed: Does Vue support multiple listeners? The answer hinges on your interpretation of "supports". To summarize:

  1. We can invoke multiple functions using fn1(); fn2(), and there's a test for that.
  2. We can also invoke them using fn1(), fn2().
  3. We can pass it through an array if stored in a variable.

Alternatively, given the newfound knowledge, we can even call them like so:

<template>
  <button @click="[fn1, fn2].forEach((fn) => fn($event))">
    Click!
  </button>
</template>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)