B 端多弹窗越来越难维护?试试把弹窗交互 Promise 化

作者: 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

函数式打开弹窗的关键机制,可以概括成三个词:containerrenderdestroy

  • 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 };
}

这段源码里有三个重点:

  1. container = document.createDocumentFragment():创建承载节点
  2. reactRender(..., container):命令式渲染
  3. 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-promiseRender 的关键逻辑摘取:

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。

它主要做三件事:

  1. 创建 host,也就是一个 Render 实例,用于完成命令式渲染
  2. 注入 onOkonCloseonOk 时 resolve,onClose 时 reject
  3. 提供 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 语义"合在了一起。组件负责在合适的时候触发 onOkonClose,调用方只关心 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);
};

这是否能带来明显的性能收益,取决于页面规模和弹窗复杂度。但从工程结构上,它提供了更合理的拆分方式,也减少了首屏加载时并不需要的弹窗逻辑。

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. 落地时建议统一三条规范

如果团队希望在业务中长期使用类似能力,最好先形成一致写法。

第一,弹窗组件统一接收 onOkonClose。弹窗内部决定何时触发 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 写法依然够用。但当弹窗开始串联、状态开始膨胀、回调链路开始散落时,把弹窗抽象成一个可等待的交互,会让业务代码更连续,也更容易维护。

更重要的是,这种方式提醒我们先看清交互本质:一次弹窗就是一次等待用户决策的过程。抽象对了,实现反而会变小。

相关推荐
奇奇怪怪的1 小时前
向量数据库选型与生产级实战
前端
徐小夕2 小时前
jitword 协同文档3.2发布:打造浏览器中最强word编辑器
前端·架构·github
纯爱掌门人4 小时前
干了这么多年前端,聊聊 2026 年我们到底还值不值钱
前端·程序员
houhou4 小时前
Monaco Editor 集成指南:从配置到优化
前端
hunterandroid4 小时前
[Android 从零到一] Custom View 自定义绘制:从 onDraw 到完整交互
前端
李明卫杭州4 小时前
Vue3 v-memo 指令详解:让你的列表渲染性能翻倍 🚀
前端
梨子同志4 小时前
Monorepo
前端
lihaozecq4 小时前
继 Web Coding Agent 后,我做了一个本地优先的桌面 AI Agent
前端·agent
用户298698530144 小时前
在 React 中使用 JavaScript 将 Excel 转换为 SVG
前端·javascript·react.js