
一句话:三个「特殊组件」分别解决三类问题——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; };
面试延伸
- 「Fragment 有什么用?」
它让你能返回多个元素而不产生额外的 DOM 节点。必要性在于:① 在 flex / grid 容器里多一层 div 包裹会改变布局(子元素变成了包裹层的子元素);② 会破坏 CSS 选择器(ul > li 不再匹配);③ 大量列表项时增加 DOM 数量。<>...</> 是简写,但不能传 key——需要 key 时必须用 <Fragment key={...}>。
- 「Portal 解决了什么问题?」
解决「弹层被祖先的样式困住」的问题。具体三类:① 被祖先的 overflow: hidden/auto 裁掉;② 被祖先的 z-index / 层叠上下文困住(弹窗盖不住其它元素);③ 被祖先的 transform 让 position: fixed 失效(fixed 相对该祖先定位)。createPortal(children, document.body) 把弹层的 DOM 挂到 body 下,彻底摆脱祖先影响。Modal / Tooltip / Dropdown / Toast 都该用。
- 「Portal 里的事件冒泡有什么特别之处?」
关键点:DOM 位置变了,但 React 组件树的位置没变。因此:① Context 依然能正常获取(context 沿组件树传递,不看 DOM);② 事件依然按组件树冒泡——在 Modal 内部点击,外层组件的 onClick 也会触发(即使 Modal 的 DOM 在 body 下)。这是与「原生事件按 DOM 树冒泡」的重要差异,处理「点击外部关闭」时容易踩坑。
- 「StrictMode 做了什么?」
在开发环境额外执行三类检查:① 组件函数被调用两次(暴露「渲染不纯」,如渲染里改外部变量);② Effect 执行「挂载 → 卸载 → 再挂载」(暴露「清理函数缺失」,如事件监听、订阅、WebSocket 没关闭);③ 检测已废弃的 API(findDOMNode、字符串 ref、defaultProps 等)。生产构建不做这些检查。
- 「StrictMode 下的双调用说明代码有问题吗?」
通常说明代码依赖了「只执行一次」的假设,而这在未来的 React 特性(如「可复用状态」)下是不成立的。典型问题:① Effect 没写清理函数(订阅建立了两次);② 请求没做取消/去重(发了两次);③ 渲染不纯(渲染中改外部变量、用 Math.random() / Date.now() 产生不一致结果)。正确做法是补齐清理逻辑,而不是用 ref 标志位绕过检查。
- 「为什么 Effect 会「挂载、卸载、再挂载」?」
这是 React 有意设计的「压力测试」:模拟「组件被卸载后状态被复用」的场景,强制开发者写出正确的清理函数。这样当 React 未来支持「在保留状态的情况下卸载/重挂载」(比如离屏渲染优化)时,代码依然正确。所以这不是 bug,而是提醒:「你的 Effect 必须能安全地执行多次」。
- 「StrictMode 会在生产环境生效吗?」
不会。StrictMode 的所有检查只在开发构建中执行——生产构建会编译掉这些检查(不会有双调用,也不会有额外的 Effect 循环)。这也解释了为什么「开发环境报错、生产正常」的情况:开发环境的检查更严格,暴露的是潜在问题,而不是「生产环境没问题」。
- 「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,用来暴露「渲染不纯」与「清理缺失」——正确做法是补齐清理,而不是绕过检查。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!