Talk is cheap. Show me your code

处理 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;
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;
demo
复制代码

 

posted @   Wise.Wrong  阅读(419)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示