前阵子我接手了一个跑在 OpenHarmony 设备上的 React Native 项目,里面有个注册页面,字段不算特别多,也就用户名、密码、确认密码、手机号、验证码这么几个,但我一看代码头就大了:十几个 useState 散落在组件里,校验逻辑东一段西一段,提交的时候还要手动判断各种状态。改了几次需求之后,我决定把整个表单状态管理换成 useReducer,顺手把 OpenHarmony 上的一些适配细节一起梳理了一遍。这篇文章就把我踩过的坑、最终落地的方案、以及为什么这么设计,完整记录下来。
如果你也在 OpenHarmony 上用 React Native 写界面,或者你的 RN 表单开始变得“牵一发动全身”,那这篇文章应该能给你省不少时间。不只是贴一段 useReducer 的代码,我会把状态结构怎么设计、action 怎么划分、校验什么时候触发、提交流程怎么防重复点击这些实操细节全讲清楚。
1. 为什么我改用useReducer管理RN表单状态
1.1 useState处理表单的痛点:状态零散、联动全靠缘分
用 useState 管理单个简单字段确实很爽,比如一个搜索框、一个开关。但表单一旦超过三四个字段,状态之间开始互相牵扯,问题就来了。
我最早看到的写法是这样的:
const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const [submitting, setSubmitting] = useState(false);看着还好对吧?但真实项目里,用户名输入之后可能要立刻清掉旧错误,密码输入之后要同时判断密码强度、还要把确认密码的错误清掉,验证码倒计时是另一组状态,提交按钮的 loading 又要跟后端返回的错误合并到 errors 对象里。
等你把这些逻辑写完,会发现handleChange里同时调用四五个 setState,而且每个 setState 都要读取当前 state 去计算下一个 state。这时候最典型的坑就出现了:你希望在输入密码时“顺手”重新校验确认密码,但确认密码的校验需要依赖 password 的最新值。如果两个 setState 进的是同一个事件处理函数,React 18 里虽然是自动批处理,可如果你写的是setErrors(errors => ...)这种函数式更新,逻辑还能凑合;一旦你直接引用了外面那个errors变量,拿到的一定是旧值。
表单字段越多,这种“值、错误、触碰状态”三位一体状态被拆得越散,每次操作都要分散在好几个地方同步更新。这种代码不是不能跑,是改起来太痛苦,加一个字段要动的地方太多了。
1.2 useReducer把状态变迁收拢成一台“状态机”
useReducer 解决的核心问题,不是让你少写代码,而是把状态从“零散的变量”变成“一个可控的对象”,并且让所有状态变化都通过 action 来驱动。
你可以把它想象成一个自动售货机:你按下一个按钮(dispatch 一个 action),售货机内部根据当前状态和按钮指令,决定下一状态是什么。外部只负责“按按钮”和“读结果”,不负责直接改机器内部的结构。
对应到表单场景:
- 用户输入字符,dispatch
FIELD_CHANGE - 输入框失焦,dispatch
FIELD_BLUR - 点击提交,dispatch
SUBMIT_START - 后端返回错误,dispatch
SUBMIT_FAILURE - 校验通过,dispatch
SUBMIT_SUCCESS
reducer 作为一个纯函数,入参是“当前状态+action”,出参是“新状态”。它不发请求、不读外部变量、不产生副作用。这就带来两个直接好处:一是所有状态流转在同一个文件里就能看清,二是这个纯函数可以直接做单元测试。
我在第四部分会专门讲测试,这里先给结论:如果表单的校验规则、错误展示、提交链路开始变得复杂,useReducer 几乎总是比一堆 useState 更稳。
2. OpenHarmony上跑RN表单:适配细节别跳坑
2.1 RN在OpenHarmony上的渲染链路和常规RN的差异
OpenHarmony 上跑 React Native,不是把 JS Bundle 丢进去就完了。RN 的组件最终要落到原生控件上,在 OpenHarmony 这边走的是 OpenHarmony 适配层的组件映射,底层的TextInput会对应到 ArkUI 的输入框组件。大部分业务逻辑确实可以跨平台复用,但输入法、焦点、键盘这类跟系统强相关的行为,跟 iOS/Android 并不完全一致。
举个实际例子:iOS 上输入框失焦事件一般按“点击空白区域收起键盘”触发,Android 原生把 windowSoftInputMode 调成 adjustResize 之后,键盘弹出会压缩布局。OpenHarmony 上不同版本的适配层对键盘行为和焦点事件的处理细节也在演进,我一度遇到onBlur在某些输入法下不触发的情况。
这点对表单状态管理影响很大:如果你把“校验”这件事完全挂在onBlur上,一旦onBlur没触发,错误提示就一直不出现,用户会以为已经填对了。
所以我在方案里的一个原则是:touched 状态要尽量在需要时由我们自己标记,而不是完全依赖原生事件的时序。校验函数选择在 reducer 内统一计算,谁触发校验、什么时候校验,都显式控制,不让平台事件差异影响业务一致性。
2.2 控件行为差异:键盘类型、returnKeyType和中文输入法
除了 onBlur,还有两个差异也要提醒:
第一个是keyboardType和returnKeyType。在 RN 标准实现里,returnKeyType可以用来把键盘右下角变成“完成”“搜索”“下一步”。但 OpenHarmony 的适配层对不同输入法和键盘类型支持得层次不齐。有些输入法根本不鸟returnKeyType,你写了done,它还是显示一个换行。
第二个是中文输入法下的组合输入问题。RN 的onChangeText在原生层拿到的是输入法上屏后的最终字符串,正常情况下没问题。但某些输入法在拼音组合过程中会触发额外的 selection 事件,如果你在 reducer 里用event.nativeEvent.text这种更底层的数据,就比较容易踩到中间态。
我的建议是:表单输入统一用onChangeText拿字符串,别去依赖那些跟平台强相关的原生事件字段。校验和状态更新全部走 action,不要直接在原生回调里改状态。
3. 基于useReducer的表单状态管理完整实现
3.1 先定义状态结构和Action协议
重构第一步,不是写代码,而是把状态结构想清楚。我用 TypeScript 定义表单状态和 action:
type FormState = { fields: Record<string, string>; // 所有字段值 errors: Record<string, string | undefined>; // 字段错误信息 touched: Record<string, boolean>; // 字段是否被触碰过 submitting: boolean; // 是否提交中 submitCount: number; // 第几次点击提交 }; type FormAction = | { type: 'FIELD_CHANGE'; name: string; value: string } | { type: 'FIELD_BLUR'; name: string } | { type: 'SUBMIT_START' } | { type: 'SUBMIT_FAILURE'; errors: Record<string, string | undefined> } | { type: 'SUBMIT_SUCCESS' } | { type: 'RESET'; initialFormState: FormState };为什么这么设计?
fields统一存放所有输入框的值,用 Record 而不是单独给每个字段建一个 state,是为了让 reducer 能通用处理字段变更,而不是每加一个字段就写一个 case。touched单独拿出来,是为了实现“用户还没碰过的字段不显示错误”。否则一进页面满屏红字,体验非常差。submitCount用来自动记录用户点了几次提交。第三次提交后,即使某个字段没被点过,也应该显示错误了——这是常见业务规则,单独记个数字比用 touched 去平铺所有情况简单得多。
对应 reducer 实现:
const initialFormState: FormState = { fields: { username: '', password: '', confirmPassword: '' }, errors: {}, touched: {}, submitting: false, submitCount: 0, }; function formReducer(state: FormState, action: FormAction): FormState { switch (action.type) { case 'FIELD_CHANGE': { const fields = { ...state.fields, [action.name]: action.value }; return { ...state, fields }; } case 'FIELD_BLUR': { const touched = { ...state.touched, [action.name]: true }; return { ...state, touched }; } case 'SUBMIT_START': return { ...state, submitting: true, submitCount: state.submitCount + 1 }; case 'SUBMIT_FAILURE': return { ...state, submitting: false, errors: action.errors }; case 'SUBMIT_SUCCESS': return { ...state, submitting: false, errors: {} }; case 'RESET': return action.initialFormState; default: return state; } }可能有朋友会问:FIELD_CHANGE里为什么不顺手把当前字段的错误清掉?我一开始也这么做过,后来发现如果FIELD_CHANGE时直接把 errors 清空,后端返回的“用户名已被占用”这种错误,会在用户改一个字之后立刻消失,如果当时正在防抖校验别的字段,会显得错误提示闪来闪去。
所以我现在的做法是:输入阶段不清 errors,只在执行正式校验时更新。用户是否看到错误,由touched和submitCount控制。这样更可控。
3.2 校验:把规则从组件里剥离出来
校验逻辑最常见的坏味道,是直接写在 onChange 或者 JSX 里。比如这样:
{password.length < 8 && <Text>密码太短</Text>}这种写法不是不行,但一旦规则复杂,JSX 会变得臃肿,而且没法复用。我习惯把校验抽成纯函数:
function validateForm(fields: Record<string, string>) { const errors: Record<string, string | undefined> = {}; const username = fields.username?.trim() ?? ''; const password = fields.password ?? ''; const confirmPassword = fields.confirmPassword ?? ''; if (!username) { errors.username = '用户名不能为空'; } else if (username.length < 3) { errors.username = '用户名至少需要3个字符'; } if (!password) { errors.password = '请输入密码'; } else if (password.length < 8) { errors.password = '密码至少8位'; } if (!confirmPassword) { errors.confirmPassword = '请再次确认密码'; } else if (confirmPassword !== password) { errors.confirmPassword = '两次输入的密码不一致'; } return errors; }这个函数的输入是fields对象,输出是 errors 对象。它不依赖任何 React 状态,也不依赖组件实例,所以你可以在任何地方调用它,测试也非常方便。
实际操作中,我会只在这里负责“同步校验”,比如格式、长度、一致性。至于“用户名是否已被占用”这种后端才能回答的问题,走异步校验,等返回结果后再补一个SET_FIELD_ERROR之类的 action。但注意,reducer 里不要做异步,异步逻辑应该放在事件处理函数或自定义 hook 里,然后通过 dispatch 把结果写回状态。
3.3 表单组件装配与提交流程
核心的注册表单组件,大概长这样:
function RegisterForm() { const [state, dispatch] = useReducer(formReducer, initialFormState); const handleFieldChange = (name: string, value: string) => { dispatch({ type: 'FIELD_CHANGE', name, value }); }; const handleSubmit = async () => { const errors = validateForm(state.fields); if (Object.keys(errors).length > 0) { dispatch({ type: 'SUBMIT_FAILURE', errors }); return; } dispatch({ type: 'SUBMIT_START' }); try { await registerApi(state.fields); dispatch({ type: 'SUBMIT_SUCCESS' }); } catch (error) { dispatch({ type: 'SUBMIT_FAILURE', errors: { apiError: '注册失败,请稍后重试' }, }); } }; return ( <View> <TextInput value={state.fields.username} onChangeText={(text) => handleFieldChange('username', text)} onBlur={() => dispatch({ type: 'FIELD_BLUR', name: 'username' })} placeholder="用户名" /> {state.touched.username && state.errors.username ? ( <Text style={styles.error}>{state.errors.username}</Text> ) : null} <TextInput value={state.fields.password} onChangeText={(text) => handleFieldChange('password', text)} onBlur={() => dispatch({ type: 'FIELD_BLUR', name: 'password' })} secureTextEntry placeholder="密码" /> {state.touched.password && state.errors.password ? ( <Text style={styles.error}>{state.errors.password}</Text> ) : null} <TextInput value={state.fields.confirmPassword} onChangeText={(text) => handleFieldChange('confirmPassword', text)} onBlur={() => dispatch({ type: 'FIELD_BLUR', name: 'confirmPassword' })} secureTextEntry placeholder="确认密码" /> {state.touched.confirmPassword && state.errors.confirmPassword ? ( <Text style={styles.error}>{state.errors.confirmPassword}</Text> ) : null} {state.errors.apiError ? ( <Text style={styles.error}>{state.errors.apiError}</Text> ) : null} <TouchableOpacity onPress={handleSubmit} disabled={state.submitting} style={styles.submitBtn} > <Text>{state.submitting ? '提交中...' : '注册'}</Text> </TouchableOpacity> </View> ); }提交流程我讲三点:
第一,点击提交时,先同步校验全部字段。如果有错误,直接 dispatchSUBMIT_FAILURE,把错误展示出来,不发请求。这一步要放在SUBMIT_START之前,因为我们不应该在明显校验不通过的情况下还去改 submitting 状态。
第二,submitting为 true 时禁用按钮,避免用户狂点触发多个请求。这个在 OpenHarmony 设备上尤其重要,RN 的事件循环在部分低端设备上比较慢,用户连续点击的话,网络请求可能堆积。
第三,整个提交按钮的文案和 loading 状态都来源于 state,而不是组件内部的另一个本地变量。这样在任何时刻,界面上展示的“提交中”和按钮禁用状态都和数据流一致。
4. 优化、测试和TypeScript强化
4.1 拆分字段组件,减少无效重渲染
直接写在上面的代码里,有一个性能隐患:每次任何字段值变化,整个RegisterForm都会重渲染,所有TextInput都会参与 diff。字段少还好,如果是七八个字段、十几个字段,在 OpenHarmony 的低端设备上,键盘输入会明显卡顿。
优化思路是:把单个字段拆成一个带React.memo的组件,只接收它自己需要的值和回调。
const FieldInput = React.memo( ({ label, value, error, showError, onChangeText, onBlur, }: { label: string; value: string; error?: string; showError: boolean; onChangeText: (text: string) => void; onBlur: () => void; }) => ( <View> <Text style={styles.label}>{label}</Text> <TextInput value={value} onChangeText={onChangeText} onBlur={onBlur} /> {showError && error ? <Text style={styles.error}>{error}</Text> : null} </View> ) );然后在父组件里:
<FieldInput label="用户名" value={state.fields.username} error={state.errors.username} showError={state.touched.username || state.submitCount > 0} onChangeText={(text) => handleFieldChange('username', text)} onBlur={() => dispatch({ type: 'FIELD_BLUR', name: 'username' })} />注意:onChangeText这个内联箭头函数,在父组件每次重渲染时都会创建,如果直接传给 memo 组件,memo 会失效。所以handleFieldChange要用useCallback包一层,或者直接传一个稳定引用:
const handleFieldChange = useCallback((name: string, value: string) => { dispatch({ type: 'FIELD_CHANGE', name, value }); }, []);dispatch本身就是稳定的,所以依赖数组可以留空。这样FieldInput只有在自己的value或error变化时才重渲染,输入时的性能会明显变好。
4.2 reducer纯函数怎么测试
useReducer 最让我安心的,就是能写单元测试。表单最大的风险不是“当下的状态不对”,而是“改了某个字段后,另一个字段的错误没清干净”这种突发问题。纯函数测试可以把这个风险前置干掉。
我用的是常见的单元测试框架,写法很简单:
describe('formReducer', () => { it('SUBMIT_START 后进入提交中状态', () => { const next = formReducer(initialFormState, { type: 'SUBMIT_START' }); expect(next.submitting).toBe(true); expect(next.submitCount).toBe(1); }); it('SUBMIT_FAILURE 后展示错误并结束提交', () => { const startState = formReducer(initialFormState, { type: 'SUBMIT_START' }); const next = formReducer(startState, { type: 'SUBMIT_FAILURE', errors: { username: '用户名重复' }, }); expect(next.submitting).toBe(false); expect(next.errors.username).toBe('用户名重复'); }); it('FIELD_BLUR 只标记对应字段为 touched', () => { const next = formReducer(initialFormState, { type: 'FIELD_BLUR', name: 'password', }); expect(next.touched.password).toBe(true); expect(next.touched.username).toBeUndefined(); }); });这些测试跑得飞快,每次重构之后跑一遍,比在模拟器上手动点半天靠谱得多。你可能觉得这种 reducer 太简单,没必要测,但从项目长期维护来看,受益人是不确定性最高的那个“未来同事”,也就是三个月后的自己。
4.3 异步校验和防抖怎么安排
注册页经常要校验用户名是否重复。reducer 不适合做异步,所以我把这个逻辑放在一个自定义 hook 里,或者直接放在组件里用useEffect监听字段值变化。
一个可选的做法:
useEffect(() => { const username = state.fields.username; if (username.length < 3) return; const timer = setTimeout(async () => { const exists = await checkUsernameExists(username); if (exists) { dispatch({ type: 'SET_FIELD_ERROR', name: 'username', value: '用户名已被占用', }); } }, 300); return () => clearTimeout(timer); }, [state.fields.username]);这就在FormAction里额外加一个SET_FIELD_ERRORaction。它专门给异步校验结果用,跟同步校验不冲突。
但我有一个经验要提醒:这种异步校验结果,要带上“当前字段值”或者一个请求标识,不然很容易出现竞态。比如用户先输入 ABC,再输入 ABCD,两次请求发出后,后一个请求先返回“可用”,前一个请求后返回“已占用”,界面就会错误地显示“已被占用”。解决办法是在 useEffect 的清理函数里取消过期请求,或者比较返回值对应的字段值跟当前fields.username是否一致。
5. 常见问题与排查实录
5.1 现场施工:我遇到的6个问题
这里列一个速查表,都是我自己在 OpenHarmony + RN 表单场景里踩过的,按症状、原因、排查方向整理:
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 点击提交,请求发出了两三次 | submitting 状态更新不及时,按钮没有真正禁用 | 提交函数开头判断 state.submitting,为 true 直接 return;按钮 disabled 同时加上 |
| 输入框一进去,错误提示就闪出来了 | touched 没有正确初始化,或者校验挂在 onChange 里 | 错误展示要同时满足 `touched |
| 修改密码后,确认密码的错误没有消失 | 错误只在 blur 和 submit 时校验,没有联动校验 | 在 validateForm 里每次统一校验所有字段,不要只校验被改的那一个 |
| OpenHarmony 上 onBlur 不触发 | 适配层对软键盘的事件处理有差异 | 不依赖 onBlur 做核心校验,提交时强制校验;touched 仅作为展示条件 |
| 输入变快时页面卡顿 | 每次击键都触发整个表单重渲染 | 拆 FieldInput 并 memo,回调用 useCallback 保持稳定 |
| 异步校验结果闪烁 | 请求竞态,先返回的覆盖后返回的 | useEffect 清理函数里 cancel 过期请求,或者结果里带上字段值做比对 |
5.2 一个容易忽略的坑:初始化函数和 RESET
useReducer 的第三个参数是“初始化函数”,只会在首次渲染时执行一次。如果之后外部数据变了,你想让表单字段跟着重置,直接改initialFormState是没用的。
我踩过的一个典型场景:注册成功之后,同一页面要清空表单,我一开始把 reset 写成dispatch({ type: 'RESET', initialFormState: 某个新常量 }),但是那个常量在模块加载时就生成了,不是最新的。所以每次需要重置时,都要把“当时的初始值”通过 action payload 带进来:
const resetForm = (baseState: FormState) => { dispatch({ type: 'RESET', initialFormState: { ...baseState, fields: { ...baseState.fields } } }); };RESET返回的是action.initialFormState,不要直接复用组件内那个state.fields引用,以免后续改字段时不小心改到同一个对象。
5.3 与后端错误合并的细节
提交时后端可能不只返回一个错误字段,比如用户已存在、验证码错误、手机号已绑定。我会把服务端错误统一塞进 errors 对象,然后通过一个apiError或者对应字段名的 key 展示。
这里的关键是:不要在SUBMIT_FAILURE里把旧的同步校验错误整个覆盖掉。后端可能只返回code字段错误,但前端已经填好的其他字段有格式错误,你如果把 errors 整个替换掉,其他字段的错误提示就丢了。更稳妥的做法是合并:
case 'SUBMIT_FAILURE': { const mergedErrors = { ...state.errors, ...action.errors }; return { ...state, submitting: false, errors: mergedErrors }; }注意,如果是在提交时触发的校验失败,action.errors来自validateForm返回的新对象,它已经算全量校验了;如果是后端返回的,它通常只有服务端字段,所以合并更合理。具体逻辑视校验策略微调,但原则是“别让一次 dispatch 把上一个状态展示中的错误清成空白”。
6. 最后,什么时候别用useReducer
说完这么多“好用”,我也得诚实说一句:useReducer 不是万能的。
如果你只是做一个登录按钮,两个输入框,用户名密码,没有联动校验,没有防抖异步校验,没有提交状态管理,那useState完全够,用不上 useReducer。强行引入 reducer 只会增加样板代码,反而让新手看不懂。
什么时候值得切到 useReducer?我的判断标准是:
- 表单字段在 4 个以上
- 多个字段之间存在关联校验
- 有提交、重置、异步校验等至少两种以上状态流转
- 你希望能给状态逻辑写单元测试
满足其中两条,就值得用。
在 OpenHarmony 上跑 RN,最后我还有两个小建议:第一,输入类控件的受控值一定要放在 reducer 的fields里,不要“一半受控一半不受控”,否则键盘输入会和状态不同步,这个 bug 非常难查。第二,调试的时候多关注原生侧日志和 JS 侧日志的时间差,OpenHarmony 的适配层偶发状态更新比 JS 侧慢,有时界面没变不代表 dispatch 没执行,先确认事件有没有到 reducer,再查 UI 渲染。
我目前这个项目把注册、登录、重置密码三个表单都改成了 useReducer 后,新增字段和调整校验规则的工作量明显下降。如果你也在为 RN 表单状态头疼,建议挑一个字段最少的表单先做试点,跑通之后再推广,比一次性重构一个大表单要稳得多。