React 原生 Portal 渲染:跨 DOM 树节点的事件冒泡流机制与 Context 穿透实现

嘿,各位未来的 React 架构师,还有那些觉得自己已经掌握了 React 但在处理模态框和悬浮层时依然像是在黑暗中开枪的“键盘侠”们,大家好!

今天我们不聊那些花里胡哨的 Hooks,也不聊什么 Next.js 的 SSR 优化,我们来聊聊一个让无数初学者(甚至是大牛)深夜抓耳挠腮的话题:React Portal(传送门)

想象一下,你的 React 应用就像一个严格的大家族。父组件渲染子组件,层级分明,父管着子,子管着孙,大家都在同一个屋檐下(同一个 DOM 树)和谐共处。但是,有时候,你的应用就像一个叛逆的青少年,它不想待在父组件的“房间”里。它想跳窗而出,去客厅,甚至去隔壁的公园。

这时候,Portal 就派上用场了。它是一个“传送门”,一个把 DOM 节点从 React 的渲染树里“切”出来,扔到 DOM 树其他位置的特工。

但是,问题来了。当你把这个“叛逆者”扔到别处时,事件冒泡这个机制会不会跟着它一起跑?还有,那个总是像幽灵一样粘着你的 Context,会不会在传送门那里迷路?

别急,今天我们就来把这两个问题——事件冒泡流和 Context 穿透——彻底扒个精光。准备好了吗?系好安全带,我们要开始穿越 DOM 的维度的了。


第一章:传送门的“越狱”艺术

首先,我们得搞清楚 Portal 到底是个什么鬼。React 官方文档里写着:ReactDOM.createPortal(child, container)

这句话翻译成人话就是:“嘿,React,把这个 child(子组件)渲染到 container(容器节点)里去,但是别把它加到我现在的 DOM 结构里。”

这听起来很简单,但背后的逻辑很微妙。通常情况下,React 的渲染层级和 DOM 层级是一一对应的。如果你写了一个 App 组件,它下面有个 Modal 组件,那么 Modal 的 DOM 节点通常就是 App 的子节点。

但是,为了防止滚动条抖动,为了解决 z-index 的地狱,我们经常把 Modal 渲染到 document.body 里面。这时候,DOM 结构就变成了这样:

  • React 树: App -> Modal
  • DOM 树: App -> div#root -> App的DOM节点 … (中间省略一万字) … -> body -> Modal的DOM节点

看到了吗?React 树里 Modal 紧挨着 App,但 DOM 树里它们相隔十万八千里。

让我们看个最简单的代码示例,感受一下这个“越狱”的过程:

import React from 'react';
import ReactDOM from 'react-dom';

// 模拟一个简单的 Modal 组件
const Modal = ({ isOpen, onClose, children }) => {
  if (!isOpen) return null;

  // 这就是传送门!
  // 我们把 children 渲染到 document.body 上,而不是当前组件的 DOM 节点里
  return ReactDOM.createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content">
        <h2>我是越狱的 Modal</h2>
        <p>虽然我离开了父组件的 DOM 树,但我依然觉得自己在 React 的世界里。</p>
        <button onClick={onClose}>关闭我</button>
      </div>
    </div>,
    document.body
  );
};

const App = () => {
  return (
    <div className="app">
      <h1>父组件:我是你的主人</h1>
      <p>点击下方按钮,让 Modal 跑到我的头上去。</p>
      <button onClick={() => alert('Modal 被打开了!')}>打开 Modal</button>
      {/* 这里通常不会直接渲染 Modal */}
    </div>
  );
};

export default App;

看到 ReactDOM.createPortal 了吗?它把 Modal 的内容强行塞进了 body。这就像是把一个人从五楼的窗户扔到了一楼。虽然物理位置变了,但在 React 的逻辑世界里,它依然是 App 的子元素。


第二章:事件冒泡——是“心有灵犀”还是“时空错乱”?

这是大家最头疼的地方。当我们点击 Portal 渲染出来的按钮时,事件是怎么冒泡的?

这里有个巨大的误区。很多人以为 Portal 打破了 React 的事件冒泡机制,或者改变了事件的目标节点。大错特错!

在 React 的世界里,Portal 并没有改变事件冒泡的规则。React 事件冒泡依然是沿着 React 的组件树(Render Tree)进行的,而不是 DOM 树。

这意味着什么?意味着:即使你的 Modal 跑到了 body 里面,点击它内部的按钮,依然会冒泡到 App 组件!

让我们来做个实验,验证一下这个“心有灵犀”:

