弹窗关掉以后,里面写了一半的表单要不要留下?

这个问题听起来像产品细节,落到代码里却经常只剩一个布尔值。

const [visible, setVisible] = useState(false)

项目刚开始时,这样当然够用。后来弹窗需要接收当前记录、保存回调和默认值,还要处理退出动画、重复打开、表单草稿,以及页面卸载时的清理。那个叫 visible 的变量慢慢开始回答一些它根本回答不了的问题。

我做 nice-use-modal 时,真正想解决的不是少写一行 useState。我只是越来越受不了「关闭弹窗」这句话里的含糊。

关闭,到底是看不见了,还是不存在了?

一个弹窗,同时有三件事在发生

弹窗至少有三个彼此独立的状态。组件有没有挂载,它现在能不能看见,它内部的局部状态还要不要保留。

只暴露 visible 时,这三件事会挤在同一个开关后面。条件渲染决定挂载,组件库的 open 决定可见性,表单草稿又可能被父组件接走。代码能跑,但生命周期散在好几个地方,调用者很难只看一处就知道下次打开会发生什么。

所以我把动作拆成了 showhidedestroy

调用 是否挂载 是否可见 局部状态
第一次 show() 之前 尚未创建
show(data?) 首次创建或继续保留
hide() 保留
destroy() 丢弃

show() 表达「现在需要这个弹窗」,第一次调用时按需挂载。hide() 只把它藏起来,不碰内部状态。真正结束实例生命的是 destroy()

方法多了两个,猜测少了很多。

草稿留不留下,不应该由组件偷偷决定

编辑弹窗最能说明这三个动作为什么不能合并。用户填到一半,临时关掉去确认别的信息,回来时大概率希望刚才的内容还在。这时调用 hide(),弹窗不可见,但表单仍然活着。

确认删除却是另一种语义。操作已经结束,下次打开最好得到一个干净实例。这里调用 destroy(),比在若干 useEffect 里手动重置字段更直接。

我喜欢这种 API 的原因,是产品意图能在调用处被读出来。

editModal.hide()       // 我还会回来
deleteModal.destroy() // 这次操作结束了

状态是否保留不再是某段内部实现碰巧造成的结果,而是调用者明确做出的选择。以后需求变化,也更容易找到该改的地方。

退出动画把 hide 和 destroy 的时间差暴露得很彻底

很多组件库关闭弹窗时,会先把 open 设为 false,等退出动画播放完,再允许卸载。如果点击关闭的瞬间就销毁组件,动画会直接被截断。

这时正确顺序很自然。

  1. 调用 hide(),让弹窗开始退出。
  2. 等组件库触发 afterClose
  3. 确实不再需要状态时,再调用 destroy()

如果要保留草稿,第三步干脆不发生。

也正是在这里,hidedestroy 的区别不再是命名洁癖。一个发生在视觉离场的开始,一个发生在实例生命周期的结束,它们本来就不在同一个时间点。

类型也应该知道每次打开需要什么

弹窗通常有两类输入。一类是创建控制器时就确定的配置和回调,另一类是打开那一刻才知道的记录 ID 或上下文。

在 nice-use-modal 里,前者进入 useModal(Component, props),后者进入 show(data)。TypeScript 会根据组件定义推导参数。需要记录 ID 的编辑弹窗不能空着调用 show(),普通提示弹窗也不会被迫接收一个没有意义的空对象。

每次 show() 都可以拿到新的输入快照,但新的输入不会顺手把组件局部状态清空。运行时数据和生命周期是两件事,类型设计也不该把它们重新搅在一起。

好 API 不是动作最少,而是含糊最少

如果只比较字符数量,setVisible(false) 肯定比 hide()destroy() 两个方法简单。可组件 API 不是打字比赛。

我更在意调用者能不能准确说出自己想做什么,以及一个错误的生命周期组合是不是很容易发生。对弹窗来说,「出现」「暂时离开」和「彻底结束」就是三个不同动作。

回到开头那张写了一半的表单。它关掉以后该不该留下,不应该由一个布尔值替产品做决定。

调用者应该自己说清楚。