一、需求描述
某个字段通常状态为查看状态,可以通过按钮(或点击字段内容)切换为编辑状态
在编辑状态下,点击当前内容之外的地方则取消编辑,回到查看状态
在编辑状态下,可以点击保存按钮提交数据,并回到查看状态
其实这种交互也做过不少,但这次的需求多了一个二次确认的气泡弹窗
如果没有按钮,仅仅是查看状态/编辑状态的切换(如下图)
这种情况就非常简单,直接通过 click 和 blur 事件切换状态即可
而加了额外的按钮,甚至加了弹窗气泡,就没办法直接使用 blur 事件
常见的做法是监听 body 的 click 事件,根据触发事件的 DOM 判断是否需要切换状态
document.body.addEventListener('click', handler)
但如果页面上有很多字段都有这样的需求,每一个字段都需要注册一个 body.click 事件处理函数,这似乎不够优雅
于是我转换思路,力求把状态控制在组件内部,let's coding....
二、组件设计
先梳理一下整个组件的状态:
1. 有两种模式:“编辑模式”、“查看模式”
2. 有三种操作:“开始编辑(查看→编辑)”、“重置(编辑→查看 并恢复初始值)”、“提交(编辑→查看 并保存当前值)”
那么整个组件的基本结构就出来了
type InputProps = { value?: string; onSubmit?: (v: string) => void; }; const Input: React.FC<InputProps> = ({ value, onSubmit }) => { const inputRef = useRef<InputRef>(null); const [readonly, setReadonly] = useState<boolean>(true); const [currentValue, setCurrentValue] = useState<InputProps['value']>(value); // 切换为编辑模式 const toggleEdit = () => {}; // 重置 const handleReset = () => {}; // 提交 const handleSubmit = () => {}; useEffect(() => { setCurrentValue(value); }, [value]); return ( <div className="desc-item"> {readonly ? ( <> {/* 查看模式 */} </> ) : ( <> {/* 编辑模式 */} </> )} </div> ); }; export default Input;
而在编辑模式下,输入框 Input 旁边还有一个保存按钮,并需要通过弹窗二次确认
所以编辑模式下的组件结构如下:
<> {/* 编辑模式 */} <AntInput className="desc-item-input" ref={inputRef} defaultValue={value} onBlur={handleReset} /> <AntPopconfirm title="是否继续操作?" okText="确认" cancelText="取消" onConfirm={handleSubmit} > <button className="desc-item-button">保存</button> </AntPopconfirm> </>
在这里就会有问题:“重置”操作是通过 onBlur 触发的,而每次点击“保存”按钮的时候必然会触发输入框的 blur 事件,更不用说 AntPopconfirm 里的“取消”或“确认”了
沿着这个业务场景思考,我需要解决的核心问题其实是:打开/关闭 AntPopconfirm 时不触发 onBlur
想到这一层就比较清晰了,除了 body.click 之外,还可以通过加锁来阻止状态切换
三、状态锁
使用 useRef 新增一个变量 shouldReset,用来控制是否执行重置操作
const shouldReset = useRef<boolean>(); // 重置 const handleReset = useCallback(() => { // 重置之前校验 shouldReset 状态, 防止“保存”等功能按钮触发 blur 事件 if (!shouldReset?.current) return; setCurrentValue(value); setReadonly(true); }, [value]);
然后在 AntPopconfirm 组件的 onVisibleChange 事件回调中锁定状态
// 二次确认的气泡显示/隐藏时的回调 const handleVisibleChange = useCallback(visible => { shouldReset.current = false; }, []);
<AntPopconfirm onVisibleChange={handleVisibleChange} > <button className="desc-item-button">保存</button> </AntPopconfirm>
但如果希望 shouldReset 这个状态锁生效,必须保证 handleVisibleChange 先于 handleReset 触发
最好的解决方案是使用异步任务 setTimeout
// 重置 const handleReset = useCallback(() => { // 保证重置功能的正常逻辑 shouldReset.current = true; setTimeout(() => { // 重置之前校验 shouldReset 状态, 防止“保存”等功能按钮触发 blur 事件 if (!shouldReset?.current) return; setCurrentValue(value); setReadonly(true); }, 160); }, [value]); // 二次确认的气泡显示/隐藏时的回调 const handleVisibleChange = useCallback(visible => { setTimeout(() => { shouldReset.current = false; }); }, [])
上面给 handleReset 的 setTimeout 加了 160ms 的延时,这样能保证它晚于 handleVisibleChange 执行,并且在交互上不会有明显的卡顿
这样的结果就是:如果在触发了 handleReset 之后的 160ms 毫秒内,有其他函数将 shouldReset 改为 false,则不会执行重置操作
四、优化细节
完成了状态锁之后,整个交互的核心逻辑就完成了,但还有一个瑕疵:
触发 onVisibleChange 之后,输入框会失焦,如果不能重新聚焦,则无法再次触发 onBlur,也就无法重置
所以如果失焦后还要继续编辑,也就是二次确认的“取消”操作时,需要让输入框重新聚焦
// 二次确认的气泡显示/隐藏时的回调 const handleVisibleChange = useCallback(visible => { // 这里的 visible 是目标状态,不是当前状态 setTimeout(() => { !visible && inputRef?.current?.focus(); shouldReset.current = false; }); }, []);
另外,为了保证交互体验,最好是给查看→编辑的操作也做上自动聚焦
// 切换为编辑视图 const toggleEdit = useCallback(() => { setReadonly(false); // 自动聚焦 setTimeout(() => { inputRef.current?.focus(); }); }, []);
以上就是通过状态锁来处理 Input 失焦交互的方案
虽然只贴出来 Input 组件的代码,但思路是通用的,对于 Select、DatePicker 或者其他自定义输入控件,也可以用这样的方案处理
标签:状态,const,shouldReset,重置,value,编辑,失焦,Input,交互 From: https://www.cnblogs.com/wisewrong/p/16807348.html