Всплытие, отмена и остановка в React
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части II всплытие вы прошли руками: клик по кнопке добирался до контейнера, делегирование ловило его через closest, а чужой stopPropagation() глушил аналитику на document. В React обработчик пишется прямо на элементе, и кажется, что всё это осталось позади. Не осталось: событие так же идёт по дереву вверх, а e.stopPropagation() и e.preventDefault() делают ровно то же самое. Изменилось одно — где висит настоящий слушатель, и без этого знания код части II и React начинают глушить друг друга.
Кнопка внутри карточки, карточка внутри списка, на всех трёх — onClick. В части II с addEventListener вы бы ответили не задумываясь: сначала цель, потом её предки по очереди. Но onClick в JSX — не addEventListener, так что запишите прогноз: сколько строк напечатает клик по кнопке и в каком порядке.
Три строки, от цели вверх — как в части II. React не придумывал свою модель событий: он по-своему подписывается на браузерную (об этом дальше), а путь у события прежний. Поля тоже прежние: target — где клик случился, и он не меняется на всём пути; currentTarget — чей обработчик работает сейчас.
Компоненты для всплытия прозрачны. Если кнопка живёт в компоненте TaskButton, а <ul onClick> — в родительском Board, клик всё равно дойдёт до списка: React смотрит на дерево элементов, а не на границы функций. Родителю не нужно ничего знать о содержимом ребёнка, чтобы получать его клики.
Обработчик получает не браузерный объект события, а обёртку React — синтетическое событие. Поля и методы у неё те же, а браузерный оригинал лежит в e.nativeEvent.
Отличие, которое подсунет компилятор: в типах React currentTarget — точно тот элемент, на котором стоит обработчик, а target — всего лишь EventTarget.
«Свойство "id" не существует в типе "EventTarget"» — компилятор прав: кликнуть могли по кнопке, по тексту, по картинке внутри, и тип не в силах угадать, по чему именно. Это тот же target из части II, только честно типизированный. Сужайте его, как учила часть III: if (e.target instanceof HTMLElement) — и внутри ветки есть и id, и closest. А если нужен элемент с обработчиком, берите currentTarget: он уже типизирован.
Самая частая причина остановить всплытие — вложенные интерактивные элементы. Карточка задачи открывается по клику, а внутри неё кнопка «удалить». Без остановки клик по кнопке удалит задачу и тут же откроет её карточку: оба обработчика стоят на пути события, оба сработают.
Одна строка вместо двух. e.stopPropagation() у синтетического события делает ровно то, чего вы ждёте от него после части II: обрывает путь вверх, и onClick карточки не вызывается. Предупреждение оттуда тоже в силе: остановка глушит всех, кто выше, — не только карточку, но и document, где могла жить аналитика или «закрыть по клику снаружи». Почему так — в разделе про корень.
Когда оба обработчика ваши и стоят в одном файле, остановка — нормальное решение, документация React сама её советует. Спорной она становится, если событие ждут где-то ещё. Тогда работает рецепт части II — решать вопрос там, где он мешает: родитель сам проверяет, откуда пришёл клик.
stopImmediatePropagation у синтетического события нет — и не нужен: на элементе один onClick, глушить соседей некого. Почему обработчик на элементе один, разбирала предыдущая статья главы.
Отмена действия браузера — отдельный вопрос, и React его не переосмыслял. Но здесь есть ловушка для тех, кто помнит старый атрибут onclick или jQuery: там переход отменял return false. Напишите так в React — и предскажите, куда денется страница после клика.
Переход состоялся, а компилятор промолчал. React не читает, что вернул обработчик, и типы здесь не помощник: обработчик обязан возвращать void, а функцию с boolean на место void ставить можно — это статья «void: почему колбэк может вернуть что угодно» из части III. Единственный способ отменить действие — тот же, что в части II.
Флаг defaultPrevented тоже есть у синтетического события, и его видят обработчики выше по дереву. Это тот самый «флаг на объекте события» из статьи про stopPropagation: ребёнок отменил действие, родитель об этом узнал — и никто никого не глушил.
| Что нужно | Что писать | Чего это не делает |
|---|---|---|
| Не дать браузеру перейти по ссылке, отправить форму, поставить галочку | e.preventDefault() | Не мешает обработчикам родителей: событие идёт дальше |
Не дать сработать onClick родителей | e.stopPropagation() | Не отменяет действие браузера; заодно глушит слушатели выше корня React |
| Узнать, отменил ли кто-то действие ниже | e.defaultPrevented | Ничего не меняет — это только флаг |
| Отменить переход по-старому | return false | Ничего: React возврат обработчика не читает |
Самый частый preventDefault в React стоит в onSubmit: иначе браузер отправит форму и перезагрузит страницу вместе с состоянием всех компонентов. Что делать с полями дальше — тема двух следующих статей.
В проекте части II вы повесили один обработчик на контейнер доски (статья «Рендер из состояния и делегирование») и разбирали клики через closest — подписывать каждую карточку заново после каждой перерисовки было невыносимо. React решает ту же проблему тем же способом, только оптом: в момент createRoot он вешает на контейнер по браузерному слушателю на каждое событие, которое умеет. Это уже проверено переносом кнопки из-под корня в статье «Обработчики в JSX против addEventListener»; здесь важно другое — где слушатель корня стоит в очереди. onClick в JSX — не подписка на кнопке, а запись в пропсах, которую React находит, когда клик всплыл до корня.
Проверить это можно старыми средствами. Повесьте на кнопку и на document обычные addEventListener и предскажите, где в этой очереди окажется onClick.
onClick сработал после слушателя на кнопке и до слушателя на document — ровно там, где стоит #root. Путь клика по странице с React выглядит так:
| Где событие | Что срабатывает |
|---|---|
| кнопка | её собственные addEventListener |
предки внутри #root | их собственные addEventListener |
#root | все onClick из JSX, от цели к корню |
document, window | слушатели страницы, аналитика, «закрыть по клику снаружи» |
Делегирование через closest в React обычно не нужно: onClick на каждом <li> — это не сотня подписок, а сотня функций в пропсах. Настоящий слушатель по-прежнему один.
Старые статьи и ответы на форумах уверяют, что React подписывается на document. Так было до 17-й версии, и это мешало: два React-приложения на одной странице делили одни слушатели, а stopPropagation() в обычном коде между кнопкой и document глушил React целиком. С React 17 слушатели живут на контейнере корня, и несколько корней не мешают ни друг другу, ни странице вокруг.
Раз настоящий слушатель на корне, любая остановка события между кнопкой и корнем лишит React события целиком. Так ведёт себя чужой виджет внутри вашей разметки или старый код части II, который вы ещё не переписали. Предскажите, что покажет счётчик.
Ноль. Слушатель на кнопке отработал первым и оборвал путь — до #root событие не дошло, и с точки зрения React клика не было. Ошибок в консоли нет, состояние не меняется: искать такое приходится по обработчикам, которые не вызвались вовсе.
В обратную сторону граница работает так же. e.stopPropagation() в обработчике React делает две вещи: помечает синтетическое событие остановленным, чтобы React не звал обработчики выше, и вызывает stopPropagation() у браузерного оригинала. Оригинал в этот момент уже на корне, так что дальше — на document и window — он не пойдёт.
Слушатель на document в фазе всплытия клика не увидел, а в фазе погружения — увидел: погружение проходит через document раньше, чем событие доберётся до кнопки и до корня React. Это тот же рецепт, которым часть II спасала аналитику от чужого stopPropagation(), — против React он работает так же.
У React есть и своя фаза погружения: к имени любого обработчика добавляется суффикс Capture — onClickCapture, onKeyDownCapture. Это аналог третьего аргумента addEventListener из части II: такой обработчик срабатывает по пути вниз, от корня к цели, раньше всех обычных.
React вызывает их из своего слушателя на корне в фазе погружения — то есть до того, как событие вообще доберётся до кнопки. Отсюда следствие, которое стоит предсказать: увидит ли onClickCapture клик, если чужой код на кнопке его глушит?
Одна строка из трёх. Обработчик погружения отработал до того, как чужой код получил событие; обработчики всплытия ждали клик на корне и не дождались. Поэтому onClickCapture на самом верхнем элементе приложения — подходящее место для аналитики и всего, что обязано видеть каждый клик, что бы ни делали с событием ниже.
Полный порядок одного клика: погружение через document → onXxxCapture от корня к цели → браузерные слушатели на кнопке и её предках → onXxx от цели к корню → всплытие через document и window.
- Событие в React проходит тот же путь, что в браузере: от цели вверх по предкам, сквозь границы компонентов; target и currentTarget значат то же, что в части II.
- e.stopPropagation() обрывает путь к родительским onClick и заодно глушит слушатели выше корня; e.preventDefault() отменяет действие браузера и никому не мешает; return false не делает ничего.
- Настоящие слушатели React висят на контейнере корня (с 17-й версии, раньше — на document): onClick из JSX срабатывает после addEventListener на самой кнопке и до слушателей на document.
- Чужой stopPropagation между кнопкой и корнем лишает React события целиком — без ошибок и без перерисовки.
- Обработчики с суффиксом Capture работают в фазе погружения, до кнопки и до чужого кода; слушатель на document с capture тоже видит клик, остановленный внутри React.
- В типах React currentTarget — точный элемент с обработчиком, а target — только EventTarget: сужайте его через instanceof.
1/4Кнопка лежит в <li>, тот — в <ul>; на всех трёх стоит onClick с console.log. В каком порядке они напечатают строки при клике по кнопке?
2/4На кнопке с React-onClick вдобавок висит обычный addEventListener("click"), ещё один — на document. Что сработает первым, вторым и третьим?
3/4Ссылка <a href="/tasks"> получила onClick={() => false}. Что скажет компилятор и что сделает браузер?
4/4Чужой код повесил на кнопку addEventListener("click", e => e.stopPropagation()). На родительском <div> стоят и onClick, и onClickCapture. Какие из них сработают?
Удалить, не открывая
Клик по карточке печатает «открыть», кнопка внутри — «удалить». Сейчас клик по кнопке печатает обе строки. Сделайте так, чтобы печаталась только «удалить», причём двумя способами: с остановкой всплытия и без неё.
Первый способ — оборвать путь события в обработчике кнопки:
Коротко, но вместе с карточкой клика лишатся и слушатели выше корня — например, аналитика на document. Второй способ — не глушить, а разбираться в карточке, откуда пришёл клик:
closest здесь надёжнее сравнения с HTMLButtonElement: если внутри кнопки появится иконка, целью клика станет она, а не кнопка. Сужение через instanceof Element нужно компилятору — у EventTarget метода closest нет.
Ссылка внутри приложения
Ссылка ведёт на #tasks-7. Обычный клик приложение обрабатывает само: печатает «переход: #tasks-7» и не даёт браузеру никуда идти. Клик с Ctrl или Cmd — «открыть в новой вкладке» — нужно оставить браузеру, как учил раздел «Отменять вежливо» статьи про preventDefault. Допишите обработчик.
Сначала проверка модификаторов — и выход без отмены, чтобы браузер сделал своё. Только потом preventDefault() и своя работа:
ctrlKey и metaKey лежат прямо на синтетическом событии, как и на браузерном. Адрес удобно брать из e.currentTarget.hash: тип HTMLAnchorElement в параметре обработчика даёт доступ к полям ссылки без всяких сужений.
Аналитика, которую не заглушить
Счётчик кликов на document должен учитывать каждый клик по странице. Компоненты трогать нельзя, а в них щедро расставлен e.stopPropagation(). Сейчас счётчик видит ноль. Исправьте одну строку вне компонентов.
Остановка в React глушит и браузерное событие, но только выше корня и только на пути вверх. Вниз событие проходит через document раньше кнопки, так что слушатель в фазе погружения увидит каждый клик:
Это ровно тот приём, которым часть II защищала аналитику от чужого stopPropagation(). Внутри приложения то же самое даёт onClickCapture на корневом элементе.