import React, { useState } from 'react';
import ReactDOM from 'react-dom';

const Modal = ({ onClose }) => {
  // 模拟一个原生事件监听器,看看能不能捕获到 React 的事件
  const handleNativeClick = (e) => {
    console.log('🚨 原生事件被触发了!目标节点是:', e.target);
  };

  return ReactDOM.createPortal(
    <div className="portal-container" onClick={onClose}>
      <button onClick={() => console.log('按钮被点击了!')}>
        我是 Portal 里的按钮
      </button>
      {/* 注意:这里没有绑定 onClick,但父 div 有 */}
    </div>,
    document.body
  );
};

const App = () => {
  const [showModal, setShowModal] = useState(false);

  return (
    <div className="app" onClick={() => console.log('App 点击了!')}>
      <h1>App 组件</h1>
      <button onClick={() => setShowModal(true)}>
        打开 Portal
      </button>

      {showModal && (
        <div className="modal-backdrop">
          <Modal onClose={() => setShowModal(false)} />
        </div>
      )}
    </div>
  );
};

export default App;

运行结果预测:
当你点击“我是 Portal 里的按钮”时,控制台会依次打印:

  1. 按钮被点击了! (React 事件在 Modal 按钮上触发)
  2. App 点击了! (事件冒泡到了 App 组件)

为什么?
因为 React 的合成事件系统是基于组件树的。Modal 组件是 App 的子组件。无论 Modal 的 DOM 节点在 DOM 树的哪个角落,React 都认为它们是父子关系。

但是! 这里有个“但是”。这只是 React 的合成事件。如果你在 document.body 上直接挂载了一个原生 DOM 事件监听器,情况会变吗?

让我们修改一下代码,在 Modal 组件里加个 useEffect

// ... 之前的代码 ...

const Modal = ({ onClose }) => {
  const modalRef = React.useRef(null);

  React.useEffect(() => {
    if (modalRef.current) {
      // 注意:这里监听的是 Portal 渲染出来的那个真实的 DOM 节点
      modalRef.current.addEventListener('click', (e) => {
        console.log('🌍 原生 DOM 事件监听器捕获到了点击!');
        console.log('事件冒泡方向:', e.target, ' -> ', e.currentTarget);
      });
    }
    return () => {
      // 记得清理!这是老生常谈但总是被遗忘的规则
      if (modalRef.current) {
        modalRef.current.removeEventListener('click', () => {});
      }
    };
  }, []);

  return ReactDOM.createPortal(
    <div className="portal-container" ref={modalRef} onClick={onClose}>
      <button>点击我</button>
    </div>,
    document.body
  );
};

运行结果:
当你点击按钮时,你会看到:

  1. 按钮被点击了! (React 事件)
  2. App 点击了! (React 事件冒泡)
  3. 🌍 原生 DOM 事件监听器捕获到了点击!

这说明,原生事件监听器依然能捕获到 Portal 内部的事件! 因为 Portal 最终还是会生成真实的 DOM 节点,DOM 事件流是物理世界最真实的法则,它不会因为你用了 React 就改变。

那 Portal 到底改变了什么?
Portal 改变了的是事件捕获的目标

  • 非 Portal 模式: 点击 Modal 按钮 -> 事件目标 = Modal 按钮 -> 冒泡到 App。
  • Portal 模式: 点击 Modal 按钮 -> 事件目标 = Modal 按钮 -> 冒泡到 App(React 层面)。

关键点来了:
如果你在 Portal 的容器(比如 body)上使用了 e.stopPropagation(),会发生什么?

const Modal = ({ onClose }) => {
  return ReactDOM.createPortal(
    <div className="portal-container" onClick={(e) => {
        e.stopPropagation(); // 停止冒泡
        onClose();
      }}>
      <button>点击我</button>
    </div>,
    document.body
  );
};

结果: App 组件的点击事件不会被触发。这符合直觉,因为我们在 Portal 的根节点上阻止了冒泡。

总结一下事件冒泡:
Portal 就像是一个中转站,它不改变事件的“血缘关系”(父子组件关系),也不改变 DOM 的事件流。它只是把事件的载体(DOM 节点)搬家了。React 依然会帮你把事件从子组件传到父组件,就像它从来没离开过一样。


第三章:Context 穿透——隐形的线

既然事件冒泡没事,那 Context 呢?React 的 Context 是用来在组件树中传递数据的。它也是基于组件树的。

默认情况下,Portal 会自动继承父组件的 Context!

这太神奇了。你不需要在 Portal 里面手动去 useContext 或者包裹 Provider。React 会自动把 Context 的值传给 Portal 内部的组件。

