小程序 iOS 多输入框焦点乱序脱坑记

问题起源

距离下班只剩十分钟,测试同学拿着 iPhone 过来,说用户登录页有点不对劲。

用户名框看着像拿到了焦点,键盘却没跟上。切到密码框后,用户名框又自己亮了。下班计划先放一边,得把这个只在小程序 iOS 环境稳定复现的问题弄明白。

登录页里有 NameInputPasswordInput 两个输入框。

真机上能看到几件事:

  • 点击输入框后,焦点边框不能及时生效;
  • 快速切换用户名和密码输入框时,旧输入框可能在延迟的 focus 事件到达后重新展示焦点态;
  • 清除输入、切换密码可见性等附属操作可能触发输入框焦点状态异常;
  • 依赖原生 blur / focus 事件的表单校验与视觉状态不稳定。

根因落在小程序原生事件的时序,以及它和 React 状态更新之间的配合。

登录组件技术栈

问题发生在一套 Taro 小程序组件库里。页面用 React 函数组件和 Hooks 写,TypeScript 负责把输入框、表单项和回调之间的约束写清楚。

位置 选择 这次排查里做了什么
跨端运行时 Taro 4 提供小程序 Input、触摸事件和 eventCenter
UI 编写方式 React 18 + Hooks useState 维护视觉焦点,用 useRef 保存不会触发渲染的激活资格
类型约束 TypeScript 5 区分原生 onBlur 和显式 onDeactivate,不再把空对象伪装成事件
基础组件 NutUI React Taro 提供登录页里的按钮、Popover 等通用交互
样式 SCSS is-focus 类控制输入框边框,不依赖小程序缺失的 :focus-within

这里最容易被忽略的是 Taro 的角色。它让 React 组件能跑在小程序里,同时也把原生输入框的事件时序带进了组件树。浏览器里习惯的 focusblur 顺序,在真机上不能照单全收。

iOS 到底出了什么事

Web 表单里,焦点切换大多按下面的顺序发生。

flowchart LR A[点击输入框] --> B[触发 focus] B --> C[展示焦点态] D[切换输入框] --> E[触发 blur] E --> F[触发新输入框的 focus] F --> G[切换焦点态]

小程序 iOS 的事件有时会晚到,顺序也可能乱掉。

sequenceDiagram participant U as 用户 participant C as 密码输入框 participant P as 用户名输入框 U->>C: 点击密码输入框 C->>C: 开始获得焦点 P-->>P: 延迟的旧 focus 事件到达 P->>P: 错误恢复焦点样式

原生 onFocus 到了,不代表这个输入框此刻还该亮着。

这是 Taro 的顽疾,还是小程序的顽疾

先把锅分清楚。这个问题的源头更接近微信小程序 iOS 的原生 Input,Taro 在上面做了一层 React 组件适配,躲不开这套事件时序。

早些年的微信开放社区里,开发者已经描述过同一种现象。iOS 上键盘开始弹出后,focus 回调可能要等键盘动画结束才到。用户如果在这段时间里做了别的操作,blur 反而会先发生,最后才等到那次迟到的 focus。从页面逻辑看,事件顺序就倒过来了。

这份反馈没有给出微信客户端内部实现,也没有承诺固定的延迟时长。它仍然足够说明一件事。把 focus 当成"用户刚刚点了这个输入框"的唯一信号,在 iPhone 小程序里并不可靠。

Taro 没有凭空制造这个问题。它最终渲染的还是小程序原生 Inputfocusblur、键盘动画和同层渲染都要遵守宿主环境的规则。Taro 在 2020 年为微信小程序补过 alwaysEmbed 属性,解决的是 iOS 输入框聚焦时的同层显示问题。这个补丁处理的是渲染层级,和本文的事件乱序不是同一件事,却能看出框架一直在适配原生输入框的边界。

React 和 Taro 这一层会让问题更显眼。原生事件晚到以后,组件状态已经因为下一次点击更新了。受控 focus 属性、重新渲染和表单校验又会继续消费这次旧事件,最后呈现成边框亮错、校验时机不对,或者键盘状态看着别扭。

所以这不是一句"升级 Taro 就能好"的问题。升级框架当然值得做,它能修复适配层自己的缺陷。输入框交互仍要把用户的触摸操作、原生焦点事件和页面视觉状态分开处理,才能扛住宿主环境的乱序。

相关记录

解决思路

动手前先记住一个很朴素的办法。

没有什么是加一层不能解决的。如果有,那就再加一层。

