陌上人如玉
公子世无双

1.9 Fragment / Portal / StrictMode

1.9 Fragment / Portal / StrictMode

一句话:三个「特殊组件」分别解决三类问题——Fragment 解决「需要多个根节点又不想多一层 DOM」、Portal 解决「弹层需要脱离父级容器」、StrictMode 在开发环境暴露潜在的副作用问题。

一、Fragment:不产生额外 DOM 的包裹

// ❌ 多根节点会语法报错
return (
  

标题

内容 ); // ✅ 用容器包裹(但多了一层 DOM) return (

标题

内容
); // ✅ 用 Fragment(不产生额外 DOM 节点) import { Fragment } from 'react'; return (

标题

内容
); // ✅ 简写形式(不需要 key 时用这个) return ( <>

标题

内容 );

为什么重要:额外 DOM 层会带来三个副作用。

副作用 说明
破坏布局 在 flex / grid 容器里多一层包裹会改变布局(子元素变成了「包裹层」的元素)
破坏选择器 ul > li 这类选择器不再匹配
增加节点数 大量列表项时多一层包裹会显著增加 DOM 数量
// ❌ 经典错误:在 flex 容器里用 div 包裹
function Toolbar() {
  return (
    
{/* display: flex */} {items.map((it) => (
{/* ⚠️ 多了一层,flex 子项变成了这个 div */}
))}
); } // 如果想让 Icon 与 Text 各自成为 flex 子项,应该用 Fragment // ✅ function Toolbar2() { return (
{items.map((it) => ( ))}
); }

Fragment 的 key:

// ❌ 简写形式不能带 key
{items.map((it) => (
  <>...            // 无法传 key
))}

// ✅ 需要 key 时必须用完整写法
import { Fragment } from 'react';
{items.map((it) => (
  ...
))}

二、Portal:把子节点渲染到别处

import { createPortal } from 'react-dom';

function Modal({ open, onClose, children }) {
  if (!open) return null;

  return createPortal(
    
e.stopPropagation()}> {children}
, document.body // ⭐ 挂载到 body 下 ); }

为什么需要 Portal:

问题:弹层如果渲染在原来的位置,会遇到三类麻烦:
① 被祖先的 overflow: hidden / auto 裁掉
② 被祖先的 z-index / 层叠上下文困住(弹窗盖不住其它元素)
③ 被祖先的 transform 劫持(position: fixed 失效)  ← 见 CSS 章节

Portal 让弹层的 DOM 出现在 body 下,彻底摆脱祖先的样式影响。
组件 是否用 Portal
Modal / Dialog ✅
Tooltip / Popover ✅
Dropdown / Select 的下拉菜单 ✅
Toast / Notification ✅
DatePicker 的浮层 ✅
普通的条件渲染内容 ❌(不需要)

Portal 的三个关键特性:

① DOM 位置变了,但【React 组件树的位置没变】
   → props、Context 依然能正常传递(context 是沿组件树走的,不是 DOM 树)

② 事件依然会【按 React 树冒泡】
   → 在 Modal 内部点击,外层的 onClick 依然会被触发
   (这是与「原生事件按 DOM 树冒泡」不同的一点,容易踩坑)

③ Portal 里的元素不在父组件的 DOM 子树中
   → 用 getBoundingClientRect 做定位时要考虑这一点
// ② 的示例:事件会「跨 DOM 树」冒泡
function App() {
  return (
    
console.log('App 收到点击')} style={{ padding: 100 }}>
); } // ⚠️ 点击 Modal 里的按钮,App 的 onClick 也会触发(尽管 Modal 的 DOM 在 body 下) // → 需要在 Modal 内部 stopPropagation,或用「点击外部关闭」的替代方案
// 一个完整的 Modal 实现要点
function Modal({ open, onClose, title, children }: ModalProps) {
  const ref = useRef(null);
  const prevFocus = useRef(null);

  useEffect(() => {
    if (!open) return;

    prevFocus.current = document.activeElement as HTMLElement;   // 记录打开前的焦点
    ref.current?.focus();                                        // 焦点移入

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();                         // ESC 关闭
    };
    document.addEventListener('keydown', onKeyDown);

    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';                     // 锁定背景滚动

    return () => {
      document.removeEventListener('keydown', onKeyDown);
      document.body.style.overflow = prevOverflow;
      prevFocus.current?.focus();                                // 归还焦点
    };
  }, [open, onClose]);

  if (!open) return null;

  return createPortal(
    
e.target === e.currentTarget && onClose()}>
{children}
, document.body ); }

