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

函数式打开弹窗的关键机制,可以概括成三个词: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 };
}

这段源码里有三个重点:

  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-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。

它主要做三件事:

  1. 创建 host,也就是一个 Render 实例,用于完成命令式渲染
  2. 注入 onOk 和 onClose:onOk 时 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 语义"合在了一起。组件负责在合适的时候触发 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);
};

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

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

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

相关推荐
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M6 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写6 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李6 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易7 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易7 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒7 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
Flynt9 小时前
别写拖拽行为,只声明"能拖到哪":UIST 最佳论文的拖拽方案我实测了
前端·svg·preact