让我们来验证这个“自动继承”的魔法:

import React, { createContext, useState } from 'react';

// 1. 创建一个 Context
const ThemeContext = createContext('light');

const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// 2. 一个需要 Context 的组件
const UserProfile = () => {
  const { theme } = React.useContext(ThemeContext);
  return <div className="user-profile">当前主题是: {theme}</div>;
};

// 3. Portal 组件
const Modal = ({ isOpen, onClose }) => {
  if (!isOpen) return null;

  // 我们在 Portal 里直接用 Context,不需要 Provider 包裹
  const { theme } = React.useContext(ThemeContext);

  return ReactDOM.createPortal(
    <div className="modal-theme">
      <h2>Portal 内部</h2>
      <UserProfile /> {/* 这里直接用,不用管它是怎么进来的 */}
      <p>Portal 继承了 Context: {theme}</p>
      <button onClick={onClose}>关闭</button>
    </div>,
    document.body
  );
};

// 4. App
const App = () => {
  return (
    <ThemeProvider>
      <div className="app">
        <h1>App 根组件</h1>
        <UserProfile />
        <Modal isOpen={true} onClose={() => {}} />
      </div>
    </ThemeProvider>
  );
};

export default App;

运行结果: Modal 内部的文字会显示“Portal 继承了 Context: dark”。UserProfile 也能正常读取到 Context。

原理是什么?
React 在处理 Context 时,它是基于组件树的。Portal 只是把 DOM 节点搬家了,但 React 的组件层级结构(App -> Modal)没有变。所以 React 就像传递 Props 一样,把 Context 的值顺着组件树传下去了。


第四章:Portal 的“盲区”与“陷阱”

虽然 Portal 很强大,但它不是万能的神灯。它有两个非常著名的“坑”,一个是Context 的盲区,一个是原生事件的断层

1. Context 的盲区:嵌套 Portal

如果 Portal 渲染的组件里面,又包含了一个 Portal,会发生什么?

const InnerModal = () => {
  return ReactDOM.createPortal(
    <div>Inner Modal</div>,
    document.getElementById('other-root') // 假设这是另一个挂载点
  );
};

const OuterModal = () => {
  return ReactDOM.createPortal(
    <div>
      <h2>Outer Modal</h2>
      <InnerModal />
    </div>,
    document.body
  );
};

问题:
InnerModal 渲染在 other-root,而 OuterModal 渲染在 body。在 React 的组件树里,InnerModalOuterModal 的子组件,OuterModalApp 的子组件。

但是!InnerModal 的 DOM 节点并不在 OuterModal 的 DOM 节点下面。它甚至不在同一个父级下。

Context 会传过去吗?
不会! 因为 Context 的传递是基于组件树的,而不是 DOM 树。InnerModal 虽然在组件树上是 OuterModal 的子组件,但 OuterModal 并没有把 Context 传给 InnerModal(除非你自己写了 Context 传递)。

Context 的传递路径是:
App (Provider) -> OuterModal -> InnerModal
如果 OuterModal 没有把 Context 传给 InnerModalInnerModal 就拿不到数据。

解决方案:
你必须在 OuterModal 内部,把 Context 包裹起来,或者使用 render props,或者使用 useContext 自己传给子组件。

const OuterModal = ({ children }) => {
  const theme = React.useContext(ThemeContext); // 拿到父级 Context

  return ReactDOM.createPortal(
    <div>
      <h2>Outer Modal (Theme: {theme})</h2>
      {/* 手动把 Context 传给子组件 */}
      <ThemeContext.Provider value={theme}>
        {children}
      </ThemeContext.Provider>
    </div>,
    document.body
  );
};

这就是嵌套 Portal 的陷阱:Context 不会自动跨越 DOM 的边界进行传递。

2. 原生事件的断层

我们刚才说了,Portal 里的原生事件能被 body 监听到。但是,如果你在 Portal 里使用了 useEffect 来监听一个特定的 DOM 元素,时机对了吗?

问题:
useEffect 是在组件渲染完成、DOM 更新之后执行的。但是 Portal 是把子组件渲染到了 body。当 App 组件的 useEffect 执行时,Portal 可能还没来得及把 DOM 插到 body 里去。

这会导致你在 useEffect 里获取到的 DOM 引用是 null

