В эту ловушку я попадал уже много раз. В современных фронтенд-фреймворках есть масса родительских и дочерних компонентов. Родительский компонент передает данные через входные параметры (англ. 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
.
Сам я предпочитаю генерировать пользовательские события, так как поддерживаю разделение таких процессов, как передача данных через входные параметры и реагирование на генерируемые события с помощью функций. Но выявленная разница — это весомый аргумент, чтобы начать придерживаться второго подхода и максимально пропускать пользовательские события.
Код предоставлен по ссылке.
Читайте также:
- 27 важных однострочных функций JavaScript, используемых разработчиками ежедневно
- 8 советов работы с JavaScript, которые повысят ценность вашего кода
- Как обеспечить работу современного кода JavaScript во всех браузерах
Читайте нас в Telegram, VK и Дзен
Перевод статьи Stavros Droutsas: Event handling done right