请添加图片描述

案例项目开源地址:https://atomgit.com/nutpi/wanandroid_rn_openharmony

注册功能写完了,测试的时候发现各种问题:用户名为空能提交、密码只输了一个字符也能提交、两次密码不一样也能提交……

表单验证,必须安排上。

我们要验证什么

WanAndroid 的注册接口有几个要求:

用户名不能为空,长度有限制。

密码不能为空,长度至少 6 位。

确认密码要和密码一致。

这些验证可以在前端做,也可以依赖后端。但前端验证能给用户更快的反馈,不用等网络请求返回才知道哪里填错了。

最基础的验证

先看我们现有的验证代码:

const handleSubmit = async () => {
  if (!username || !password) {
    Alert.alert('提示', '请输入用户名和密码');
    return;
  }
  if (isRegister && password !== repassword) {
    Alert.alert('提示', '两次密码不一致');
    return;
  }
  // 提交逻辑...
};

这段代码做了两个验证:

第一个验证:非空检查

!username || !password 检查用户名和密码是否为空。这里用到了 JavaScript 的类型转换特性:空字符串 '' 在布尔上下文中是 false,所以 !'' 等于 true

逻辑运算符 || 是短路求值,只要左边为 true 就不会计算右边。所以如果用户名为空,直接触发验证失败,不会再检查密码。

第二个验证:密码一致性检查

isRegister && password !== repassword 这个表达式有两层含义:

  • isRegister 确保只在注册模式下检查,登录模式不需要确认密码
  • password !== repassword 用严格不等于比较两个字符串

验证失败的处理

Alert.alert('提示', '请输入用户名和密码') 弹出系统原生对话框,第一个参数是标题,第二个是内容。

return 语句很关键,它阻止函数继续执行。没有这个 return,验证失败后还会继续执行提交逻辑。

这个验证能用,但有几个问题。

问题一:空格也能通过

用户输入一堆空格,!username 是 false,验证通过了。但空格用户名肯定是不行的。

解决方法是用 trim() 去掉首尾空格:

if (!username.trim() || !password.trim()) {
  Alert.alert('提示', '请输入用户名和密码');
  return;
}

trim() 方法详解

trim() 是字符串的内置方法,它会返回一个新字符串,去掉原字符串首尾的空白字符(空格、制表符、换行符等)。

举几个例子:

  • ' hello '.trim() 返回 'hello'
  • ' '.trim() 返回 ''(空字符串)
  • 'hello'.trim() 返回 'hello'(没有空格就原样返回)

注意 trim() 不会修改原字符串,而是返回新字符串。JavaScript 的字符串是不可变的。

更进一步,可以在提交前统一处理:

const handleSubmit = async () => {
  const trimmedUsername = username.trim();
  const trimmedPassword = password.trim();
  
  if (!trimmedUsername || !trimmedPassword) {
    Alert.alert('提示', '请输入用户名和密码');
    return;
  }
  // 用 trimmedUsername 和 trimmedPassword 提交
};

为什么要用新变量

这里用 trimmedUsernametrimmedPassword 两个新变量,而不是直接修改 state。原因有两个:

  1. setUsername(username.trim()) 是异步的,调用后 username 不会立刻变化,后面的验证还是用的旧值
  2. 保持输入框显示用户实际输入的内容,只在提交时处理空格,用户体验更好

问题二:密码太短

用户输入一个字符的密码,验证通过了。但这种密码太弱,容易被破解。

加一个长度验证:

if (password.length < 6) {
  Alert.alert('提示', '密码至少6位');
  return;
}

字符串的 length 属性

password.length 返回字符串的字符数。注意是字符数,不是字节数。对于中文、emoji 等多字节字符,一个字符就是一个长度单位。

比如:

  • 'abc'.length 是 3
  • '你好'.length 是 2
  • '😀'.length 是 2(emoji 在 JavaScript 里可能占 2 个长度单位,这是 UTF-16 编码的特性)

WanAndroid 的接口要求密码至少 6 位,我们在前端也做同样的限制。前后端保持一致,避免前端验证通过但后端拒绝的情况。

问题三:用户名太短或太长

用户名也应该有长度限制:

if (username.trim().length < 3) {
  Alert.alert('提示', '用户名至少3个字符');
  return;
}

