作者: Kr1s Li|灏仟亿前端技术团队
在 B 端系统里,弹窗是很常见的交互形态:新增、编辑、详情、导入、审批、二次确认、权限提示,几乎每条业务链路都会遇到。弹窗少的时候,用几个 visible 状态就能解决;但当一个页面同时出现 3 个、5 个甚至更多弹窗,并且它们之间还有串行依赖时,问题就会变得明显。
真正麻烦的不是"怎么维护更多 visible",而是弹窗交互经常被拆散在状态、回调、组件生命周期和请求逻辑之间。业务本来是一条连续流程,代码里却被切成了好几段。
render-promise 想解决的就是这个问题:让一次弹窗交互像一次异步请求一样可等待。调用方只需要 await openXxxModal(),确认时继续向下走,取消时自然中断。

1. 传统弹窗写法的问题
在页面组件里提前实例化多个弹窗,是最常见的写法:


通常会配套做几件事:
- 在页面里提前挂载多个弹窗组件
- 每个弹窗维护一个
visible状态 - 为每个弹窗写对应的 open / close 方法
- 在确认、取消回调里继续处理后续逻辑
一个典型示意大概是这样:
typescript
import React, { useCallback, useState } from 'react';
import { Button } from 'antd';
import EditModal from './EditModal';
import DetailModal from './DetailModal';
import ConfirmModal from './ConfirmModal';
export default function Page() {
const [editVisible, setEditVisible] = useState(false);
const [detailVisible, setDetailVisible] = useState(false);
const [confirmVisible, setConfirmVisible] = useState(false);
const [currentId, setCurrentId] = useState<string>();
const openEdit = useCallback((id: string) => {
setCurrentId(id);
setEditVisible(true);
}, []);
const closeEdit = useCallback(() => setEditVisible(false), []);
const openDetail = useCallback((id: string) => {
setCurrentId(id);
setDetailVisible(true);
}, []);
const closeDetail = useCallback(() => setDetailVisible(false), []);
const openConfirm = useCallback(() => setConfirmVisible(true), []);
const closeConfirm = useCallback(() => setConfirmVisible(false), []);
const onEditOk = async () => {
closeEdit();
// 可能还要刷新列表/再弹二次确认/再打开详情...
openConfirm();
};
return (
<>
<Button onClick={() => openEdit('1001')}>编辑</Button>
<Button onClick={() => openDetail('1001')}>详情</Button>
<EditModal open={editVisible} id={currentId} onOk={onEditOk} onClose={closeEdit} />
<DetailModal open={detailVisible} id={currentId} onClose={closeDetail} />
<ConfirmModal open={confirmVisible} onOk={() => { closeConfirm(); }} onClose={closeConfirm} />
</>
);
}
在弹窗数量少、流程简单时,这种写法没有问题。一旦弹窗数量增加,或者弹窗之间开始串联,维护成本会快速上升。
状态会膨胀
页面里会出现一排 visibleXxx 和一排 setVisibleXxx。即使后续尝试抽象成通用 open / close,也很容易引入更多分支参数,把管理逻辑继续复杂化。
链路会被切碎
典型场景是:先打开 A 弹窗,用户确认后触发校验或请求;校验通过后再打开 B 弹窗;B 弹窗确认后刷新列表或跳转页面。
这本来是一条完整业务链路,但在代码里往往会散落到页面组件、弹窗组件、请求函数,甚至多个文件里。代码能跑,但阅读和修改成本会越来越高。
生命周期与业务意图不一致
当 visible=false 时,组件通常只是隐藏,并没有卸载。这会带来两个问题:
- 弹窗内部状态可能残留,下次打开还要手动 reset
- 弹窗即使没有真正打开,也可能在页面首次渲染时执行 effect 或加载依赖
比如下面这个例子,弹窗没有打开,但组件已经被实例化,useEffect 仍然会执行:
typescript
import React, { useEffect, useState } from 'react';
import { Modal, Select } from 'antd';
import { queryEnums } from './api';
export default function EditModal(props: { open: boolean; onClose: () => void }) {
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
// 旧模式下:只要组件在页面根节点被实例化,这个 effect 就会执行
// 即便此时 open/visible 是 false,接口也会提前请求
useEffect(() => {
queryEnums().then(setOptions);
}, []);
return (
<Modal open={props.open} onCancel={props.onClose} onOk={props.onClose}>
<Select options={options} />
</Modal>
);
}
所以,这里真正要解决的不是"如何更优雅地维护 N 个 visible",而是如何让弹窗交互的表达方式更贴近业务流程:从上到下可读,逻辑连续,边界清晰。
2. 把弹窗看成一次可等待的决策
从产品交互看,弹窗出现通常意味着用户需要完成一次决策。它通过遮罩暂时隔离主页面交互,让用户把注意力放在当前任务上。
弹窗打开之后,结果一般只有两类:
- 用户确认:产生有效结果,例如提交表单、选择选项、确认操作
- 用户取消或关闭:不产生业务结果,当前流程中止或回到原流程
这和 Promise 的语义很接近:确认对应 resolve,取消或关闭对应 reject。
如果"打开弹窗"可以被抽象成一个返回 Promise 的函数,业务层就能用 await 组织弹窗链路:等待 A 的结果,再决定是否进入 B;用户取消时,流程自然中断。
scss
// 业务层:像 await 一个异步函数一样 await 弹窗结果
const flag = await aModalPromise();
if (flag) {
// refreshPageList()
// or
// navToXxxxPage()
}
这个思路看起来简单,但要真正落地,需要回答一个关键问题:
如果不再在页面 JSX 里提前实例化弹窗组件,弹窗到底渲染到哪里?它如何创建、如何卸载,又如何保证不会留下残余 DOM 或内存泄漏?
3. 核心机制:container / render / destroy
函数式打开弹窗的关键机制,可以概括成三个词:container、render、destroy。
container:创建一个承载弹窗的 DOM 节点,通常挂在document.body下render:把弹窗组件渲染到这个节点里destroy:弹窗结束后卸载组件,移除容器节点,释放资源
这种机制把"弹窗何时实例化、何时渲染、何时销毁"从页面组件里收敛出来。需要注意的是,像 Ant Design 的 Modal 本身也会通过 portal 把 DOM 插到 body,但传统写法里,组件实例是否存在、状态如何重置、effect 何时触发,仍然由页面侧的 visible 管理。
命令式渲染进一步把这些细节收进统一机制里:一次打开就是一次实例化,一次结束就是一次销毁。调用方不需要关心容器在哪里,也不需要管理卸载细节,只需要等待一个结果。