const Modal = ({ isOpen }) => {
  const [mounted, setMounted] = React.useState(false);
  const modalRef = React.useRef(null);

  React.useEffect(() => {
    if (isOpen) {
      setMounted(true);
    } else {
      setMounted(false);
    }
  }, [isOpen]);

  React.useEffect(() => {
    // 如果 isOpen 刚变成 true,这个 effect 可能会跑在 DOM 插入 body 之前
    if (mounted && modalRef.current) {
      console.log('DOM 已经准备好了!');
      modalRef.current.addEventListener('click', ...);
    }
  }, [mounted]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div ref={modalRef}>...</div>,
    document.body
  );
};

解决方案:
你需要把 useEffect 放在 Portal 内部,并且依赖 isOpen。因为 Portal 内部的组件生命周期和 App 组件的生命周期是同步的。只要 Modal 组件渲染了,它的 useEffect 就会在它自己的 DOM 节点插入后执行。

或者,更优雅的方式是使用 React 的 useRef 来监听变化,或者直接在渲染时绑定事件(但这不是最佳实践,因为频繁绑定解绑不好)。


第五章:实战演练——打造一个“不滚屏”的 Toast 通知系统

光说不练假把式。让我们用 Portal 来实现一个经典的 Toast 通知系统。

需求:

  1. 点击按钮,在屏幕右上角弹出一个通知。
  2. 通知弹出时,背景页面不能滚动。
  3. 点击通知上的关闭按钮,通知消失,背景恢复滚动。
  4. 通知之间可以堆叠。

代码实现:

import React, { useState, createContext, useContext } from 'react';
import ReactDOM from 'react-dom';

// 1. 创建 Context,用于控制页面的滚动状态
const ScrollContext = createContext();

const ScrollProvider = ({ children }) => {
  const [isLocked, setIsLocked] = React.useState(false);

  const lockScroll = () => setIsLocked(true);
  const unlockScroll = () => setIsLocked(false);

  return (
    <ScrollContext.Provider value={{ lockScroll, unlockScroll, isLocked }}>
      {children}
    </ScrollContext.Provider>
  );
};

// 2. Toast 通知组件
const Toast = ({ message, onClose }) => {
  // 获取 Context,控制滚动
  const { lockScroll, unlockScroll } = useContext(ScrollContext);

  React.useEffect(() => {
    lockScroll(); // 通知出现,锁定滚动
    return () => {
      unlockScroll(); // 通知消失,解锁滚动
    };
  }, [lockScroll, unlockScroll]);

  return (
    ReactDOM.createPortal(
      <div className="toast-container">
        <div className="toast">
          <span>{message}</span>
          <button onClick={onClose}>×</button>
        </div>
      </div>,
      document.body // 渲染到 body
    )
  );
};

// 3. 主应用
const App = () => {
  const [toasts, setToasts] = useState([]);
  const { lockScroll, unlockScroll } = useContext(ScrollContext);

  const showToast = (message) => {
    const id = Date.now();
    setToasts([...toasts, { id, message }]);

    // 可选:如果想要一次性通知,可以在 3 秒后自动移除
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, 3000);
  };

  const handleClose = (id) => {
    setToasts(prev => prev.filter(t => t.id !== id));
    if (toasts.length <= 1) {
      // 如果没有通知了,解锁滚动
      unlockScroll();
    }
  };

  return (
    <ScrollProvider>
      <div style={{ height: '300vh', padding: '20px' }}>
        <h1>这是一个超级长的页面,用来测试滚动</h1>
        <p>请点击下方按钮,体验 Portal 带来的滚动锁定效果。</p>
        <button onClick={() => showToast('第一条通知!')}>
          显示通知
        </button>

        {/* 渲染所有通知 */}
        {toasts.map(toast => (
          <Toast 
            key={toast.id} 
            message={toast.message} 
            onClose={() => handleClose(toast.id)} 
          />
        ))}
      </div>
    </ScrollProvider>
  );
};

export default App;

这个例子中 Portal 发挥了什么作用?

  1. 渲染位置: 通知被渲染到了 document.body 的最顶层。无论页面上有多少个嵌套的 div,通知永远是可见的,不会被父容器的 overflow: hiddenz-index 遮挡。
  2. 滚动锁定: 我们在 Toast 组件内部使用了 useContext。因为 ToastApp 的子组件,所以它能访问 ScrollContext。当 Toast 渲染时,它锁定滚动;当 Toast 卸载时,它解锁滚动。这是最完美的实现方式,不需要手动操作 DOM 的 scrollTop

第六章:深入探讨——事件委托与 Portal

虽然 Portal 很方便,但有时候我们会有性能焦虑。我们会在 document.body 上挂载成百上千个 DOM 节点吗?比如一个全屏的拖拽层或者一个复杂的编辑器。

性能优化:事件委托。