if (username.trim().length > 20) {
  Alert.alert('提示', '用户名不能超过20个字符');
  return;
}

为什么要限制长度

最小长度限制:太短的用户名没有辨识度,比如 aab 这种。而且容易被恶意注册占用。

最大长度限制:太长的用户名会导致 UI 显示问题,比如用户名显示区域放不下,要么截断要么换行,都不好看。数据库字段通常也有长度限制。

链式调用

username.trim().length 是链式调用:先调用 trim() 返回去掉空格的新字符串,再访问这个新字符串的 length 属性。这样写比分两行更简洁。

问题四:一次只提示一个错误

现在的验证是串行的,第一个验证失败就 return 了,用户只能看到一个错误。

如果用户名为空、密码太短、两次密码不一致,用户要提交三次才能看到所有错误。体验不好。

可以改成收集所有错误,一次性提示:

const handleSubmit = async () => {
  const errors: string[] = [];
  
  if (!username.trim()) {
    errors.push('请输入用户名');
  } else if (username.trim().length < 3) {
    errors.push('用户名至少3个字符');
  }
  
  if (!password) {
    errors.push('请输入密码');
  } else if (password.length < 6) {
    errors.push('密码至少6位');
  }
  
  if (isRegister && password !== repassword) {
    errors.push('两次密码不一致');
  }
  
  if (errors.length > 0) {
    Alert.alert('提示', errors.join('\n'));
    return;
  }
  // 提交逻辑...
};

数组收集错误

const errors: string[] = [] 声明一个字符串数组,用来收集所有错误信息。TypeScript 的类型注解 string[] 表示这是一个字符串数组。

每个验证失败时,用 errors.push() 把错误信息添加到数组末尾,而不是直接 return。

else if 的作用

注意用户名的验证用了 else if:先检查是否为空,不为空才检查长度。这样避免了重复提示,比如用户名为空时不会同时提示"请输入用户名"和"用户名至少3个字符"。

join() 方法

errors.join('\n') 把数组元素用指定的分隔符连接成字符串。'\n' 是换行符,所以每个错误占一行。

比如 ['错误1', '错误2'].join('\n') 返回 '错误1\n错误2',在弹窗里显示为两行。

最终检查

if (errors.length > 0) 检查是否有错误。数组的 length 属性返回元素个数,大于 0 说明有错误,弹窗提示并 return。

问题五:没有实时反馈

现在的验证是在点击提交按钮时才触发。用户填完整个表单,点提交,才发现第一个输入框就填错了。

更好的体验是实时验证:输入框失去焦点时就验证,有错误立刻提示。

const [usernameError, setUsernameError] = useState('');
const [passwordError, setPasswordError] = useState('');

const validateUsername = (value: string) => {
  if (!value.trim()) {
    setUsernameError('请输入用户名');
  } else if (value.trim().length < 3) {
    setUsernameError('用户名至少3个字符');
  } else {
    setUsernameError('');
  }
};

<TextInput
  value={username}
  onChangeText={setUsername}
  onBlur={() => validateUsername(username)}
/>
{usernameError ? <Text style={styles.error}>{usernameError}</Text> : null}

错误状态管理

每个输入框对应一个错误状态:usernameErrorpasswordError。初始值是空字符串,表示没有错误。

验证函数根据验证结果设置错误信息,验证通过时设置为空字符串清除错误。

onBlur 事件

onBlur 在输入框失去焦点时触发。"失去焦点"就是用户点击了输入框外面的地方,或者切换到下一个输入框。

这个时机很合适:用户输入完一个字段,准备输入下一个字段时,验证上一个字段。不会在用户还在输入时就报错。

条件渲染错误提示

{usernameError ? <Text>...</Text> : null} 是条件渲染的三元表达式写法。usernameError 有值时渲染 Text 组件,没值时渲染 null(什么都不显示)。

也可以用短路写法:{usernameError && <Text>...</Text>},效果一样。

错误提示的样式:

error: {
  color: '#ff4444',
  fontSize: 12,
  marginTop: -12,
  marginBottom: 12,
  marginLeft: 4
}

样式细节解释

  • color: '#ff4444' 红色,醒目地提示错误
  • fontSize: 12 比正文小一号,不喧宾夺主
  • marginTop: -12 负边距,让错误提示紧贴输入框。因为输入框有 marginBottom: 16,不用负边距的话错误提示会离输入框太远
  • marginBottom: 12 和下一个输入框保持间距
  • marginLeft: 4 稍微缩进,和输入框内的文字对齐

