处理 Input 失焦交互的另一种方案
一、需求描述
某个字段通常状态为查看状态,可以通过按钮(或点击字段内容)切换为编辑状态
在编辑状态下,点击当前内容之外的地方则取消编辑,回到查看状态
在编辑状态下,可以点击保存按钮提交数据,并回到查看状态
其实这种交互也做过不少,但这次的需求多了一个二次确认的气泡弹窗
如果没有按钮,仅仅是查看状态/编辑状态的切换(如下图)
这种情况就非常简单,直接通过 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 或者其他自定义输入控件,也可以用这样的方案处理
最后我将这部分逻辑封装成了一个 Hooks, 源码如下:

import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
type Props = {
reset?: () => void;
};
const useToggle = ({ reset }: Props) => {
// 输入控件的 Ref, 用于触发 focus
const inputRef = useRef<InputRef>();
// 是否触发重置
const shouldReset = useRef<boolean>();
// 查看模式/编辑模式
const [readonly, setReadonly] = useState<boolean>(true);
// 切换为编辑模式
const toggleEdit = useCallback(() => {
setReadonly(false);
// 自动聚焦
setTimeout(() => {
inputRef?.current?.focus();
});
}, []);
// 重置
const onReset = useCallback(() => {
shouldReset.current = true;
setTimeout(() => {
// 重置之前校验 shouldReset 状态, 防止“保存”等功能按钮触发 blur 事件
if (!shouldReset?.current) return;
typeof reset === 'function' && reset();
setReadonly(true);
}, 160);
}, [reset]);
// 二次确认的气泡显示/隐藏时的回调
const onVisibleChange = useCallback((visible: boolean) => {
// 这里的 visible 是目标状态,不是当前状态
setTimeout(() => {
!visible && inputRef?.current?.focus();
shouldReset.current = false;
});
}, []);
return {
inputRef,
readonly,
setReadonly,
toggleEdit,
onReset,
onVisibleChange,
};
};
export default useToggle;
用法示例:

import React, { useState, useCallback, useEffect } from 'react';
import { Input as AntInput, Popconfirm as AntPopconfirm } from 'antd';
import useToggle from './useToggle';
const Input = ({ value }) => {
const [currentValue, setCurrentValue] = useState(value);
const reset = useCallback(() => {
setCurrentValue(value);
}, [value]);
const { inputRef, readonly, setReadonly, toggleEdit, onReset, onVisibleChange } = useToggle({ reset });
const handleSubmit = useCallback(() => {
setReadonly(true);
}, [setReadonly]);
useEffect(() => {
setCurrentValue(value);
}, [value]);
return (
<div className='desc-item'>
{readonly ? (
<>
{/* 查看模式 */}
<span>{currentValue || '-'}</span>
<button className='desc-item-button' onClick={toggleEdit}>
编辑
</button>
</>
) : (
<>
{/* 编辑模式 */}
<AntInput className='desc-item-input' ref={inputRef} defaultValue={value} onBlur={onReset} />
<AntPopconfirm onConfirm={handleSubmit} onVisibleChange={onVisibleChange}>
<button className='desc-item-button'>保存</button>
</AntPopconfirm>
</>
)}
</div>
);
};
export default Input;
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具