原生 Inputfocus 到得晚,直接等它来改样式就会被事件时序牵着走。那就不要把所有事都交给它。我们在 Input 外面包一层 View,先从 View 的触摸事件里拿到用户刚刚点了哪里,再决定 Input 的焦点样式何时生效。

这层 View 不负责替代原生输入框。键盘、光标和真实输入仍由 Input 处理。它只负责抢到更早的交互信号,把边框状态先稳住。

有了这层分工,新的实现其实就围绕下面几件事展开。

  1. 用户点击输入框时,焦点样式应立即反馈;
  2. 同一表单中只能有一个输入框处于视觉焦点态;
  3. 延迟到达的旧 focus 事件不能抢回焦点;
  4. 不伪造原生 blur 事件;
  5. 点击清除、切换密码可见性等附属操作时,不应错误激活输入框;
  6. 保持现有 iOS 兼容逻辑,不通过定时器猜测事件时序。

实施方案:给焦点加一张准入证

外层 View 解决了第一步。用户一按下去,页面就能马上给边框反馈。接下来还有另一件事要处理。页面里不止一个输入框,迟到的原生事件也不会只出现一次。

这类逻辑不该塞进 NameInputPasswordInput。它们各自要处理值、占位文案、附属按钮,焦点仲裁和这些业务没有关系。直接写进去会很快复制出两份相近代码,后面改一个边界条件就得四处找。

这里选了自定义 Hook。它把状态、事件订阅和组件卸载时的清理放在一起,对外只提供"激活""尝试聚焦""失焦"这几个动作。Input 继续管结构和样式,登录页里的具体输入框只复用 Input,不用知道焦点是怎么协调的。

相比高阶组件和 render props,Hook 不会多包一层渲染节点。小程序输入框已经有原生组件和外层 View,再加无关节点,排查触摸冒泡和布局会更麻烦。相比把状态提到表单父组件,Hook 也更适合这件事。每个输入框保留自己的状态,互斥关系通过一个小事件协议完成。有了思路后就可以让 AI 实现 useInputFocus hook。

useInputFocus 里拥有两个状态。先看一遍伪代码。重点是激活资格和视觉焦点分开保存,其他输入框激活时,当前输入框立刻退出。

ts 复制代码
全局维护一个"有输入框被激活"的事件

useInputFocus(onDeactivate):
  为当前输入框生成唯一 inputId
  active = false
  isFocus = false

  activateInput():
    active = true
    广播"inputId 被激活"

  focusInput():
    如果 active 为 false:
      返回 false
    isFocus = true
    返回 true

  blurInput():
    isFocus = false

  订阅"有输入框被激活"事件:
    如果被激活的不是当前 inputId:
      active = false
      isFocus = false
      调用 onDeactivate

  组件卸载时:
    取消订阅

  返回 isFocus、activateInput、focusInput、blurInput
状态 含义
activeRef 当前输入框是否仍有资格响应原生延迟 focus
isFocus 当前输入框是否展示焦点样式,并同步到 Taro Input.focus
  • activeRef 用于处理事件乱序;
  • isFocus 用于控制 UI 边框和原生输入框的焦点请求。
ts 复制代码
const activeRef = useRef(false);
const [isFocus, setIsFocus] = useState(false);

用户点到一个输入框时,先给它发准入证。

ts 复制代码
const activateInput = useCallback(() => {
  activeRef.current = true;
  eventCenter.trigger(deactivateInputEventName, { activeInputId: inputId });
}, [inputId]);

原生 focus 晚到了,也得先验这张证。

ts 复制代码
const focusInput = useCallback(() => {
  if (!activeRef.current) return false;

  setIsFocus(true);
  return true;
}, []);

旧输入框就算收到了迟到的 focus,也不会再抢回焦点样式。

防止两个输入框互相抢焦点

每个输入框有自己的 ID。

ts 复制代码
const inputId = useMemo(() => {
  const id = `input-${inputSeq}`;
  inputSeq += 1;
  return id;
}, []);

它被激活时,会通过 Taro eventCenter 通知其他输入框。

ts 复制代码
eventCenter.trigger(deactivateInputEventName, { activeInputId: inputId });

其他输入框收到通知后,会交出准入证,顺手清掉自己的视觉焦点。

ts 复制代码
if (activeInputId !== inputId) {
  activeRef.current = false;
  setIsFocus(false);
  onDeactivateRef.current?.();
}

切换过程会变成这样。

text 复制代码
用户名输入框激活
→ 用户名输入框拥有激活资格和焦点态

用户点击密码输入框
→ 密码输入框获得激活资格
→ 用户名输入框取消激活资格并移除焦点态
→ 用户名输入框后续迟到的 focus 事件被忽略

为什么 blur 来了也不能急着收证