4. 从 Ant Design 的命令式 API 看这个机制
日常使用 Ant Design 时,我们已经接触过不少命令式 API,比如 message、notification、confirm。调用方并不需要提前在页面 JSX 里放一个 <Message /> 或 <ConfirmModal />,它们也能按需出现在界面上。
less
AntMessage.success('新增成功');
AntMessage.success('登录失败');
AntModal.confirm({
// ...
});
这类能力背后的模式通常也离不开"创建容器、渲染到容器、销毁并清理容器"。
javascript
// 非某个 antd 版本的源码,只是与 antd 相同的机制示意
function openLikeAntd(
renderIntoContainer: (el: any, container: HTMLElement) => void,
element: any,
) {
const container = document.createElement('div');
document.body.appendChild(container);
function destroy() {
// ReactDOM.unmountComponentAtNode(container) 或 root.unmount()
document.body.removeChild(container);
}
renderIntoContainer(element, container);
return { destroy };
}
再看 Ant Design v5.0.0 的 components/modal/confirm.tsx。下面保留与机制最相关的部分:
源码链接:ant-design/ant-design@5.0.0/components/modal/confirm.tsx
ini
// Ant Design v5.0.0 - components/modal/confirm.tsx(节选,保留关键逻辑)
export default function confirm(config: ModalFuncProps) {
const container = document.createDocumentFragment();
let currentConfig = { ...config, close, open: true } as any;
let timeoutId: NodeJS.Timeout;
function destroy(...args: any[]) {
const triggerCancel = args.some(param => param && param.triggerCancel);
if (config.onCancel && triggerCancel) {
config.onCancel(() => {}, ...args.slice(1));
}
for (let i = 0; i < destroyFns.length; i++) {
const fn = destroyFns[i];
if (fn === close) {
destroyFns.splice(i, 1);
break;
}
}
reactUnmount(container);
}
function render({ okText, cancelText, prefixCls: customizePrefixCls, ...props }: any) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const runtimeLocale = getConfirmLocale();
const { getPrefixCls, getIconPrefixCls } = globalConfig();
const rootPrefixCls = getPrefixCls(undefined, getRootPrefixCls());
const prefixCls = customizePrefixCls || `${rootPrefixCls}-modal`;
const iconPrefixCls = getIconPrefixCls();
reactRender(
<ConfirmDialog
{...props}
prefixCls={prefixCls}
rootPrefixCls={rootPrefixCls}
iconPrefixCls={iconPrefixCls}
okText={okText}
locale={runtimeLocale}
cancelText={cancelText || runtimeLocale.cancelText}
/>,
container,
);
});
}
function close(...args: any[]) {
currentConfig = {
...currentConfig,
open: false,
afterClose: () => {
if (typeof config.afterClose === 'function') {
config.afterClose();
}
destroy.apply(this, args);
},
};
if (currentConfig.visible) delete currentConfig.visible;
render(currentConfig);
}
function update(configUpdate: ConfigUpdate) {
currentConfig = typeof configUpdate === 'function'
? configUpdate(currentConfig)
: { ...currentConfig, ...configUpdate };
render(currentConfig);
}
render(currentConfig);
destroyFns.push(close);
return { destroy: close, update };
}
这段源码里有三个重点:
container = document.createDocumentFragment():创建承载节点reactRender(..., container):命令式渲染destroyFns.push(close)和reactUnmount(container):集中管理关闭与卸载
调用侧不关心容器,只拿到一个可销毁句柄;渲染与销毁逻辑被集中管理,这就是命令式 UI 能够稳定运行的关键。
回到 render-promise,它做的是把这套机制再往前推一步:让"弹窗结束"与 Promise 的 resolve/reject 绑定,从而把业务链路写顺。
5. render-promise 的源码设计
render-promise 可以按职责拆成两层:
Render:负责容器生命周期,包括创建、渲染、卸载、销毁renderPromise:把一个组件包装成 Promise,并注入onOk/onClose,同时提供 cleanup 兜底

5.1 Render:管理容器生命周期
容器生命周期里最重要的一点是:无论卸载是否成功,都要保证容器被移除。这样才能避免残留 DOM,也能降低长期运行下的内存风险。
下面是 render-promise 中 Render 的关键逻辑摘取:
kotlin
// src/render-special.ts(节选)
class Render {
create() {
this.div = document.createElement('div');
this.div.setAttribute('tool-element', this.name);
document.body.appendChild(this.div);
}
render(element: JSX.Element) {
const container = this.ensureContainer();
// React 18 优先 createRoot,否则 fallback ReactDom.render
ReactDom.render(element, container);
}
unmountComponentAtNode() {
const container = this.div;
if (!container) return;
try {
ReactDom.unmountComponentAtNode(container);
} finally {
this.remove(); // finally 清理 DOM 容器
}
}
}
这里的设计重点不是代码量,而是生命周期边界清楚:创建容器、渲染组件、卸载组件、移除容器,每一步都有明确归属。
5.2 renderPromise:把组件包装成 Promise
renderPromise 接收一个内部组件 InnerComponent,返回一个函数。业务调用这个函数时,会得到一个 Promise。
它主要做三件事:
- 创建 host,也就是一个
Render实例,用于完成命令式渲染 - 注入
onOk和onClose:onOk时 resolve,onClose时 reject - 提供
cleanup()兜底:确保卸载逻辑只执行一次,渲染失败时也能回收资源
其中 cleanup + 幂等开关 是稳定性的重要来源。所谓幂等,就是同一个操作重复执行多次,最终结果与执行一次一致。放在这里就是:无论 cleanup() 被调用 1 次还是 3 次,最后都只会发生一次卸载和移除,不会因为重复清理导致异常。
typescript
// src/render-promise.tsx(接近完整实现,便于理解整体结构)
import React from 'react';
import Render from './render-special';
import type { Options, ResolveValue } from './type';
import { normalizeResolvePayload } from './utils';
function renderPromise<InnerProps extends Record<string, any>>(
InnerComponent: React.ComponentType<InnerProps>,
name: string,
options?: Options,
) {
type RecordProps = Omit<InnerProps, 'onOk' | 'onClose'>;
return function (props?: RecordProps) {
let host: Render | null = new Render(name, options);
let cleaned = false;
type PromiseResolveType = ResolveValue<InnerProps['onOk']>;
const cleanup = () => {
if (cleaned) return; // 幂等开关:只清理一次
cleaned = true;
host?.unmountComponentAtNode();
host = null;
};
return new Promise<PromiseResolveType>((resolve, reject) => {
const _props = (props ?? {}) as InnerProps;
const handleOk = (...args: any[]) => {
cleanup();
resolve(normalizeResolvePayload(args) as PromiseResolveType);
};
const handleClose = (reason: unknown) => {
cleanup();
reject(reason);
};
const element = (
<InnerComponent {..._props} onOk={handleOk} onClose={handleClose} />
);
if (!host) {
reject(new Error('Failed to create render instance'));
return;
}
try {
host.render(element);
} catch (error) {
cleanup();
reject(error);
}
});
};
}
export default renderPromise;
到这里,renderPromise 已经把"容器机制"和"Promise 语义"合在了一起。组件负责在合适的时候触发 onOk 或 onClose,调用方只关心 await 的结果。
5.3 类型与 payload 归一化
为了让调用体验更自然,renderPromise 还处理了返回值类型。
它会把 onOk 的入参推导为 Promise resolve 的返回值类型。这样业务侧 await openXxx() 后,可以拿到更清晰的类型提示。
同时,onOk 可能不传参、传一个参或传多个参,所以内部会对 resolve 参数做归一化:
- 0 个参数:返回
undefined - 1 个参数:返回该参数
- 多个参数:返回数组
相关类型推导与归一化逻辑如下:
typescript
// src/type.d.ts(节选)
export type ResolveValue<T> = NonNullable<T> extends (...args: infer Args) => any
? Args extends []
? void
: Args extends [infer OnlyArg]
? OnlyArg
: Args
: void;
ini
// src/utils.ts(节选)
export const normalizeResolvePayload = (args: unknown[]) => {
if (args.length === 0) return undefined;
if (args.length === 1) return args[0];
return args;
};
这些细节看起来不大,但会直接影响业务侧写法是否顺手。类型和返回值形态稳定,调用方就不需要在每个弹窗结果上额外做一层兼容。
6. 它在业务里带来的四类收益
6.1 状态天然清理:关闭即卸载
传统 visible=false 模式通常只是隐藏组件,不一定卸载组件。弹窗内部状态可能残留,下一次打开还要手动 reset。
旧方案里经常要写这样的清理逻辑:
scss
const closeEdit = () => {
setEditVisible(false);
setFormValue(DEFAULT_VALUE); // 手动清理
setOptions([]); // 手动清理
};
而命令式创建与销毁的方式,会让组件在关闭时自然卸载,内部 state 随组件卸载释放:
scss
await openEditModal({ id }); // 关闭后组件被卸载,state 不会残留到下一次打开
这不代表所有清理逻辑都能消失,但至少可以减少"每个弹窗都要写一套 reset"的维护负担。
6.2 链路更连续:业务流程从上到下表达
弹窗 Promise 化之后,业务层可以用 await 组织交互:先等待 A 的结果,再决定下一步。链路不会被回调切碎,阅读时更像一段完整业务流程。

