Привет, друзья!
В этой небольшой заметке я хочу рассказать вам об одной интересной особенности порталов в 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 (
<Box>
<Accordion
onChange={() => {
console.log('changed')
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant='subtitle1'>Accordion title</Typography>
<Box>
<Button variant='contained' onClick={() => setModalOpen(true)}>
Open modal
</Button>
<Dialog onClose={() => setModalOpen(false)} open={isModalOpen}>
<DialogTitle>Modal title</DialogTitle>
<DialogContent>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Explicabo enim minus itaque necessitatibus quis amet nesciunt
iusto, placeat inventore reprehenderit possimus aperiam omnis
dolore aliquid.
</DialogContent>
</Dialog>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
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.
</AccordionDetails>
</Accordion>
</Box>
)
}
У нас есть аккордеон (или коллапс), в верхней панели которого рендерится заголовок, кнопка для открытия модалки и сама модалка (ну и иконка), а в нижней панели — какой-то текст. В чем проблема данного кода?
Песочница:
Видите, что происходит? Мало того, что при клике по кнопке открывается не только модалка (как ожидается), но и контент коллапса (что не очень приятно), так еще и клик по содержимому модалки и ее оверлею приводит к изменению состояния видимости контента коллапса и, как следствие, вызову onChange()
коллапса (что совсем неприятно). Почему так происходит?
С кнопкой все более-менее ясно — клик по ней всплывает (bubble) до обработчика клика коллапса, который вызывает onChange()
:
<Accordion
onChange={() => {
console.log('changed')
}}
onClick={() => {
console.log('clicked')
}}
>
JSR. Всплытие и погружение.
Неужели тоже самое происходит с кликом по модалке? Проверим:
<Accordion
onChange={() => {
console.log('changed')
}}
onClick={(e) => {
console.log(e.eventPhase)
}}
>
MDN. Event.eventPhase.
Вывод в консоли:
changed
3
3
означает константу Event.BUBBLING_PHASE
, т.е. клик по модалке, как и клик по кнопке, всплывает до обработчика клика коллапса. Интуиция и опыт подсказывают, что тут что-то не так (пс, модалка рендерится в портале :D).
Как известно, событие всплывает от потомка к родителю. Взглянем на DOM (разметку):
Кнопка является потомком коллапса — все ок.
Но:
Модалка действительно рендерится в портале и является прямым потомком body
! Каким же чудесным образом клик по ней может всплыть до коллапса, если он, являясь потомком <div id="root">
, находится на уровень ниже, чем модалка? Ответ кроется во внутренних особенностях работы React.
Из официальной документации React (которую, как оказалось, я читал недостаточно внимательно :D):
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.
Портал меняет только физическое расположение узла DOM. В остальном, JSX, который вы рендерите в портале, ведет себя как потомок узла компонента React, который рендерит портал. Например, потомок имеет доступ к контексту, предоставляемому родительским деревом, а события всплывают (!) от потомков к предкам в соответствии с их расположением в дереве React.
Таким образом, несмотря на то, что в DOM портал является прямым потомком body
, в дереве React он является потомком коллапса. Поэтому клик по модалке всплывает до обработчика клика коллапса и вызывается onChange()
.
Если воспользоваться расширением для Chrome React Developer Tools и открыть вкладку "Components", можно убедиться, что модалка является потомком коллапса и находится на одном уровне с кнопкой в дереве React:
Тоже самое мы увидим, если "законсолим" компонент 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 (
<Box>
<Accordion
onChange={() => {
console.log('changed')
}}
>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant='subtitle1'>Accordion title</Typography>
{/* Блокируем распространение (всплытие) события клика */}
<Box
onClick={(e) => {
e.stopPropagation()
}}
>
<Button variant='contained' onClick={() => setModalOpen(true)}>
Open modal
</Button>
<Dialog onClose={() => setModalOpen(false)} open={isModalOpen}>
<DialogTitle>Modal title</DialogTitle>
<DialogContent>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Explicabo enim minus itaque necessitatibus quis amet nesciunt
iusto, placeat inventore reprehenderit possimus aperiam omnis
dolore aliquid.
</DialogContent>
</Dialog>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
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.
</AccordionDetails>
</Accordion>
</Box>
)
}
MDN. Event.stopPropagation().
Песочница:
Случаи использования (преимущества и недостатки) всплытия событий в порталах React.
Пожалуй, это все, чем я хотел с вами поделиться в этой заметке. Happy coding!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
Комментарии (8)
devlev
29.07.2024 13:22+2Статья о том, что автор не внимательно читал документацию. Но потом автор прочитал документацию и узнал о stopPropagation(). Могу порекомендовать написать статью о preventDefault() и stopImmediatePropagation().
artptr86
29.07.2024 13:22Видите, что происходит?
Нет :) Почему-то ни в Chrome, ни в Firefox нормально проект не заработал. Ну то есть вроде как сервер запустился, но дальше был просто белый экран без каких-либо ошибок в консоли.
adminNiochen
29.07.2024 13:22То, что спрашивают на собесе у джунов и о чём явно написано в документации, стало открытием для автора статьи.
deamondz
Статью, кмк, надо было назвать "Как я прочитал инструкцию, только после того как всё сломалось")