直觉上,原生 blur 发生时就该执行下面这行。

ts 复制代码
activeRef.current = false;

可是在 iOS 小程序里,blurfocus 也会乱序。过早收证,正在输入的框可能被误判成失活,边框消失,输入也跟着不对。

这里保留了一个取舍。

  • 原生 blur:仅清除视觉焦点;
  • 其他输入框被激活:取消旧输入框的激活资格;
  • 页面级销毁、隐藏或未来 Scope 卸载:统一清理全部输入框资格。
ts 复制代码
const blurInput = useCallback(() => {
  setIsFocus(false);
}, []);

useInputFocus完整代码

ts 复制代码
import { eventCenter } from '@tarojs/taro';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

let inputSeq = 0;
const deactivateInputEventName = 'taro-deactivate-input';

interface IUseUniqueFocusParams {
  /** 被其他输入框激活后失去视觉焦点 */
  onDeactivate?: () => void;
}

export function useInputFocus(params: IUseUniqueFocusParams = {}) {
  const { onDeactivate } = params;

  // inputId唯一标识每个input
  const inputId = useMemo(() => {
    const id = `input-${inputSeq}`;
    inputSeq += 1;
    return id;
  }, []);

  // 标记是否活跃(关键实现: 聚焦前需要处于活跃状态,否则丢弃聚焦)
  const activeRef = useRef(false);

  const onDeactivateRef = useRef(onDeactivate);
  onDeactivateRef.current = onDeactivate;

  // 标记是否聚焦
  const [isFocus, setIsFocus] = useState(false);

  // 激活input操作(关键实现: 排他操作,一个input激活将失活所有其他input,并false化其他input聚焦状态)
  const activateInput = useCallback(() => {
    activeRef.current = true;
    eventCenter.trigger(deactivateInputEventName, { activeInputId: inputId });
  }, [inputId]);

  // 聚焦input操作
  const focusInput = useCallback(() => {
    if (!activeRef.current) return false;
    setIsFocus(true);
    return true;
  }, []);

  // 失焦input操作
  const blurInput = useCallback(() => {
    setIsFocus(false);
  }, []);

  useEffect(() => {
    const deactivateInput = ({ activeInputId }: { activeInputId: string }) => {
      if (activeInputId !== inputId) {
        activeRef.current = false;
        setIsFocus(false);
        onDeactivateRef.current?.();
      }
    };
    eventCenter.on(deactivateInputEventName, deactivateInput);
    return () => {
      eventCenter.off(deactivateInputEventName, deactivateInput);
    };
  }, [inputId]);

  return { inputId, isFocus, activateInput, focusInput, blurInput };
}

边框先亮起来

原生 onFocus 有延迟。等它到了再改边框,用户已经觉得页面没反应了。

所以触摸开始时,容器先把输入框激活。

tsx 复制代码
<View
  className={uniteClass(className, `${PREFIX}-input`, {
    'is-focus': isFocus,
  })}
  onTouchStart={() => {
    activateInput();
  }}
>

点击输入框后,立即更新视觉焦点。

ts 复制代码
const handleClick = useCallback(
  (event: ITouchEvent) => {
    onClick?.(event);

    if (!isFocus) {
      activateInput();
      focusInput();
    }
  },
  [onClick, isFocus, activateInput, focusInput],
);

原生 focus 到达后再做一次兜底同步。

ts 复制代码
const handleFocus = useCallback(
  (event: BaseEventOrig<TaroInputProps.inputForceEventDetail>) => {
    clearingRef.current = false;

    if (focusInput()) {
      onFocus?.(event);
    }
  },
  [onFocus, focusInput],
);

边框样式仍由 is-focus 控制。

scss 复制代码
.login-input {
  &.is-focus {
    border-color: var(--login-input-border-color-hover, var(--login-color-primary));
  }
}

别拿空对象冒充 blur

旧实现里,一个输入框被另一个输入框替代时,没有可靠的原生 blur 事件,于是传了一个空对象。

ts 复制代码
const PASSIVE_BLUR_EVENT = {} as InputBlurEvent;
onBlur?.(PASSIVE_BLUR_EVENT);

这会留下三个坑。

  • 事件对象不真实;
  • 业务回调如果读取 event.detail,可能运行时报错;
  • 被动失焦与原生 blur 都可能触发,容易导致重复校验。

后来加了显式的 onDeactivate

ts 复制代码
interface IUseInputFocusParams {
  onDeactivate?: () => void;
}

焦点协调 Hook 只传递一件事,当前输入框被另一个输入框替代了。

ts 复制代码
onDeactivateRef.current?.();