旧方案里,回调与 visible 经常交织在一起:
typescript
// =========================
// Page.tsx
// =========================
import React, { useCallback, useState } from 'react';
import { Button } from 'antd';
import AModal from './AModal';
import BModal from './BModal';
export default function Page() {
const [aVisible, setAVisible] = useState(false);
const [bVisible, setBVisible] = useState(false);
const [id, setId] = useState<string>();
const [aResult, setAResult] = useState<{ token: string } | null>(null);
const openAModal = useCallback((nextId: string) => {
setId(nextId);
setAVisible(true);
}, []);
const closeAModal = useCallback(() => setAVisible(false), []);
const openBModal = useCallback(() => setBVisible(true), []);
const closeBModal = useCallback(() => setBVisible(false), []);
const refreshList = useCallback(async () => {
// ...刷新列表
}, []);
return (
<>
<Button onClick={() => openAModal('1001')}>开始流程</Button>
<AModal
open={aVisible}
id={id}
onClose={closeAModal}
onOk={async (res) => {
// A 弹窗确认:先关 A,再决定开 B
closeAModal();
setAResult(res);
openBModal();
}}
/>
<BModal
open={bVisible}
token={aResult?.token}
onClose={closeBModal}
onOk={async () => {
// B 弹窗确认:先关 B,再刷新页面数据
closeBModal();
await refreshList();
}}
/>
</>
);
}
// =========================
// AModal.tsx
// =========================
import React, { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { preCheck } from './api';
export default function AModal(props: {
open: boolean;
id?: string;
onClose: () => void;
onOk: (res: { token: string }) => void;
}) {
const [loading, setLoading] = useState(false);
const handleOk = useCallback(async () => {
setLoading(true);
try {
// A 弹窗内部:做前置校验 / 请求
const token = await preCheck(props.id);
props.onOk({ token }); // 把结果抛回 Page
} finally {
setLoading(false);
}
}, [props]);
return (
<Modal open={props.open} confirmLoading={loading} onOk={handleOk} onCancel={props.onClose}>
A 弹窗内容
</Modal>
);
}
// =========================
// BModal.tsx
// =========================
import React, { useCallback } from 'react';
import { Modal } from 'antd';
export default function BModal(props: {
open: boolean;
token?: string | null;
onClose: () => void;
onOk: () => void;
}) {
const handleOk = useCallback(() => {
// B 弹窗内部:可能还要处理提交/确认
props.onOk(); // 最终还是回到 Page 去刷新/跳转
}, [props]);
return (
<Modal open={props.open} onOk={handleOk} onCancel={props.onClose}>
B 弹窗内容(token={String(props.token)})
</Modal>
);
}
改成 Promise 化之后,链路会收敛很多:
csharp
try {
const aResult = await openAModal({ id });
const bResult = await openBModal({ aResult });
await refreshList(bResult);
} catch (e) {
// 用户取消/关闭,链路自然中断
}
在更复杂的流程里,也可以保持从上到下的阅读顺序:
csharp
async function onClick() {
await preCheck();
await openConfirmModal({ text: '确认要执行吗?' });
const result = await openProgressModal({ taskId: await startTask() });
await openResultModal({ result });
}
对团队协作来说,这一点往往比"少写几行代码"更重要。入口清晰、链路连续,维护者才能更快定位问题,也更容易判断改动影响。
6.3 初始化更合理:按需引入弹窗模块
弹窗以函数式打开的形态存在后,更容易做按需引入:业务触发时再加载相关模块,而不是页面初始化时就把所有弹窗组件和依赖都引进来。
旧写法通常在页面初始化时就 import:
javascript
// 旧:页面初始化就 import 进来(弹窗依赖也会跟着进 bundle)
import { openBigModal } from './big-modal';
新写法可以在真正需要时再加载:
typescript
// 新:按需 import(业务触发时再加载弹窗模块)
const openBigModal = async (props: any) => {
const mod = await import('./big-modal');
return mod.openBigModal(props);
};
这是否能带来明显的性能收益,取决于页面规模和弹窗复杂度。但从工程结构上,它提供了更合理的拆分方式,也减少了首屏加载时并不需要的弹窗逻辑。
6.4 不只适用于 Modal
renderPromise 的核心是"命令式渲染一个 React 节点,并在结束时销毁",所以它并不限定只能用于 Modal。
如果某个固定定位的提示块、临时操作面板或轻量交互层也需要"可等待/可关闭"的能力,同样可以用这套机制。
typescript
import React from 'react';
import renderPromise from 'render-promise';
type FixedTipProps = {
onOk: () => void;
onClose: () => void;
text: string;
};
function FixedTip(props: FixedTipProps) {
return (
<div
style={{
position: 'fixed',
top: 20,
left: '50%',
transform: 'translateX(-50%)',
padding: 12,
borderRadius: 8,
background: '#111',
color: '#fff',
zIndex: 9999,
}}
onClick={() => props.onOk()}
>
{props.text}(点我关闭)
</div>
);
}
export const openFixedTip = renderPromise(FixedTip, 'fixed-tip');
业务侧调用:
arduino
await openFixedTip({ text: '保存成功' });
只要交互符合"打开、等待结果、结束后销毁"这条链路,它就可以使用类似抽象。
7. 一个最小闭环示例
下面用 README 式示例看一下完整写法。
my-modal.tsx:
typescript
import React, { useEffect, useState } from 'react';
import { Modal } from 'antd';
import renderPromise from 'render-promise';
type MyModalProps = {
onOk: (payload: { id: string }) => void;
onClose: (reason?: unknown) => void;
id: string;
};
function MyModal(props: MyModalProps) {
const [loading, setLoading] = useState(false);
useEffect(() => {
// 弹窗真正被打开时才会触发(因为组件此时才会被渲染)
// ...fetch detail by props.id
}, [props.id]);
return (
<Modal
open
confirmLoading={loading}
title="示例弹窗"
onOk={() => props.onOk({ id: props.id })}
onCancel={() => props.onClose('cancel')}
>
这里是内容
</Modal>
);
}
export const openMyModal = renderPromise(MyModal, 'my-modal');
export default MyModal;
page.tsx:
csharp
import React from 'react';
import { Button } from 'antd';
import { openMyModal } from './my-modal';
export default function Page() {
return (
<Button
onClick={async () => {
try {
const res = await openMyModal({ id: '1001' });
// res 有类型提示(由 onOk 的入参推导)
console.log(res.id);
// ...refresh
} catch (e) {
// 用户取消/关闭
}
}}
>
打开弹窗
</Button>
);
}
这个例子里,页面不再维护 visible,也不需要提前挂载弹窗。弹窗只在调用 openMyModal 时创建,结束后销毁。调用方通过 await 拿到结果,取消则进入 catch。
8. 落地时建议统一三条规范
如果团队希望在业务中长期使用类似能力,最好先形成一致写法。

第一,弹窗组件统一接收 onOk 与 onClose。弹窗内部决定何时触发 resolve / reject,调用方不再维护 visible。
第二,统一导出 open 函数,例如 openXxx = renderPromise(XxxModal, 'xxx')。创建、渲染、销毁机制都封装在 open 函数内部。
第三,业务层只关心 await openXxx() 的结果,并据此组织后续流程。取消或关闭统一进入 catch,如果业务需要区分原因,再约定 reason 的形态。
reason 的处理方式可以简单分成两类。
统一把 reject 当作取消或关闭,不做细分:
arduino
try {
await openMyModal({ id });
} catch {
// ignore
}
或者按 reason 分流:
javascript
try {
await openMyModal({ id });
} catch (reason) {
if (reason === 'cancel') return;
// 其他异常:上报或提示
console.error(reason);
}
选哪种写法不取决于工具,而取决于团队对交互边界的约定。重要的是保持一致,不要在不同页面里混用多套语义。
9. 总结
render-promise 解决的不是"弹窗能不能打开",而是"弹窗交互如何以更接近业务流程的方式表达"。
它背后的机制并不复杂:container / render / destroy,再加上 Promise 的 resolve/reject 语义,以及工程上的 cleanup 兜底和类型归一化。
当一个页面只有一两个简单弹窗时,传统 visible 写法依然够用。但当弹窗开始串联、状态开始膨胀、回调链路开始散落时,把弹窗抽象成一个可等待的交互,会让业务代码更连续,也更容易维护。
更重要的是,这种方式提醒我们先看清交互本质:一次弹窗就是一次等待用户决策的过程。抽象对了,实现反而会变小。