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 (     <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-канале


ссылка на оригинал статьи https://habr.com/ru/articles/828816/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *