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

jchwst6a3nwlxlscnmuw5tdhmry.png

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

В этой небольшой заметке я хочу рассказать вам об одной интересной особенности порталов в 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
            
              
               setModalOpen(false)} open={isModalOpen}>
                Modal title
                
                  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.
                
              
            
          
        
        
          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 (разметку):


jym4h9_v556wxomwldrzmfutwyk.png

Кнопка является потомком коллапса — все ок.

Но:


opjuymcn5fdtygcnjnyt9wmi-k8.png

Модалка действительно рендерится в портале и является прямым потомком body! Каким же чудесным образом клик по ней может всплыть до коллапса, если он, являясь потомком

, находится на уровень ниже, чем модалка? Ответ кроется во внутренних особенностях работы 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:


nhept9xxihsehnct7bcc8pauw0s.png

Тоже самое мы увидим, если «законсолим» компонент App:

if (isModalOpen) {
  console.log(App)
}


xfc28uxu5rarbpi5as61ulq03t4.png

К счастью, рассматриваемый баг легко фиксится:

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
            {/* Блокируем распространение (всплытие) события клика */}
             {
                e.stopPropagation()
              }}
            >
              
               setModalOpen(false)} open={isModalOpen}>
                Modal title
                
                  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.
                
              
            
          
        
        
          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.
        
      
    
  )
}


MDN. Event.stopPropagation ().

Песочница:


Случаи использования (преимущества и недостатки) всплытия событий в порталах React.

Пожалуй, это все, чем я хотел с вами поделиться в этой заметке. Happy coding!



Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

u9vgio3hxj12h5u7j3un0wx_zpk.png

© Habrahabr.ru