现代替代方案:<dialog> 元素的 showModal() 自带焦点陷阱、ESC 关闭、遮罩与焦点归还,能省掉上面大部分代码(见 HTML 章节)。React 里可以用 ref 调用它的方法。

三、StrictMode:开发环境的「严格检查」

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  
    
  
);

StrictMode 在开发环境做什么:

行为 目的
组件函数被调用两次(双调用) 暴露「不纯的渲染」(如渲染中改了外部变量、有副作用的计算)
Effect 被挂载 → 卸载 → 再挂载 暴露「清理函数没写全」的问题(模拟未来「可复用状态」的场景)
检测已废弃的 API 提示迁移(如 findDOMNode、字符串 ref、defaultProps)
检测 useState 的可疑用法 如「渲染中调用 setState」
// ① 双调用会暴露「不纯的渲染」
let renderCount = 0;

function Bad() {
  renderCount++;                  // ❌ 渲染中修改外部变量(副作用)
  if (renderCount > 1) {          // ⚠️ StrictMode 下会「提前」进入这个分支
    console.log('看起来被渲染了多次');
  }
  return 
; } // → 在开 StrictMode 时行为异常,正是提示你「渲染必须是纯函数」 // ✅ 渲染函数只做计算,不改外部状态 function Good({ items }) { const sorted = [...items].sort(); // 只计算,不改原数组 return
    {sorted.map((it) =>
  • {it.name}
  • )}
; }
// ② Effect 的「挂载 → 卸载 → 挂载」会暴露清理缺失
useEffect(() => {
  const timer = setInterval(tick, 1000);
  return () => clearInterval(timer);     // ✅ 有清理 → 双调用没问题
}, []);

useEffect(() => {
  const ws = new WebSocket(url);         // ⚠️ 没有清理
  ws.onmessage = handle;
  // StrictMode 下会创建两个 WebSocket(第一个没被关闭)
}, [url]);
// ✅ 必须有清理
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = handle;
  return () => ws.close();
}, [url]);

常见的「StrictMode 才会出现的问题」:

① 请求被发了两次(Effect 双执行)→ 说明没有做「取消/去重」
② 订阅被建立了两次 → 说明清理函数没写
③ 渲染中出现随机值/时间戳不一致 → 说明渲染不纯
④ 计数器加了两倍 → 说明在渲染里做了累加
⑤ ref 被意外重置 → 检查清理逻辑
// 请求被发两次的处理方式
useEffect(() => {
  let cancelled = false;
  const controller = new AbortController();

  fetchData({ signal: controller.signal })
    .then((data) => { if (!cancelled) setData(data); })
    .catch((e) => { if (e.name !== 'AbortError') setError(e); });

  return () => {
    cancelled = true;
    controller.abort();          // ⭐ 真正取消请求(避免「发送两次」)
  };
}, [id]);

注意:StrictMode 的这些检查只在开发环境生效,生产构建不会双调用。所以「生产环境没问题的代码」在开发环境报错,通常说明代码真的有问题(依赖了「只执行一次」的假设)。

四、三者的对比与适用

组件 作用 生产环境影响
Fragment 包裹多元素但不产生 DOM 无(生产也生效,但不产生节点)
createPortal 把子节点渲染到其它 DOM 位置 生效(真实渲染到目标容器)
StrictMode 开发环境严格检查 无(生产环境自动跳过全部检查)
// 三者可以一起用
function App() {
  return (
    
      
        <>
          
{open && createPortal(, document.body)} ); }

五、常见坑

// ① Fragment 简写形式不能带 key
{items.map((it) => ...)}       // ❌ 语法错误
{items.map((it) => ...)}   // ✅

// ② 用 Fragment 却没解决「包裹层」问题
// Fragment 不产生 DOM,所以 flex/grid 里的子元素会「提升」到父容器

// ③ Portal 里的事件监听以为是「独立的」
// → React 事件依然按组件树冒泡(需要 stopPropagation)

// ④ Portal 的容器不存在
createPortal(children, document.getElementById('not-exist')!)  // ❌ 报错
// ✅ 确认容器存在,或在 mount 后再渲染

// ⑤ SSR 中使用 Portal
// → 服务端没有 document,需要判断环境或延迟到客户端渲染

// ⑥ StrictMode 只在开发环境生效,误以为「生产也有双调用」
// ⑦ 因为「双调用」而用 ref 标志位绕过 → 掩盖了真正的问题(清理缺失)
// ⑧ 在 StrictMode 里用「只执行一次」的第三方库初始化 → 需要加幂等保护
// ⑨ Portal 内的元素无法被父组件的 CSS 选择器命中(不同 DOM 子树)
// ⑩ 忘记在 Portal 关闭时清理 body 上的样式(如 overflow: hidden)
// ⑩ 的一个真实案例
useEffect(() => {
  if (open) document.body.style.overflow = 'hidden';
  return () => { document.body.style.overflow = ''; };    // ⭐ 一定要还原
}, [open]);
// 更好:保存原值再还原(避免覆盖其它逻辑设置的样式)
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };

