React: одна любопытная особенность порталов
Привет, друзья!
В этой небольшой заметке я хочу рассказать вам об одной интересной особенности порталов в React, которую я долгое время упускал из виду, но которая на днях привела к любопытному багу. Речь идет о том, что структурно дерево React не всегда соответствует DOM.
Полагаю, статья будет интересна всем разработчикам React, а также тем, кто любит разбираться с тонкостями работы JavaScript и браузерных API.
Предполагается, что вы имеете некоторый опыт работы с React, и вам не надо объяснять, что такое порталы и для чего они нужны.
Рассмотрим следующий код:
import { ExpandMore } from '@mui/icons-material'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
Typography,
} from '@mui/material'
import { useState } from 'react'
export default function App() {
const [isModalOpen, setModalOpen] = useState(false)
return (
{
console.log('changed')
}}
>
}>
Accordion title
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ut dicta
repellendus, aliquid quos vitae libero consectetur animi, fuga, veniam
suscipit vero perferendis repellat maiores molestiae.
)
}
У нас есть аккордеон (или коллапс), в верхней панели которого рендерится заголовок, кнопка для открытия модалки и сама модалка (ну и иконка), а в нижней панели — какой-то текст. В чем проблема данного кода?
Песочница:
Видите, что происходит? Мало того, что при клике по кнопке открывается не только модалка (как ожидается), но и контент коллапса (что не очень приятно), так еще и клик по содержимому модалки и ее оверлею приводит к изменению состояния видимости контента коллапса и, как следствие, вызову onChange()
коллапса (что совсем неприятно). Почему так происходит?
С кнопкой все более-менее ясно — клик по ней всплывает (bubble) до обработчика клика коллапса, который вызывает onChange()
:
{
console.log('changed')
}}
onClick={() => {
console.log('clicked')
}}
>
JSR. Всплытие и погружение.
Неужели тоже самое происходит с кликом по модалке? Проверим:
{
console.log('changed')
}}
onClick={(e) => {
console.log(e.eventPhase)
}}
>
MDN. Event.eventPhase.
Вывод в консоли:
changed
3
3
означает константу Event.BUBBLING_PHASE
, т.е. клик по модалке, как и клик по кнопке, всплывает до обработчика клика коллапса. Интуиция и опыт подсказывают, что тут что-то не так (пс, модалка рендерится в портале: D).
Как известно, событие всплывает от потомка к родителю. Взглянем на DOM (разметку):
Кнопка является потомком коллапса — все ок.
Но:
Модалка действительно рендерится в портале и является прямым потомком Из официальной документации React (которую, как оказалось, я читал недостаточно внимательно : D): Портал меняет только физическое расположение узла DOM. В остальном, JSX, который вы рендерите в портале, ведет себя как потомок узла компонента React, который рендерит портал. Например, потомок имеет доступ к контексту, предоставляемому родительским деревом, а события всплывают (!) от потомков к предкам в соответствии с их расположением в дереве React. Таким образом, несмотря на то, что в DOM портал является прямым потомком Если воспользоваться расширением для Chrome React Developer Tools и открыть вкладку «Components», можно убедиться, что модалка является потомком коллапса и находится на одном уровне с кнопкой в дереве React: Тоже самое мы увидим, если «законсолим» компонент К счастью, рассматриваемый баг легко фиксится: Песочница: Пожалуй, это все, чем я хотел с вами поделиться в этой заметке. Happy coding! body
! Каким же чудесным образом клик по ней может всплыть до коллапса, если он, являясь потомком A portal only changes the physical placement of the DOM node. In every other way, the JSX you render into a portal acts as a child node of the React component that renders it. For example, the child can access the context provided by the parent tree, and events bubble up from children to parents according to the React tree.
body
, в дереве React он является потомком коллапса. Поэтому клик по модалке всплывает до обработчика клика коллапса и вызывается onChange()
.App
: if (isModalOpen) {
console.log(App)
}
import { ExpandMore } from '@mui/icons-material'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
Typography,
} from '@mui/material'
import { useState } from 'react'
export default function App() {
const [isModalOpen, setModalOpen] = useState(false)
return (
MDN. Event.stopPropagation ().
Случаи использования (преимущества и недостатки) всплытия событий в порталах React.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