问题六:输入时清除错误

用户看到错误提示后开始修改,但错误提示还在,感觉怪怪的。

应该在用户输入时清除错误:

const handleUsernameChange = (value: string) => {
  setUsername(value);
  if (usernameError) {
    setUsernameError('');
  }
};

<TextInput
  value={username}
  onChangeText={handleUsernameChange}
  onBlur={() => validateUsername(username)}
/>

为什么要清除错误

用户看到"用户名至少3个字符"的错误后,开始补充输入。如果错误提示一直显示,用户会困惑:我已经在改了,为什么还报错?

在用户输入时立刻清除错误,给用户一个"我知道你在修改"的反馈。等输入框失去焦点时再重新验证。

条件判断的优化

if (usernameError) 先判断是否有错误,有才清除。虽然直接 setUsernameError('') 也能工作,但会触发不必要的重新渲染。React 的 setState 即使设置相同的值也会触发渲染(除非用了 memo 等优化)。

事件处理函数的命名

handleUsernameChange 这个命名遵循 React 的惯例:handle + 事件名。一看就知道是处理用户名变化的函数。

输入框样式变化

有错误时,输入框的边框可以变成红色,更醒目:

<TextInput
  style={[
    styles.input,
    {backgroundColor: theme.bg, color: theme.text, borderColor: usernameError ? '#ff4444' : theme.border}
  ]}
  // ...
/>

动态样式的写法

style 属性接受一个数组,React Native 会把数组里的样式对象合并。后面的样式会覆盖前面的同名属性。

styles.input 是基础样式,后面的对象是动态样式。borderColor 根据 usernameError 是否有值来决定用红色还是主题色。

三元表达式

usernameError ? '#ff4444' : theme.border 是三元表达式:条件 ? 真值 : 假值。

usernameError 是字符串,非空字符串在布尔上下文中是 true,空字符串是 false。所以有错误信息时用红色,没有时用主题边框色。

提交按钮的禁用状态

如果表单有错误,提交按钮可以禁用:

const isFormValid = username.trim().length >= 3 && 
                    password.length >= 6 && 
                    (!isRegister || password === repassword);

<TouchableOpacity
  style={[styles.btn, {backgroundColor: isFormValid ? theme.accent : theme.border}]}
  onPress={handleSubmit}
  disabled={!isFormValid || loading}
>

表单有效性判断

isFormValid 是一个布尔值,综合判断表单是否可以提交:

  • username.trim().length >= 3:用户名去掉空格后至少 3 个字符
  • password.length >= 6:密码至少 6 位
  • (!isRegister || password === repassword):这个稍微复杂,我们拆开看

逻辑运算符的组合

!isRegister || password === repassword 的意思是:

  • 如果是登录模式(!isRegister 为 true),整个表达式为 true,不需要检查确认密码
  • 如果是注册模式(!isRegister 为 false),需要 password === repassword 为 true

这是一个常见的逻辑技巧:!条件 || 检查 等价于"如果条件成立,则必须通过检查"。

按钮的视觉反馈

backgroundColor: isFormValid ? theme.accent : theme.border:表单有效时用强调色(通常是蓝色或绿色),无效时用边框色(通常是灰色)。

灰色按钮在视觉上告诉用户"这个按钮现在不能点"。

disabled 属性

disabled={!isFormValid || loading} 禁用按钮的两种情况:

  • 表单无效时禁用,防止提交无效数据
  • 正在加载时禁用,防止重复提交

不过这种方式有个问题:用户不知道为什么按钮是灰的。所以实时错误提示还是要有的。

完整的验证逻辑

把上面的优化整合起来:

const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [repassword, setRepassword] = useState('');
const [usernameError, setUsernameError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [repasswordError, setRepasswordError] = useState('');

const validateUsername = (value: string) => {
  const trimmed = value.trim();
  if (!trimmed) {
    setUsernameError('请输入用户名');
    return false;
  }
  if (trimmed.length < 3) {
    setUsernameError('用户名至少3个字符');
    return false;
  }
  if (trimmed.length > 20) {
    setUsernameError('用户名不能超过20个字符');
    return false;
  }
  setUsernameError('');
  return true;
};

const validatePassword = (value: string) => {
  if (!value) {
    setPasswordError('请输入密码');
    return false;
  }
  if (value.length < 6) {
    setPasswordError('密码至少6位');
    return false;
  }
  setPasswordError('');
  return true;
};

const validateRepassword = (value: string) => {
  if (value !== password) {
    setRepasswordError('两次密码不一致');
    return false;
  }
  setRepasswordError('');
  return true;
};

const handleSubmit = async () => {
  const isUsernameValid = validateUsername(username);
  const isPasswordValid = validatePassword(password);
  const isRepasswordValid = isRegister ? validateRepassword(repassword) : true;
  
  if (!isUsernameValid || !isPasswordValid || !isRepasswordValid) {
    return;
  }
  
  setLoading(true);
  // 提交逻辑...
};

验证函数的返回值

每个验证函数返回 boolean:验证通过返回 true,失败返回 false

这样设计的好处是可以在提交时统一调用所有验证函数,根据返回值判断是否继续。

提前返回模式

验证函数里用了"提前返回"模式:每个条件检查失败就立刻 return false,不用写一堆 else if。代码更扁平,更易读。

最后一行 return true 只有在所有检查都通过后才会执行。

提交时的验证调用

const isUsernameValid = validateUsername(username);
const isPasswordValid = validatePassword(password);
const isRepasswordValid = isRegister ? validateRepassword(repassword) : true;

注意这里没有用短路求值(&&),而是把每个验证结果存到变量里。这样即使第一个验证失败,后面的验证也会执行,所有错误都会显示出来。

如果用 validateUsername(username) && validatePassword(password),第一个失败就不会执行第二个了。

条件验证

isRegister ? validateRepassword(repassword) : true:只在注册模式下验证确认密码,登录模式直接返回 true。

后端验证也不能少

前端验证是为了用户体验,但不能完全依赖前端。用户可以绕过前端直接调接口,所以后端也要做验证。

WanAndroid 的接口会返回错误信息:

const register = async (username: string, password: string, repassword: string): Promise<boolean> => {
  try {
    const res = await userApi.register(username, password, repassword);
    if (res.errorCode === 0) {
      setIsLoggedIn(true);
      setUserInfo(res.data);
      return true;
    } else {
      Alert.alert('注册失败', res.errorMsg || '请检查输入信息');
      return false;
    }
  } catch (e) {
    Alert.alert('错误', '网络错误');
    return false;
  }
};

Promise 返回类型

Promise<boolean> 表示这个异步函数返回一个 Promise,resolve 的值是 boolean 类型。调用方可以用 await 获取这个 boolean 值。

错误码判断

res.errorCode === 0 是 WanAndroid 接口的约定:errorCode 为 0 表示成功,非 0 表示失败。

不同的接口可能有不同的约定,有的用 code,有的用 status,有的用 HTTP 状态码。要看具体接口文档。

错误信息的容错

res.errorMsg || '请检查输入信息':如果后端返回了错误信息就用后端的,没返回就用默认的。

|| 运算符在左边是 falsy 值(null、undefined、空字符串等)时返回右边的值。

try-catch 捕获网络错误

try-catch 捕获的是网络层面的错误,比如断网、超时、服务器无响应等。这些错误会导致 fetch 抛出异常。

接口返回的业务错误(errorCode 非 0)不会抛异常,需要在 try 块里判断处理。

res.errorMsg 是后端返回的错误信息,比如"用户名已存在"。这种错误前端没法验证,只能靠后端。

用户名已存在的处理

用户名已存在是常见的注册失败原因。后端返回错误后,可以把错误显示在用户名输入框下方:

const register = async (...) => {
  const res = await userApi.register(username, password, repassword);
  if (res.errorCode !== 0) {
    if (res.errorMsg?.includes('已存在') || res.errorMsg?.includes('已注册')) {
      setUsernameError('用户名已被注册');
    } else {
      Alert.alert('注册失败', res.errorMsg || '请检查输入信息');
    }
    return false;
  }
  // ...
};

可选链操作符

res.errorMsg?.includes('已存在') 用了可选链 ?.。如果 res.errorMsg 是 null 或 undefined,不会报错,直接返回 undefined。

这比 res.errorMsg && res.errorMsg.includes('已存在') 更简洁。

字符串的 includes 方法

includes() 检查字符串是否包含指定子串,返回 boolean。比 indexOf() !== -1 更直观。

智能错误展示

根据错误信息判断是不是用户名重复,是的话显示在输入框下方,比弹窗更直观。用户一眼就知道是用户名的问题,不用看弹窗再去找哪个输入框。

密码强度提示

可以加一个密码强度指示器,告诉用户密码是弱、中、强:

const getPasswordStrength = (pwd: string) => {
  if (pwd.length < 6) return {level: 0, text: ''};
  let score = 0;
  if (pwd.length >= 8) score++;
  if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++;
  if (/\d/.test(pwd)) score++;
  if (/[^a-zA-Z0-9]/.test(pwd)) score++;
  
  if (score <= 1) return {level: 1, text: '弱', color: '#ff4444'};
  if (score <= 2) return {level: 2, text: '中', color: '#ffaa00'};
  return {level: 3, text: '强', color: '#00aa00'};
};

const strength = getPasswordStrength(password);

{password.length >= 6 && (
  <Text style={{color: strength.color, fontSize: 12, marginBottom: 8}}>
    密码强度:{strength.text}
  </Text>
)}

正则表达式检测

  • /[a-z]/.test(pwd):检查是否包含小写字母
  • /[A-Z]/.test(pwd):检查是否包含大写字母
  • /\d/.test(pwd):检查是否包含数字,\d 等价于 [0-9]
  • /[^a-zA-Z0-9]/.test(pwd):检查是否包含特殊字符,^ 在方括号里表示"非"

评分机制

每满足一个条件加 1 分:长度 >= 8、大小写混合、包含数字、包含特殊字符。

根据分数返回不同的强度等级和颜色。这个算法比较简单,实际项目可能需要更复杂的评估。

条件渲染

password.length >= 6 && (...) 只有密码达到最小长度才显示强度提示。太短的密码显示"弱"没意义,用户还在输入呢。

这个功能不是必须的,但能引导用户设置更安全的密码。

当前项目的验证代码

我们项目里的验证比较简洁:

const handleSubmit = async () => {
  if (!username || !password) {
    Alert.alert('提示', '请输入用户名和密码');
    return;
  }
  if (isRegister && password !== repassword) {
    Alert.alert('提示', '两次密码不一致');
    return;
  }

  setLoading(true);
  let success = false;
  if (isRegister) {
    success = await register(username, password, repassword);
  } else {
    success = await login(username, password);
  }
  setLoading(false);

  if (success) {
    setUsername('');
    setPassword('');
    setRepassword('');
    onClose();
  }
};

代码流程分析

  1. 先做前端验证,失败就 return
  2. 设置 loading 状态,显示加载动画
  3. 根据模式调用 register 或 login
  4. 关闭 loading 状态
  5. 成功则清空表单并关闭弹窗

let 声明的使用

let success = false 用 let 而不是 const,因为后面要重新赋值。const 声明的变量不能重新赋值。

await 的使用

success = await register(...) 等待异步操作完成,获取返回值。如果不用 await,success 会是一个 Promise 对象,不是 boolean。

成功后的清理

清空三个输入框的状态,调用 onClose() 关闭弹窗。这样下次打开弹窗时是干净的。

够用,但不够完善。如果你的项目对用户体验要求更高,可以参考上面的优化方案。

验证的时机选择

总结一下验证的几个时机:

输入时(onChange)

适合格式化输入,比如手机号自动加空格、银行卡号每四位加空格。不适合验证,因为用户还没输完,过早报错会打断用户。

失焦时(onBlur)

适合单个字段的验证。用户输完一个字段,切换到下一个字段时验证上一个。这个时机比较自然,不会打断用户输入。

提交时(onSubmit)

最后一道防线。即使前面的验证被跳过(比如用户直接点提交按钮),提交时也要验证一遍。这是必须有的。

实时验证(onChange + 防抖)

有些场景需要实时验证,比如检查用户名是否已被注册。但要加防抖,不然每输入一个字符就发一次请求,太浪费了。

我们的项目用的是提交时验证,简单直接。如果想要更好的体验,可以加上失焦时验证。

表单验证看着简单,细节还挺多的。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