面试延伸

  1. 「Fragment 有什么用?」

它让你能返回多个元素而不产生额外的 DOM 节点。必要性在于:① 在 flex / grid 容器里多一层 div 包裹会改变布局(子元素变成了包裹层的子元素);② 会破坏 CSS 选择器(ul > li 不再匹配);③ 大量列表项时增加 DOM 数量。<>...</> 是简写,但不能传 key——需要 key 时必须用 <Fragment key={...}>。

  1. 「Portal 解决了什么问题?」

解决「弹层被祖先的样式困住」的问题。具体三类:① 被祖先的 overflow: hidden/auto 裁掉;② 被祖先的 z-index / 层叠上下文困住(弹窗盖不住其它元素);③ 被祖先的 transform 让 position: fixed 失效(fixed 相对该祖先定位)。createPortal(children, document.body) 把弹层的 DOM 挂到 body 下,彻底摆脱祖先影响。Modal / Tooltip / Dropdown / Toast 都该用。

  1. 「Portal 里的事件冒泡有什么特别之处?」

关键点:DOM 位置变了,但 React 组件树的位置没变。因此:① Context 依然能正常获取(context 沿组件树传递,不看 DOM);② 事件依然按组件树冒泡——在 Modal 内部点击,外层组件的 onClick 也会触发(即使 Modal 的 DOM 在 body 下)。这是与「原生事件按 DOM 树冒泡」的重要差异,处理「点击外部关闭」时容易踩坑。

  1. 「StrictMode 做了什么?」

在开发环境额外执行三类检查:① 组件函数被调用两次(暴露「渲染不纯」,如渲染里改外部变量);② Effect 执行「挂载 → 卸载 → 再挂载」(暴露「清理函数缺失」,如事件监听、订阅、WebSocket 没关闭);③ 检测已废弃的 API(findDOMNode、字符串 ref、defaultProps 等)。生产构建不做这些检查。

  1. 「StrictMode 下的双调用说明代码有问题吗?」

通常说明代码依赖了「只执行一次」的假设,而这在未来的 React 特性(如「可复用状态」)下是不成立的。典型问题:① Effect 没写清理函数(订阅建立了两次);② 请求没做取消/去重(发了两次);③ 渲染不纯(渲染中改外部变量、用 Math.random() / Date.now() 产生不一致结果)。正确做法是补齐清理逻辑,而不是用 ref 标志位绕过检查。

  1. 「为什么 Effect 会「挂载、卸载、再挂载」?」

这是 React 有意设计的「压力测试」:模拟「组件被卸载后状态被复用」的场景,强制开发者写出正确的清理函数。这样当 React 未来支持「在保留状态的情况下卸载/重挂载」(比如离屏渲染优化)时,代码依然正确。所以这不是 bug,而是提醒:「你的 Effect 必须能安全地执行多次」。

  1. 「StrictMode 会在生产环境生效吗?」

不会。StrictMode 的所有检查只在开发构建中执行——生产构建会编译掉这些检查(不会有双调用,也不会有额外的 Effect 循环)。这也解释了为什么「开发环境报错、生产正常」的情况:开发环境的检查更严格,暴露的是潜在问题,而不是「生产环境没问题」。

  1. 「Portal 在 SSR 中有什么问题?」

createPortal 需要真实 DOM 容器(通常是 document.body),而服务端渲染时没有 document。所以:① 直接调用会报错;② 需要判断环境(typeof document !== 'undefined')或用一个「客户端挂载后才渲染」的包装组件(useEffect 里 setMounted(true) 后再渲染 Portal);③ Next.js 里可以用 dynamic(() => import(...), { ssr: false }) 让该组件只在客户端渲染。

一句话速记

Fragment 让多元素包裹不产生 DOM(简写 <> 不能带 key,需要 key 时用 <Fragment key>)——它在 flex/grid 里尤其重要;Portal 把弹层的 DOM 挂到 body(解决被 overflow 裁剪、被 z-index 困住、被 transform 劫持三个问题),但事件仍按组件树冒泡、Context 仍正常;StrictMode 只在开发环境双调用组件函数与 Effect,用来暴露「渲染不纯」与「清理缺失」——正确做法是补齐清理,而不是绕过检查。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.9 Fragment / Portal / StrictMode

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