表单层收到这个信号后,按 blur 规则校验。它不需要假装自己收到了原生事件。

ts 复制代码
const handleControlDeactivate = useCallback(() => {
  api?.runFieldValidate(name, 'blur');
}, [api, name]);
tsx 复制代码
cloneElement(child, {
  onChange: handleControlChange,
  onBlur: handleControlBlur,
  onDeactivate: handleControlDeactivate,
});

各层的事也更清楚了。

场景 回调
原生输入框真实失焦 onBlur(event)
其他输入框被激活导致当前控件退出焦点态 onDeactivate()
表单失焦校验 两种场景均可触发

小按钮别来抢焦点

密码可见性切换按钮和清除按钮都在输入框容器里。触摸事件一路冒泡到容器,就可能触发 activateInput()

这两个位置得先把事件拦住。

tsx 复制代码
<View
  className="login-input__clear"
  onTouchStart={(event) => {
    event.stopPropagation();
    clearingRef.current = true;
  }}
>

密码可见性切换按钮作为 suffix 时,也用容器隔离触摸事件。

tsx 复制代码
{
  suffix ? <View onTouchStart={(event) => event.stopPropagation()}>{suffix}</View> : null;
}

这样一来,就不会发生下面这件事。

sequenceDiagram participant U as 用户 participant T as 密码可见性按钮 participant I as 输入框容器 U->>T: 点击切换密码可见性 T-->>I: touchstart 冒泡 I->>I: 错误执行 activateInput I->>U: 焦点边框或键盘状态异常

这几个组件怎么分工

flowchart TB NameInput --> Input PasswordInput --> Input Input --> useInputFocus LoginFormItem -->|注入 onChange| Input LoginFormItem -->|注入 onBlur| Input LoginFormItem -->|注入 onDeactivate| Input
  • NameInputPasswordInput 复用基础 Input
  • Input 负责原生事件与视觉焦点状态;
  • useInputFocus 负责多输入框焦点仲裁;
  • LoginFormItem 负责将原生失焦和被动失焦映射为表单校验。

修复效果

这套办法还没解决什么?

当前用全局 eventCenter 协调输入框,单个登录或注册表单用起来没问题。

下面几类场景还要继续盯着。

  • 同页面存在多个独立表单;
  • 多层弹窗中同时挂载多个输入框;
  • 页面隐藏但组件未卸载;
  • 跨页面或跨业务模块共享运行时事件中心。

后面可以引入 InputFocusScope,把焦点互斥限制在一个表单或弹层里。

tsx 复制代码
<InputFocusScope>
  <NameInput />
  <PasswordInput />
</InputFocusScope>

小结

回头看,真正要守住的只有一件事:原生事件会迟到,界面不能跟着误判。

输入框该不该亮,由当前这次交互决定。

代码里做的事不复杂。

  1. 把激活资格和视觉焦点分开保存;
  2. 用唯一 ID 拦住迟到的事件;
  3. 新输入框激活时,让旧输入框退出;
  4. 用户刚点下去,边框先给出反馈;
  5. onDeactivate 传递被替代的状态;
  6. 清除和密码可见性按钮不参与抢焦点。

整个过程没有塞进 setTimeout,也没有为某一台 iPhone 猜一个延迟时间。以后再遇到焦点乱序,顺着谁被激活、谁该退出这条线查下去,问题会好拆很多。

相关推荐
2501_915909069 小时前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
随遇丿而安11 小时前
第 1 周:别小看 `UILabel`,它跟 Android TextView 不只是名字不同
ios
Greg_Zhong2 天前
微信小程序 + 腾讯云人体分析:从 0 到 1 实现 AI 抠图打卡合照(细节待更新~)
人工智能·微信小程序·腾讯云·ai抠图-ai打卡拍照
游戏开发爱好者82 天前
iOS 推送怎么配置,APNs 推送证书、设备库与群发
android·ios·小程序·https·uni-app·iphone·webview
Anhty2 天前
2026 实测 4 款 AI 变声器|QQ 聊天伪装声线,告别僵硬假声
人工智能·功能测试·ios·智能手机·安卓
唔663 天前
flutter web iOS 在浏览器加载中文慢的问题
前端·flutter·ios
ZacJi3 天前
我为 DeepSeek Harness 写了一个 dsh-apple-mode 插件
ios·deepseek·vibecoding
2501_915921433 天前
抓包鹰 Traceeagle 解除证书绑定,SSL Pinning 解除
网络·网络协议·网络安全·ios·adb·https·ssl
人月神话Lee3 天前
我做了个不要账号、不要定位权限的旅行 App,聊聊那些「不做」的决定
ios·ai编程·产品