Как стать автором
Обновить
1579.83
Timeweb Cloud
То самое облако

React: одна любопытная особенность порталов

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров1.6K



Привет, друзья!


В этой небольшой заметке я хочу рассказать вам об одной интересной особенности порталов в 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-канале

Теги:
Хабы:
+9
Комментарии5

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud