首页
/ LobeHub 命令式弹窗实战:createModal、confirmModal 与全局 ModalHost 的完整接入指南

LobeHub 命令式弹窗实战:createModal、confirmModal 与全局 ModalHost 的完整接入指南

2026-09-06 12:24:19作者:冯梦姬Eddie

在 LobeHub 中,弹窗(Modal / Dialog / Confirm)是贯穿设置页、Agent 管理、任务验收、技能商店等几十个功能模块的交互基础。本文基于仓库内的 Modal 规范文档(SKILL.md),系统讲解 LobeHub 推荐的 base-ui 命令式弹窗栈:从 createModal / confirmModal / ModalHost / useModalContext 的用法,到"为什么用命令式而非声明式"、"哪个关闭回调真正可靠",并对照仓库中真实的功能弹窗源码(如 CreateGoalModalForwardModal),帮助你在本仓库中新建或迁移弹窗时一次性做对。读完本文,你将能够独立编写符合 LobeHub 约定的 base-ui 弹窗,并正确处理关闭回调、i18n 与全局宿主挂载。

为什么选择命令式弹窗

规范文档给出了两种模式取舍的明确结论:

模式 特征 是否推荐
声明式 组件内维护 open 状态 + <Modal /> 不推荐
命令式 直接调用 createModal(),无本地状态 推荐

命令式的核心价值在于消除"调用方状态"与"弹窗状态"之间的耦合:调用处不需要 useState、不需要渲染 <Modal /> 标签、不需要传递 open / onCancel props,一次函数调用即可弹出。这在 LobeHub 中尤为关键——弹窗内容渲染在全局 ModalHost 的独立宿主树中,远离触发它的组件树,因此把弹窗状态收敛到一个 createXxxModal() 工厂函数里,是最自然的组织方式。

技术选型:@lobehub/ui/base-ui

新代码应使用 base-ui 弹窗栈(headless 原语,而不是 antd 的 Modal):

  • createModalconfirmModalModalHost 均来自 @lobehub/ui/base-ui
  • 弹窗内容组件内部使用 @lobehub/ui/base-uiuseModalContext

从源码结构看,本仓库根 package.json 声明的 @lobehub/ui 版本为 ^5.38.0,base-ui 子路径导出正是该组件库的推荐入口;仓库内大量功能模块(如 GoalDetailActions@lobehub/ui/base-ui 导入 confirmModalDropdownMenutoast)已统一迁移到该栈。

Body 插槽说明:传 content(也可以传 children,运行时按 content ?? children 取用)。

全局 ModalHost:必需,且来自 base-ui 子路径

base-ui 的 createModal 通过一个独立于根包的宿主进行渲染。应用必须在接近根组件的位置挂载一次 @lobehub/ui/base-ui 导出的 ModalHost(例如和其他全局宿主放在一起)。否则 createModal() 的调用将不可见——这是最容易踩的坑。

如果项目中只挂载了 @lobehub/uiModalHost,需要额外补挂一个 base-ui 的 ModalHost,直到所有命令式弹窗完成迁移。

LobeHub 主应用的做法可以直接参照 SPAGlobalProvider

import { ContextMenuHost, ModalHost, TooltipGroup } from '@lobehub/ui';
import { ModalHost as BaseModalHost, ToastHost } from '@lobehub/ui/base-ui';

// …
<ModalHost />        // 遗留栈宿主
<BaseModalHost />    // base-ui 栈宿主(base-ui createModal 依赖它)
<ToastHost />
<ContextMenuHost />

各子应用(share / workbench)同样在各自 Shell 中挂载了 base-ui 的 ModalHost,例如 ShareAppShellWorkbenchShell

标准文件结构

仓库约定的组织方式是把每个弹窗拆成"内容组件 + 工厂函数"两个文件:

features/
└── MyFeatureModal/
    ├── index.tsx            # export createXxxModal
    └── MyFeatureContent.tsx # modal body

1. 内容组件(MyFeatureContent.tsx

'use client';

import { useModalContext } from '@lobehub/ui/base-ui';
import { useTranslation } from 'react-i18next';

export const MyFeatureContent = () => {
  const { t } = useTranslation('namespace');
  const { close } = useModalContext();

  return <div>{/* ... */}</div>;
};

内容组件通过 useModalContext() 拿到 close 等 API,无需任何 props 传递即可自己关闭弹窗。

2. 工厂函数(index.tsx

'use client';

import { createModal } from '@lobehub/ui/base-ui';
import { t } from 'i18next';

import { MyFeatureContent } from './MyFeatureContent';

export const createMyFeatureModal = () =>
  createModal({
    content: <MyFeatureContent />,
    footer: null,
    maskClosable: true,
    styles: {
      content: { overflow: 'hidden', padding: 0 },
    },
    title: t('myFeature.title', { ns: 'setting' }),
    width: 'min(80%, 800px)',
  });

仓库中的真实示例 createGoalModal 与此结构完全一致,并返回 ModalInstance 供调用方后续 close() / update()

export const createGoalModal = (props?: CreateGoalContentProps): ModalInstance =>
  createModal({
    content: <CreateGoalContent {...props} />,
    footer: null,
    maskClosable: false,
    styles: { content: { overflow: 'hidden', padding: 0 } },
    title: null,
    width: 'min(88vw, 720px)',
  });

可以看到 width 支持 min(88vw, 720px) 这类 CSS 表达式写法,适合做"大屏限宽、小屏自适应"的响应式弹窗。

3. 调用方式

import { createMyFeatureModal } from '@/features/MyFeatureModal';

const handleOpen = useCallback(() => {
  createMyFeatureModal();
}, []);

return <Button onClick={handleOpen}>Open</Button>;

调用方没有 open 状态、没有条件渲染,交互逻辑完全解耦。

跨 Provider 边界的注意事项:把 store 递进弹窗树

base-ui 弹窗渲染在全局 ModalHost 的独立 React 树中,不会自动继承调用方组件的 Context。仓库中有两类实战处理,值得掌握:

  1. 显式回传 store apiForwardModal 的注释写得很直白:"The conversation store is context-scoped, and the modal renders in the global ModalHost tree — hand the live store api back"。它的做法是把 createConversationStore 作为参数传入 createModal,在 content 里包一层 Provider:

    export const openForwardModal = ({ createConversationStore, onClosed }: OpenForwardModalOptions) =>
      createModal({
        content: (
          <Provider createStore={createConversationStore}>
            <ForwardModalContent />
          </Provider>
        ),
        footer: null,
        onOpenChangeComplete: (open) => {
          if (!open) onClosed?.();
        },
        title: translate('messageForward.modal.title', { ns: 'chat' }),
        width: 760,
      });
    

    设置页的凭证弹窗(CreateCredModal/Content 等)也以同样的方式注释说明了"createModal() 渲染在全局 ModalHost,位于 CredsApiProvider 之外"的问题。

  2. 用 Portal 把 children 接回原组件树。遗留封装组件 ImperativeModal 记录了一个更隐蔽的坑:直接把 children 作为 content 传入时,这些子节点处于另一个宿主树,对调用方状态的变化总是晚一个 commit 才感知——React 会在每次事件结束时把受控输入还原为过期值,直接打断 IME 组合,导致中文等 CJK 输入不可用。该组件的解法是:modal 树里只渲染一个稳定的空占位 div,真正的 children 通过 createPortal(children, contentHost) 从原组件树 portal 进去,使状态与输入在同一棵树中同步提交。如果你要在弹窗里放 CJK 输入框,且出现输入法异常,可以优先从这个方向排查。

i18n:两处不同的翻译入口

  • 内容组件内:使用 React hook useTranslationreact-i18next);
  • createModal 选项里:因为工厂函数不是组件、不能调用 hook,使用 import { t } from 'i18next'

这也是规范文档给出的明确分工。

useModalContext

const { close, setCanDismissByClickOutside } = useModalContext();

在弹窗内容中可用它获取关闭能力,或动态控制"点击遮罩是否允许关闭"(例如表单有未保存内容时可调用 setCanDismissByClickOutside(false) 阻止误触关闭)。

关闭回调辨析:onOpenChange vs onOpenChangeComplete

这是本规范中最重要、也最反直觉的一节。close()——无论来自内容内的 useModalContext(),还是来自返回的 ModalInstance——都只是把栈中的条目翻转为 open: false,它不走 base-ui 的 dismissal 路径,因此两类回调的触发行为不同:

回调 用户主动关闭(Esc / 遮罩 / 头部 ✕) 内容或实例调用 close()
onOpenChange 触发 不触发
onOpenChangeComplete false 触发 false 触发

**结论:调用方侧的清理逻辑(清除"正在编辑"标志位、重置 Provider 的 open 状态等)应挂在 onOpenChangeComplete 上。**把它挂在 onOpenChange 上看起来没问题,直到某个 footer 按钮通过 close() 关闭弹窗——此时调用方永远收不到通知,标志位保持置位,弹窗将无法再次打开。仓库中的 ForwardModal 留有注释佐证:"NOT onOpenChange: that skips instance.close(), which is how the in-content Cancel and Forward buttons close this modal"。

另外注意:createModal 只会以 false 完成(命令式渲染器自行提供参数,不会把 prop 转发给 base-ui),但仍建议保留 guard——DropdownMenu 等其他 base-ui 原语会双向上报,保留判断可以让调用点不依赖这一实现差异:

onOpenChangeComplete: (open) => {
  if (!open) onClosed?.();
},

常用配置项(base-ui)

ImperativeModalProps 继承自 BaseModalProps,支持 titlewidthmaskClosableopenonOpenChangefooterstyles / classNames(语义键:backdroppopupheadertitleclosecontent 等)。

属性 说明
content 主体内容(优先于 children 的命名方式)
maskClosable 点击遮罩外部关闭
styles.* 语义区域样式,注意不是 antd 的 styles.body

这里与 antd 的关键差异是样式/类名按语义区域组织(content 而非 bodypopup 而非 wrapper)。仓库的遗留封装 ImperativeModal 中有一段现成的映射代码,说明了二者的转换关系:classNames.content ← classNames.bodyclassNames.popup ← className + classNames.wrapperstyles.content ← styles.bodystyles.popup ← styles.wrapper

confirmModal:确认型弹窗

删除、取消等破坏性操作使用 confirmModal,仓库中的典型用法见 GoalDetailActions(删除目标的二次确认):

import { confirmModal } from '@lobehub/ui/base-ui';

confirmModal({
  title: '…',
  content: '…',
  okText: '…',
  cancelText: '…',
  onOk: async () => {},
});

配合 okButtonProps: { danger: true } 可将确认按钮渲染为危险样式(如删除操作)。onOk 支持 async 函数,内部执行 store 动作或路由跳转后确认弹窗自动关闭。

遗留栈:@lobehub/ui 根包

旧调用点使用 @lobehub/ui 导出的 createModal,其类型是 antd Modal propschildrenallowFullscreengetContainerdestroyOnHiddenstyles.body 等)。新工作应优先迁移到 @lobehub/ui/base-ui

  • 遗留示例:src/features/SkillStore/index.tsxsrc/features/LibraryModal/CreateNew/index.tsx
  • 主应用通过 ImperativeModal 这一遗留封装组件做了 antd 语义 props → base-ui 语义 props 的归一化(normalizeClassNames / normalizeStyles),并在内部复用 base-ui 的 createModalModalFooterButton,同时用 createPortal 解决前述 CJK IME 问题——这也是"迁移过渡期"的一个可参考实现;
  • 迁移完成的判断标准:调用点全部改为 @lobehub/ui/base-ui 导入、styles/classNames 改用 content / popup 等语义键、关闭清理逻辑落在 onOpenChangeComplete 上。

小结:新弹窗落地的检查清单

  1. 确认目标应用中已挂载 base-ui 的 ModalHost(主应用参照 SPAGlobalProvider 的双宿主写法);
  2. MyFeatureModal/index.tsx + MyFeatureContent.tsx 结构新建,工厂函数返回 ModalInstance
  3. content 传主体、footer: null(或提供自定义 footer)、styles.content 设置 overflow: 'hidden'padding: 0
  4. 内容内用 useModalContext()close,工厂内用 i18nextt
  5. 需要调用方感知关闭时,只写 onOpenChangeComplete: (open) => { if (!open) ... }
  6. 破坏性操作用 confirmModal,并考虑 okButtonProps: { danger: true }
  7. 若调用方有 Context 依赖(store、主题、权限等),显式把依赖以 props 或内层 Provider 的形式递进 content

遵循以上约定,你的弹窗将和 LobeHub 仓库内 Agent 目标、任务验收、消息转发等模块的既有实现保持一致,后续统一维护和继续向 base-ui 迁移也不会出现宿主缺失或回调漏报的隐性 bug。

登录后查看全文
热门项目推荐
相关项目推荐