问题起源
距离下班只剩十分钟,测试同学拿着 iPhone 过来,说用户登录页有点不对劲。
用户名框看着像拿到了焦点,键盘却没跟上。切到密码框后,用户名框又自己亮了。下班计划先放一边,得把这个只在小程序 iOS 环境稳定复现的问题弄明白。
登录页里有 NameInput 和 PasswordInput 两个输入框。

真机上能看到几件事:

- 点击输入框后,焦点边框不能及时生效;
- 快速切换用户名和密码输入框时,旧输入框可能在延迟的
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 组件能跑在小程序里,同时也把原生输入框的事件时序带进了组件树。浏览器里习惯的 focus 和 blur 顺序,在真机上不能照单全收。
iOS 到底出了什么事
Web 表单里,焦点切换大多按下面的顺序发生。
小程序 iOS 的事件有时会晚到,顺序也可能乱掉。
原生 onFocus 到了,不代表这个输入框此刻还该亮着。
这是 Taro 的顽疾,还是小程序的顽疾
先把锅分清楚。这个问题的源头更接近微信小程序 iOS 的原生 Input,Taro 在上面做了一层 React 组件适配,躲不开这套事件时序。
早些年的微信开放社区里,开发者已经描述过同一种现象。iOS 上键盘开始弹出后,focus 回调可能要等键盘动画结束才到。用户如果在这段时间里做了别的操作,blur 反而会先发生,最后才等到那次迟到的 focus。从页面逻辑看,事件顺序就倒过来了。
这份反馈没有给出微信客户端内部实现,也没有承诺固定的延迟时长。它仍然足够说明一件事。把 focus 当成"用户刚刚点了这个输入框"的唯一信号,在 iPhone 小程序里并不可靠。
Taro 没有凭空制造这个问题。它最终渲染的还是小程序原生 Input,focus、blur、键盘动画和同层渲染都要遵守宿主环境的规则。Taro 在 2020 年为微信小程序补过 alwaysEmbed 属性,解决的是 iOS 输入框聚焦时的同层显示问题。这个补丁处理的是渲染层级,和本文的事件乱序不是同一件事,却能看出框架一直在适配原生输入框的边界。
React 和 Taro 这一层会让问题更显眼。原生事件晚到以后,组件状态已经因为下一次点击更新了。受控 focus 属性、重新渲染和表单校验又会继续消费这次旧事件,最后呈现成边框亮错、校验时机不对,或者键盘状态看着别扭。
所以这不是一句"升级 Taro 就能好"的问题。升级框架当然值得做,它能修复适配层自己的缺陷。输入框交互仍要把用户的触摸操作、原生焦点事件和页面视觉状态分开处理,才能扛住宿主环境的乱序。
相关记录
解决思路
动手前先记住一个很朴素的办法。
没有什么是加一层不能解决的。如果有,那就再加一层。
原生 Input 的 focus 到得晚,直接等它来改样式就会被事件时序牵着走。那就不要把所有事都交给它。我们在 Input 外面包一层 View,先从 View 的触摸事件里拿到用户刚刚点了哪里,再决定 Input 的焦点样式何时生效。
这层 View 不负责替代原生输入框。键盘、光标和真实输入仍由 Input 处理。它只负责抢到更早的交互信号,把边框状态先稳住。
有了这层分工,新的实现其实就围绕下面几件事展开。
- 用户点击输入框时,焦点样式应立即反馈;
- 同一表单中只能有一个输入框处于视觉焦点态;
- 延迟到达的旧
focus事件不能抢回焦点; - 不伪造原生
blur事件; - 点击清除、切换密码可见性等附属操作时,不应错误激活输入框;
- 保持现有 iOS 兼容逻辑,不通过定时器猜测事件时序。
实施方案:给焦点加一张准入证
外层 View 解决了第一步。用户一按下去,页面就能马上给边框反馈。接下来还有另一件事要处理。页面里不止一个输入框,迟到的原生事件也不会只出现一次。
这类逻辑不该塞进 NameInput 或 PasswordInput。它们各自要处理值、占位文案、附属按钮,焦点仲裁和这些业务没有关系。直接写进去会很快复制出两份相近代码,后面改一个边界条件就得四处找。
这里选了自定义 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 小程序里,blur 和 focus 也会乱序。过早收证,正在输入的框可能被误判成失活,边框消失,输入也跟着不对。
这里保留了一个取舍。
- 原生
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;
}
这样一来,就不会发生下面这件事。
这几个组件怎么分工
NameInput与PasswordInput复用基础Input;Input负责原生事件与视觉焦点状态;useInputFocus负责多输入框焦点仲裁;LoginFormItem负责将原生失焦和被动失焦映射为表单校验。
修复效果

这套办法还没解决什么?
当前用全局 eventCenter 协调输入框,单个登录或注册表单用起来没问题。
下面几类场景还要继续盯着。
- 同页面存在多个独立表单;
- 多层弹窗中同时挂载多个输入框;
- 页面隐藏但组件未卸载;
- 跨页面或跨业务模块共享运行时事件中心。
后面可以引入 InputFocusScope,把焦点互斥限制在一个表单或弹层里。
tsx
<InputFocusScope>
<NameInput />
<PasswordInput />
</InputFocusScope>
小结
回头看,真正要守住的只有一件事:原生事件会迟到,界面不能跟着误判。
输入框该不该亮,由当前这次交互决定。
代码里做的事不复杂。
- 把激活资格和视觉焦点分开保存;
- 用唯一 ID 拦住迟到的事件;
- 新输入框激活时,让旧输入框退出;
- 用户刚点下去,边框先给出反馈;
- 用
onDeactivate传递被替代的状态; - 清除和密码可见性按钮不参与抢焦点。
整个过程没有塞进 setTimeout,也没有为某一台 iPhone 猜一个延迟时间。以后再遇到焦点乱序,顺着谁被激活、谁该退出这条线查下去,问题会好拆很多。