问题起源
距离下班只剩十分钟,测试同学拿着 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 表单里,焦点切换大多按下面的顺序发生。
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 没有凭空制造这个问题。它最终渲染的还是小程序原生 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 里拥有两个状态。先看一遍伪代码。重点是激活资格和视觉焦点分开保存,其他输入框激活时,当前输入框立刻退出。
全局维护一个“有输入框被激活”的事件
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 边框和原生输入框的焦点请求。
const activeRef = useRef(false);
const [isFocus, setIsFocus] = useState(false);
用户点到一个输入框时,先给它发准入证。
const activateInput = useCallback(() => {
activeRef.current = true;
eventCenter.trigger(deactivateInputEventName, { activeInputId: inputId });
}, [inputId]);
原生 focus 晚到了,也得先验这张证。
const focusInput = useCallback(() => {
if (!activeRef.current) return false;
setIsFocus(true);
return true;
}, []);
旧输入框就算收到了迟到的 focus,也不会再抢回焦点样式。
防止两个输入框互相抢焦点
每个输入框有自己的 ID。
const inputId = useMemo(() => {
const id = `input-${inputSeq}`;
inputSeq += 1;
return id;
}, []);
它被激活时,会通过 Taro eventCenter 通知其他输入框。
eventCenter.trigger(deactivateInputEventName, { activeInputId: inputId });
其他输入框收到通知后,会交出准入证,顺手清掉自己的视觉焦点。
if (activeInputId !== inputId) {
activeRef.current = false;
setIsFocus(false);
onDeactivateRef.current?.();
}
切换过程会变成这样。
用户名输入框激活
→ 用户名输入框拥有激活资格和焦点态
用户点击密码输入框
→ 密码输入框获得激活资格
→ 用户名输入框取消激活资格并移除焦点态
→ 用户名输入框后续迟到的 focus 事件被忽略
为什么 blur 来了也不能急着收证
直觉上,原生 blur 发生时就该执行下面这行。
activeRef.current = false;
可是在 iOS 小程序里,blur 和 focus 也会乱序。过早收证,正在输入的框可能被误判成失活,边框消失,输入也跟着不对。
这里保留了一个取舍。
- 原生
blur:仅清除视觉焦点; - 其他输入框被激活:取消旧输入框的激活资格;
- 页面级销毁、隐藏或未来 Scope 卸载:统一清理全部输入框资格。
const blurInput = useCallback(() => {
setIsFocus(false);
}, []);
useInputFocus完整代码
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 有延迟。等它到了再改边框,用户已经觉得页面没反应了。
所以触摸开始时,容器先把输入框激活。
<View
className={uniteClass(className, `${PREFIX}-input`, {
'is-focus': isFocus,
})}
onTouchStart={() => {
activateInput();
}}
>
点击输入框后,立即更新视觉焦点。
const handleClick = useCallback(
(event: ITouchEvent) => {
onClick?.(event);
if (!isFocus) {
activateInput();
focusInput();
}
},
[onClick, isFocus, activateInput, focusInput],
);
原生 focus 到达后再做一次兜底同步。
const handleFocus = useCallback(
(event: BaseEventOrig<TaroInputProps.inputForceEventDetail>) => {
clearingRef.current = false;
if (focusInput()) {
onFocus?.(event);
}
},
[onFocus, focusInput],
);
边框样式仍由 is-focus 控制。
.login-input {
&.is-focus {
border-color: var(--login-input-border-color-hover, var(--login-color-primary));
}
}
别拿空对象冒充 blur
旧实现里,一个输入框被另一个输入框替代时,没有可靠的原生 blur 事件,于是传了一个空对象。
const PASSIVE_BLUR_EVENT = {} as InputBlurEvent;
onBlur?.(PASSIVE_BLUR_EVENT);
这会留下三个坑。
- 事件对象不真实;
- 业务回调如果读取
event.detail,可能运行时报错; - 被动失焦与原生 blur 都可能触发,容易导致重复校验。
后来加了显式的 onDeactivate。
interface IUseInputFocusParams {
onDeactivate?: () => void;
}
焦点协调 Hook 只传递一件事,当前输入框被另一个输入框替代了。
onDeactivateRef.current?.();
表单层收到这个信号后,按 blur 规则校验。它不需要假装自己收到了原生事件。
const handleControlDeactivate = useCallback(() => {
api?.runFieldValidate(name, 'blur');
}, [api, name]);
cloneElement(child, {
onChange: handleControlChange,
onBlur: handleControlBlur,
onDeactivate: handleControlDeactivate,
});
各层的事也更清楚了。
| 场景 | 回调 |
|---|---|
| 原生输入框真实失焦 | onBlur(event) |
| 其他输入框被激活导致当前控件退出焦点态 | onDeactivate() |
| 表单失焦校验 | 两种场景均可触发 |
小按钮别来抢焦点
密码可见性切换按钮和清除按钮都在输入框容器里。触摸事件一路冒泡到容器,就可能触发 activateInput()。
这两个位置得先把事件拦住。
<View
className="login-input__clear"
onTouchStart={(event) => {
event.stopPropagation();
clearingRef.current = true;
}}
>
密码可见性切换按钮作为 suffix 时,也用容器隔离触摸事件。
{
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
NameInput与PasswordInput复用基础Input;Input负责原生事件与视觉焦点状态;useInputFocus负责多输入框焦点仲裁;LoginFormItem负责将原生失焦和被动失焦映射为表单校验。
修复效果
这套办法还没解决什么?
当前用全局 eventCenter 协调输入框,单个登录或注册表单用起来没问题。
下面几类场景还要继续盯着。
- 同页面存在多个独立表单;
- 多层弹窗中同时挂载多个输入框;
- 页面隐藏但组件未卸载;
- 跨页面或跨业务模块共享运行时事件中心。
后面可以引入 InputFocusScope,把焦点互斥限制在一个表单或弹层里。
<InputFocusScope>
<NameInput />
<PasswordInput />
</InputFocusScope>
小结
回头看,真正要守住的只有一件事:原生事件会迟到,界面不能跟着误判。
输入框该不该亮,由当前这次交互决定。
代码里做的事不复杂。
- 把激活资格和视觉焦点分开保存;
- 用唯一 ID 拦住迟到的事件;
- 新输入框激活时,让旧输入框退出;
- 用户刚点下去,边框先给出反馈;
- 用
onDeactivate传递被替代的状态; - 清除和密码可见性按钮不参与抢焦点。
整个过程没有塞进 setTimeout,也没有为某一台 iPhone 猜一个延迟时间。以后再遇到焦点乱序,顺着谁被激活、谁该退出这条线查下去,问题会好拆很多。