学 React 遇到的第一个坑:更改zustand的状态后 message 不显示?

33 阅读3分钟

本文记录我在学习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 下。

AntDmessage、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 保持稳定,不随页面更新而被卸载

希望这篇文章能帮你避坑。如果有解释错误的欢迎评论区交流。