弹窗关掉以后,里面写了一半的表单要不要留下?
这个问题听起来像产品细节,落到代码里却经常只剩一个布尔值。
const [visible, setVisible] = useState(false)项目刚开始时,这样当然够用。后来弹窗需要接收当前记录、保存回调和默认值,还要处理退出动画、重复打开、表单草稿,以及页面卸载时的清理。那个叫 visible 的变量慢慢开始回答一些它根本回答不了的问题。
我做 nice-use-modal 时,真正想解决的不是少写一行 useState。我只是越来越受不了「关闭弹窗」这句话里的含糊。
关闭,到底是看不见了,还是不存在了?
一个弹窗,同时有三件事在发生
弹窗至少有三个彼此独立的状态。组件有没有挂载,它现在能不能看见,它内部的局部状态还要不要保留。
只暴露 visible 时,这三件事会挤在同一个开关后面。条件渲染决定挂载,组件库的 open 决定可见性,表单草稿又可能被父组件接走。代码能跑,但生命周期散在好几个地方,调用者很难只看一处就知道下次打开会发生什么。
所以我把动作拆成了 show、hide 和 destroy。
| 调用 | 是否挂载 | 是否可见 | 局部状态 |
|---|---|---|---|
第一次 show() 之前 |
否 | 否 | 尚未创建 |
show(data?) |
是 | 是 | 首次创建或继续保留 |
hide() |
是 | 否 | 保留 |
destroy() |
否 | 否 | 丢弃 |
show() 表达「现在需要这个弹窗」,第一次调用时按需挂载。hide() 只把它藏起来,不碰内部状态。真正结束实例生命的是 destroy()。
方法多了两个,猜测少了很多。
草稿留不留下,不应该由组件偷偷决定
编辑弹窗最能说明这三个动作为什么不能合并。用户填到一半,临时关掉去确认别的信息,回来时大概率希望刚才的内容还在。这时调用 hide(),弹窗不可见,但表单仍然活着。
确认删除却是另一种语义。操作已经结束,下次打开最好得到一个干净实例。这里调用 destroy(),比在若干 useEffect 里手动重置字段更直接。
我喜欢这种 API 的原因,是产品意图能在调用处被读出来。
editModal.hide() // 我还会回来
deleteModal.destroy() // 这次操作结束了状态是否保留不再是某段内部实现碰巧造成的结果,而是调用者明确做出的选择。以后需求变化,也更容易找到该改的地方。
退出动画把 hide 和 destroy 的时间差暴露得很彻底
很多组件库关闭弹窗时,会先把 open 设为 false,等退出动画播放完,再允许卸载。如果点击关闭的瞬间就销毁组件,动画会直接被截断。
这时正确顺序很自然。
- 调用
hide(),让弹窗开始退出。 - 等组件库触发
afterClose。 - 确实不再需要状态时,再调用
destroy()。
如果要保留草稿,第三步干脆不发生。
也正是在这里,hide 和 destroy 的区别不再是命名洁癖。一个发生在视觉离场的开始,一个发生在实例生命周期的结束,它们本来就不在同一个时间点。
类型也应该知道每次打开需要什么
弹窗通常有两类输入。一类是创建控制器时就确定的配置和回调,另一类是打开那一刻才知道的记录 ID 或上下文。
在 nice-use-modal 里,前者进入 useModal(Component, props),后者进入 show(data)。TypeScript 会根据组件定义推导参数。需要记录 ID 的编辑弹窗不能空着调用 show(),普通提示弹窗也不会被迫接收一个没有意义的空对象。
每次 show() 都可以拿到新的输入快照,但新的输入不会顺手把组件局部状态清空。运行时数据和生命周期是两件事,类型设计也不该把它们重新搅在一起。
好 API 不是动作最少,而是含糊最少
如果只比较字符数量,setVisible(false) 肯定比 hide()、destroy() 两个方法简单。可组件 API 不是打字比赛。
我更在意调用者能不能准确说出自己想做什么,以及一个错误的生命周期组合是不是很容易发生。对弹窗来说,「出现」「暂时离开」和「彻底结束」就是三个不同动作。
回到开头那张写了一半的表单。它关掉以后该不该留下,不应该由一个布尔值替产品做决定。
调用者应该自己说清楚。