Как правильно обрабатывать события

В эту ловушку я попадал уже много раз. В современных фронтенд-фреймворках есть масса родительских и дочерних компонентов. Родительский компонент передает данные через входные параметры (англ. props). А дочерние компоненты генерируют события для взаимодействия с своим родителем. Или нет?

Конечно, родительский компонент должен предоставить данные через входные параметры. Однако бывают случаи, когда он также должен предоставить функции-обработчики вместо реагирования на сгенерированное событие. Будем разбираться! 

Тестовые объекты 

Для начала нужен простой дочерний компонент в двух вариантах. В одном из них он генерирует событие для родителя (назовем его вариант emit), а в другом  —  принимает функцию-обработчик в качестве входного параметра (вариант handler). 

<template>
<div>
<button class="btn" @click="onClick">Click me!</button>
</div>
</template>

<script setup lang="ts">
const emit = defineEmits<{
(e: "button-click"): void;
}>();

function onClick() {
emit("button-click");
}
</script>
<template>
<div>
<button class="btn" @click="onClick">Click me!</button>
</div>
</template>

<script setup lang="ts">
const props = defineProps<{
clickHandler: () => void;
}>();

function onClick() {
props.clickHandler();
}
</script>

Как видно, главное отличие содержится в строке 13 (в обоих фрагментах кода). В первом варианте компонент генерирует событие, а во втором  —  выполняет переданную функцию. 

Сравнительный анализ 

Сравним 2 представленных варианта по следующим показателям: 

  • сложность/многословность дочернего компонента; 
  • сложность/многословность родительского компонента;
  • модульное тестирование; 
  • производительность приложения. 

Сложность дочерних компонентов

По данному показателю оба компонента равны и выглядят просто. Уровень сложности и многословности низкий. Код обоих компонентов понятен с первого взгляда. Счет между кнопкой emit и кнопкой handler составляет 1–1. 

Сложность родительских компонентов

<template>
<div>
<BaseButtonEmit @button-click="onButtonClick()" />
</div>
</template>

<script setup lang="ts">
import BaseButtonEmit from "@/components/BaseButtonEmit.vue";

function onButtonClick() {
console.log("clicked");
}
</script>
<template>
<div>
<BaseButtonHandler :click-handler="onButtonClick" />
</div>
</template>

<script setup lang="ts">
import BaseButtonHandler from "@/components/BaseButtonHandler.vue";

function onButtonClick() {
console.log("clicked");
}
</script>

У обоих родительских компонентов одна и та же функция. Единственное отличие заключается в строке 3. В первом варианте там значится обработчик события, а во втором  —  входной параметр. Сложность и многословность остаются на низком уровне. Счет по-прежнему равный: 2–2. 

Модульное тестирование

Тесты дочерних компонентов:

import { mount } from "@vue/test-utils";
import BaseButtonEmit from "@/components/BaseButtonEmit.vue";

describe("BaseButtonEmit", () => {
it("should emit event", () => {
const wrapper = mount(BaseButtonEmit);

wrapper.find("button").trigger("click");

expect(wrapper.emitted()["button-click"]).toBeTruthy();
});
});
import { mount } from "@vue/test-utils";
import BaseButtonHandler from "@/components/BaseButtonHandler.vue";

describe("BaseButtonHandler", () => {
it("should execute function", () => {
const clickHandler = jest.fn();
const wrapper = mount(BaseButtonHandler, { props: { clickHandler } });

wrapper.find("button").trigger("click");

expect(clickHandler).toHaveBeenCalled();
});
});

Согласно этим модульным тестам, код сгенерированного события выглядит немного проще. В целом оба варианта равнозначны, но для большей точности мы должны прописать дополнительный шаг определения функции jest. В результате счет становится 3–2 в пользу варианта emit.

Тесты родительских компонентов:

import { mount } from "@vue/test-utils";
import EmitView from "@/views/EmitView.vue";

describe("EmitView", () => {
it("should execute function on event", () => {
const stubs = {
BaseButtonEmit: {template: '<div></div>'},
};
const wrapper = mount(EmitView, {
global: {
stubs,
},
});
const spy = jest.spyOn(wrapper.vm, "onButtonClick");

wrapper.findComponent(stubs.BaseButtonEmit).vm.$emit("button-click");

expect(spy).toHaveBeenCalled();
});
});
import { mount } from "@vue/test-utils";
import HandlerView from "@/views/HandlerView.vue";

describe("HandlerView", () => {
it("should pass function as prop", () => {
const stubs = {
BaseButtonHandler: { template: "<div></div>", props: ["clickHandler"] },
};
const wrapper = mount(HandlerView, {
global: {
stubs,
},
});

const childPropValue = wrapper
.findComponent(stubs.BaseButtonHandler)
.props("clickHandler");

expect(childPropValue).toEqual(wrapper.vm.onButtonClick);
});
});

Тесты отражают последовательности из одинаковых шагов до момента монтирования родительского компонента. В варианте emit мы принуждаем дочерний компонент генерировать событие и с помощью spy (шпиона) проверяем, вызывается ли соответствующая функция. В варианте handler снабжаем заглушку дочернего компонента полем props и проверяем, является ли новый входной параметр ожидаемым. В этом пункте опять ничья. Счет составляет 4-3. 

Производительность 

Перед проверкой результатов поразмышляем на тему различий между компонентами. В первом случае дочерний компонент генерирует пользовательское событие, его родитель слушает и выполняет функцию. Данный тип привязки события, вероятно, реализуется посредством шаблона “Наблюдатель” (англ. Observer). Он не добавляет дополнительного слушателя события, поскольку это не событие DOM. Во втором случае дочерний компонент требует дополнительный входной параметр. Входные параметры обладают реактивностью, механизм которой реализуется с помощью объекта Proxy

Какой их этих вариантов дороже? Чтобы это выяснить, добавляем одну магическую строку в оба родительских компонента: 

v-for="i in 300_000" :key="i"

Далее создаем продакшн-сборку приложения и размещаем ее на сервере NGINX или Live Server. Сначала открываем окно в режиме приватного просмотра, без всяких расширений, затем открываем Dev Tools и проверяем производительность монитора: 

Генерация пользовательского события 
Использование функции в качестве входного параметра 

Как видно, в обоих случаях у нас примерно 300 000 слушателей событий и примерно 1 200 000 узлов DOM (по результатам после сборки мусора). Явно заметна разница в размере кучи JS (JS heap size), которая составляет около 100 MB в пользу варианта handler. По результатам анализа производительности безоговорочно побеждает вариант handler.

Сам я предпочитаю генерировать пользовательские события, так как поддерживаю разделение таких процессов, как передача данных через входные параметры и реагирование на генерируемые события с помощью функций. Но выявленная разница  —  это весомый аргумент, чтобы начать придерживаться второго подхода и максимально пропускать пользовательские события. 

Код предоставлен по ссылке

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Stavros Droutsas: Event handling done right

Предыдущая статьяPython 4.0: программирование следующего поколения
Следующая статьяСопоставление LiveData, SingleLiveEvent и MediatorLiveData в Android