Unlocking the Puzzle: Investigating Multiple Event Listeners in Vue.js
Tags: webdev, javascript, frontend, vue
Published: 2023-08-27 15:48:40

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:

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

Now, let's [take a plunge into the JS tab](https://play.vuejs.org/#eNqFkstuwjAQRX9l5E1BQdCKHQTUh1jQRVu1XXoTzAQCjh35Qami/HvHTqFIILqL596TuTN2zR6qqr/zyEYstcIUlQOLzldTroqy0sZBDQZzaCA3uoQbst6MueJKaGUdCO2Vg0mwdG67JOReCVdoBYUSnS7B0dHfZdIjJBO4G0PDVTpoe1EXOjgsK5k5pBNAuh5O618MmiYd0DnWF945+u+9kIXYTjiLjiThbDpXwmCJCh0svlsySdJBC1yGKd0ZSDP8C3W65/0yKa9ztIfJtF3IGS2zcrHMTvh0cFwH6zFnac95sepvrFZ0R3VoEGYvq0Kiea3Cri1nI4hK0CiO/nqONWc89g51sUaxvVDf2H2ocfZm0KLZIWdHzWVmha6VZx8vuKfvo1jqpZfkviK+o9XSh4yt7dGrJcU+8cW08/jSCrX6tLO9Q2UPQ4WgwdlEP2f0+p6ujP4Xd9gfRo6rhjU/+Gz36Q==) 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):

```js
_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",
);
```

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](https://play.vuejs.org/#eNp9Us1uEzEQfpXBlyba/IByazeBgipRDoCAoy8bZ5Js67Ute5wERfvujL003bZRbvZ8P/PN2Edx69xkF1FcizIoXzuCgBTdQpq6cdYTHMHjGlpYe9vAFVOvbqSRRlkTCJSNhmCeKIP3QwbW0SiqrYHaqMGQxZkx2VU6IhRz+HADbY8VtnZ/q9FT5lb51FMMM7ucdsk4E18IG6crQr4BlNvZ4vi/CbRtOeV7ri8jEft/UrpWj3MpMqMoRv2OUizujfLYIIuXfzuXoiinnbgzejcew1e7B7LgqhCgiZpqpzGNjN4oDB9hPD7flHfwugerXvqf0Qx47EsxK60ve/Ay5wsYZKsXE7/x0lWzXFU9t3J62q8YCQr8zOt6M3kI1vAXOaZ2aZmNq9nxh0uPGKS4howkjMPZ/bdcIx9x9FRXW1SPZ+oP4ZBqUvz0GNDvUIoTRpXfIHXw3e/veODzCWzsKmpmXwB/YbA6powd7XM0K47d4+W09/mj12bzJ9wdCE14GioFTcw286Xgz//lwujPcWeTWdZJ04r2H6qTItk=). Here is the code:
```js
// @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",
);
```

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.
```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")
```

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.

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

```
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](https://play.vuejs.org/#eNp9kk1PAjEQhv9K04sQCWi4wUJEw0ETP6LerIe1DFDotk07BRKy/91pcYGD4daZ952ZZ5rZ84lz3U0EPuBFkF45ZAEwurEwqnLWI9szD3NWs7m3Fbsi69VQGGGkNQGZtNEgGyVL66ZNwjwaicoapoxstak4O7qbUkdg1yN2O2T1mSss7XaiwWP2lvl1VtHO7mZYFTUqp4HmfVH7zqn6myYXvQM/kVOAUDldIlDEWLHsj/d/KKyuix7FOf8TEYniTmol1yPBmwmCj5+bYVoFBAM+FL2DnUqL3rE/73AMxDdXi+4qWEMfuU+9BZe2coroXl1aNQg+YFlJWqm13T7lHPoInSYvlyDX/+RXYZdygr95COA3RHjUsPQLwIM8/XiBHb2PYmVnMe1zQXyHYHVMjAfbfTQzwj7zZdrHfA7KLD7DdEc/EpqlEmhy1tkvOJ3Iw4XVT7j9bj/XCVPz+hecPdwm). 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](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/index.ts)). 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](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/index.ts)). This renderer's primary task is to convert our `VNode`s 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 `VNode`s.
3. The renderer then traverses these nodes, converting them into native DOM elements.

As the renderer transforms `VNode`s 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](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/index.ts#L33) `rendererOptions`, encompassing a "patched" `patchProp` [method](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/patchProp.ts). Let's delve into this for further understanding.

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

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](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/modules/events.ts#L35). 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?"
```js
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
  }
}
```

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](https://github.com/vuejs/core/commit/d45e47569d366b932c0e3461afc6478b45a4602d) 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](https://github.com/vuejs/core/blob/main/packages/compiler-core/__tests__/transforms/vOn.spec.ts#L162-L178) 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:
```vue
<template>
  <button @click="[fn1, fn2].forEach((fn) => fn($event))">
    Click!
  </button>
</template>
```