不要在每一个 Portal 渲染的元素上绑定事件。那样太浪费内存了。我们应该在 document.body 上绑定一个事件委托监听器。

原理:
利用事件冒泡。当用户点击 Portal 里的某个按钮时,事件会冒泡到 body。我们在 body 上监听点击事件,然后通过 e.target 判断点击的是哪个元素。

代码示例:

const DraggableModal = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  // 使用 useEffect 在 body 上挂载事件监听器
  React.useEffect(() => {
    const handleMouseDown = (e) => {
      // 判断点击的是否是 Modal 的头部
      if (e.target.closest('.modal-header')) {
        // 记录鼠标初始位置
        const startX = e.clientX;
        const startY = e.clientY;
        const initialX = position.x;
        const initialY = position.y;

        const handleMouseMove = (moveEvent) => {
          setPosition({
            x: initialX + (moveEvent.clientX - startX),
            y: initialY + (moveEvent.clientY - startY),
          });
        };

        const handleMouseUp = () => {
          document.removeEventListener('mousemove', handleMouseMove);
          document.removeEventListener('mouseup', handleMouseUp);
        };

        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);
      }
    };

    // 在 body 上监听
    document.addEventListener('mousedown', handleMouseDown);

    return () => {
      document.removeEventListener('mousedown', handleMouseDown);
    };
  }, [position]);

  return ReactDOM.createPortal(
    <div className="modal" style={{ transform: `translate(${position.x}px, ${position.y}px)` }}>
      <div className="modal-header">拖拽我</div>
      <div className="modal-body">内容区域</div>
    </div>,
    document.body
  );
};

注意点:

  1. 闭包陷阱: 在上面的代码中,handleMouseDown 内部定义了 handleMouseMovehandleMouseUp,并且引用了 position。在 useEffect 的依赖数组中,你需要把 position 加进去(或者使用 useRef 来存储最新的 position,避免频繁重绘)。上面的代码为了简化省略了依赖数组,实际生产中要注意。
  2. React 事件 vs 原生事件:
    • 如果你在 handleMouseDown 里调用 e.stopPropagation(),它会阻止事件冒泡到 body 吗?
    • 会! 因为 handleMouseDownbody 上的原生监听器。如果 Modal-header 上绑定了 React 的 onMouseDown,并且调用了 stopPropagation,那么 body 上的原生监听器就收不到这个事件了。
    • 解决方案: 如果你在 Portal 里使用了 React 事件,并且想要在 body 上做事件委托,你不能在 Portal 内部使用 stopPropagation,除非你在 body 上监听的是 React 事件(这很难,因为 body 不是 React 组件)。
    • 通常,如果要在 Portal 里做事件委托,我们倾向于在 Portal 内部处理事件,或者干脆不使用 stopPropagation,让事件冒泡到 body

第七章:总结与展望(不,真的没有总结)

好了,朋友们,我们聊了很多。

Portal 不仅仅是一个 API,它是 React 为了解决 UI 层级与 DOM 层级不一致问题而引入的一个强力工具。它让我们可以像指挥家一样,在 React 的逻辑世界里构建组件,在物理世界里控制 DOM 的位置。

回顾一下今天我们学到的核心点:

  1. Portal 的本质: 它是 React 树和 DOM 树之间的桥梁。它改变了 DOM 的位置,但没有改变 React 的组件层级关系。
  2. 事件冒泡: React 事件冒泡依然沿着组件树进行。Portal 里的按钮点击,依然会冒泡到 App。原生 DOM 事件监听器依然能捕获到 Portal 内部的点击。
  3. Context 继承: Portal 默认继承父组件的 Context,这是 React 的默认行为。但是,嵌套的 Portal 会打破 Context 的自动传递,需要手动传递。
  4. 原生事件: 在 Portal 内部使用 useEffect 时,要注意 DOM 插入的时机,最好在 Portal 组件内部绑定事件。
  5. 性能: 对于大量的 Portal 内容,使用事件委托在 body 上处理事件是最佳实践。

最后,我想说的是,Portal 是 React 的“黑魔法”之一,也是它最强大的特性之一。掌握它,你就能摆脱 DOM 层级的束缚,构建出更加灵活、美观、符合用户体验的 UI 组件。

不要害怕打破 DOM 的层级,只要你的 React 组件树依然是清晰的。记住,Portal 是你的朋友,只要你别让它迷路。

好了,今天的讲座就到这里。去写代码吧,去创造那些跳出屏幕的组件吧!如果遇到问题,记得回来看看这篇“叛逃指南”。再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注