本文记录我在学习react的路上,做一个 Todo 项目时遇到的一个真实坑,涉及 Zustand 状态管理、React 重渲染机制、AntD message 与 React Portal 原理。希望能帮助刚学 React 的同学避坑,也帮助你更理解 React 的渲染机制。
🎯 现象:调用 deleteTodo 后,message 不显示了
我在页面里写了这样一段代码:
/views/Task/components/TaskCard/index.tsx
function TaskCard({ todo }: TodoCardProps) {
const [modal, modalContentHolder] = Modal.useModal();
const [messageApi, contextHolder] = message.useMessage();
const { deleteTodo } = useTaskStore();
const items: MenuProps["items"] = [
{
key: "2",
label: "删除",
danger: true,
icon: <DeleteOutlined />,
onClick: async (e: any) => {
e.domEvent.stopPropagation();
const confirm = await modal.confirm({
title: "提示",
content: "确定要删除吗?",
});
if (!confirm) return;
await reqDeleteTodo({ id: todo.id });
deleteTodo(todo.id, todo.status);
messageApi.success("删除成功");
},
},
];
return (
<div>
{contextHolder}
{modalContentHolder}
<div>
<div className="flex items-center gap-x-2">
<div>
{todo.title}
</div>
<div className="ml-auto flex items-center shrink-0">
<PriorityTag type={todo.priority} />
<div onClick={(e) => e.stopPropagation()}>
<Dropdown className="ml-4" menu={{ items }}>
<a onClick={(e) => e.preventDefault()}>
<EllipsisOutlined />
</a>
</Dropdown>
</div>
</div>
</div>
</div>
</div>
);
}
export default TaskCard;
理论上:
- 接口成功
- Store 删除 Todo 成功
- 应该提示“删除成功”
结果:message 根本不显示。
但奇怪的是:如果我把 deleteTodo 注释掉,message 又能正常显示。
❓ 为什么 deleteTodo 会导致 message 不显示?
核心原因只有一个:deleteTodo 更新了 Zustand 状态,导致当前页面重新渲染,而你的 message Portal 在这次渲染中被卸载了。
页面里写了:
const [messageApi, contextHolder] = message.useMessage();
只要组件重新执行(re-render),contextHolder 会:
- 旧的被卸载
- 新的被创建
⚠️ 由于 AntD 的 message 是依赖旧的 contextHolder 渲染 Portal的。
所以当 deleteTodo 更新状态时:
- 父组件重新渲染
- 子组件重新执行
- 旧 Portal 卸载
- message 来不及挂载 → 消失、不显示
🎯 deleteTodo 为什么会触发页面重新渲染?
因为 Zustand 的 set 会修改状态:
deleteTodo: (id: number, status: TaskStatus) => {
set((state) => {
const currentList = state.todoListMap[status].list || [];
const filteredList = currentList.filter((t) => t.id !== id);
console.log("delete");
return {
todoListMap: {
...state.todoListMap,
[status]: {
...state.todoListMap[status],
list: filteredList,
total: state.todoListMap[status].total - 1,
},
},
};
});
},
而在TaskCard的父组件中存在以下:
const todoList = useTodoStore((s) => s.todoListMap);
状态变化 → selector 变化 → 父组件重新渲染 → 子组件重新渲染 → Portal 被卸载。
⭐ React Portal 到底是什么?
Portal 让 React 组件的内容渲染到当前 DOM 结构以外的地方。
简单例子:
createPortal(<Modal />, document.body)
即使你写在:
<App>
<Page>
<Modal />
</Page>
</App>
它依然会挂载到 body 下。
AntD 的 message、Modal、Notification 全都基于 Portal 实现。
Portal 依赖一个“根节点”(也就是 contextHolder),如果这个根节点被卸载,那么所有 message 都会消失。
这也解释了为什么 deleteTodo 会导致 message 不显示。
✔️ 最终正确解法:把 message.useMessage() 放到 App 根组件
让 Portal 永远只创建一次,不会被重新渲染。
function App() {
const [messageApi, contextHolder] = message.useMessage();
return (
<MessageContext.Provider value={messageApi}>
{contextHolder}
<RouterProvider router={router} />
</MessageContext.Provider>
);
}
然后子组件通过 Context 获取实例:
const message = useContext(MessageContext);
message.success("删除成功");
这样 Portal 不会因为 deleteTodo 而被卸载,message 稳定显示。
🔍 React 渲染不是“一次性渲染”
很多刚学 React 的同学容易误解,以为:
React 渲染是一次性的,之后就不会再动了。
其实不是。
React 的真正机制是:
任何状态变化(setState / Zustand.set / props 变化)都会触发相关组件重新执行函数组件,重新渲染。
所以页面里写 useMessage() 这种带 UI 根节点的 hook,本质上是不稳定的。它必须放在不会重新渲染的地方(如 App、Layout)。
💡 总结
这是我学 React 时遇到的第一个坑:
把 message.useMessage() 放在会被状态更新影响的页面里,导致 Portal 被卸载,从而 message 无法显示。
解决:
message.useMessage只能写一次,放在 App 根组件- 子组件通过
Context获取messageApi Portal保持稳定,不随页面更新而被卸载
希望这篇文章能帮你避坑。如果有解释错误的欢迎评论区交流。