前言

一个注册表单,看起来就是几个输入框,但要做到"该提示的时候提示、不该提示的时候不打扰、注册按钮灰色时不可点",背后需要好几个状态变量分工合作。

HarmonyOS PC 端的注册表单用四个 @State 变量就能把所有交互逻辑覆盖掉。这篇把这四个变量各自负责什么、怎么联动说清楚。

Single-page hand-drawn educational infographic in

四个核心状态

@State formData: FormData = {        // 存所有输入框的值
  username: '', email: '',
  password: '', confirmPassword: ''
}

@State agreeTerms: boolean = false   // 用户协议是否勾选

@State showPassword: boolean = false  // 密码是否可见

@State attempted: boolean = false    // 用户是否点过提交

一个状态,一个职责,互不重叠。

formData:一个对象管所有字段

Hand-drawn educational flowchart on warm cream pap

不要给每个输入框单独定义 @State username@State email……六七个字段就六七个状态,冗余。

用一个接口对象统一管理:

interface FormData {
 
  username: string
  email: string
  password: string
  confirmPassword: string
}



@State formData: FormData = {
  username: '', email: '', password: '', confirmPassword: ''
}

更新时展开赋值:

this.formData = { ...this.formData, username: newValue }

这样不会丢掉其他字段,也能触发响应式刷新。

Single-page hand-drawn educational infographic in

attempted:控制错误提示的显示时机

"用户没有点提交就显示错误提示"是一个很差的体验——用户刚打开表单,还没输任何东西,就一片红,这让人很不舒服。

attempted 就是解决这个问题的:

// 错误提示只在 attempted 为 true 后才显示
if (this.attempted && this.hasError(field.key)) {
  Text(this.getErrorMsg(field))
    .fontColor('#EF4444')
}

点击注册按钮时设 attempted = true,这时才开始显示错误提示:

Button('注册账号')
  .onClick(() => {
    this.attempted = true
    if (this.isFormValid) {
      this.submitForm()
    }
  })

isFormValid:用 get 派生,不用 @State

表单是否填写完整、是否可提交,这不是一个需要存储的状态,而是从 formData 派生的计算结果:

get isFormValid(): boolean {
  return !!(
    this.formData.username.trim().length >= 2 &&
    this.formData.email.includes('@') &&
    this.formData.password.length >= 8 &&
    this.formData.confirmPassword === this.formData.password &&
    this.agreeTerms
  )
}

formDataagreeTerms 任意一个变化,isFormValid 自动重新计算,注册按钮的禁用状态跟着变。

完整代码

 
enum FormFieldKey {
    None,
    Username,
    Email,
    Password,
    ConfirmPassword
}

interface FieldConfig {
    key: FormFieldKey
    label: string
    placeholder: string
    required: boolean
    type: 'text' | 'email' | 'password'
    iconLeft: string
    hint: string
    errorMsg: string
}

@Entry
@Component
struct PcFormLayoutPage {
    @State username: string = ''
    @State email: string = ''
    @State password: string = ''
    @State confirmPassword: string = ''
    @State showPassword: boolean = false
    @State showConfirmPassword: boolean = false
    @State attempted: boolean = false
    @State focusedField: FormFieldKey = FormFieldKey.None

    fields: FieldConfig[] = [
        { key: FormFieldKey.Username, label: '用户名', placeholder: '4~20个字符,支持中英文', required: true, type: 'text', iconLeft: '👤', hint: '', errorMsg: '用户名不能为空' },
        { key: FormFieldKey.Email, label: '电子邮箱', placeholder: 'example@email.com', required: true, type: 'email', iconLeft: '📧', hint: '', errorMsg: '请输入有效的邮箱地址' },
        { key: FormFieldKey.Password, label: '密码', placeholder: '至少8位,包含字母和数字', required: true, type: 'password', iconLeft: '🔒', hint: '忘记密码?', errorMsg: '密码至少8位' },
        { key: FormFieldKey.ConfirmPassword, label: '确认密码', placeholder: '再次输入密码', required: true, type: 'password', iconLeft: '🔒', hint: '', errorMsg: '两次密码不一致' },
    ]

    getFieldValue(key: FormFieldKey): string {
        if (key === FormFieldKey.Username) return this.username
        if (key === FormFieldKey.Email) return this.email
        if (key === FormFieldKey.Password) return this.password
        return this.confirmPassword
    }

    hasError(key: FormFieldKey, val: string): boolean {
        if (!this.attempted) return false
        if (!val || val.trim() === '') return true
        if (key === FormFieldKey.Email && !val.includes('@')) return true
        if (key === FormFieldKey.Password && val.length < 8) return true
        if (key === FormFieldKey.ConfirmPassword && val !== this.password) return true
        return false
    }

    getErrorMsg(field: FieldConfig, val: string): string {
        if (!this.hasError(field.key, val)) return ''
        if (!val || val.trim() === '') return field.errorMsg
        if (field.key === FormFieldKey.Email) return '请输入有效的邮箱地址'
        if (field.key === FormFieldKey.Password) return '密码至少8位'
        if (field.key === FormFieldKey.ConfirmPassword) return '两次密码不一致'
        return field.errorMsg
    }

    get isFormValid(): boolean {
        return !!(
            this.username.trim() &&
                this.email.includes('@') &&
                this.password.length >= 8 &&
                this.confirmPassword === this.password
        )
    }

    @Builder
    formField(field: FieldConfig, value: string) {
        Column({ space: 6 }) {
            // Label 行
            Row({ space: 4 }) {
                if (field.required) {
                    Text('*')
                        .fontSize(13)
                        .fontColor('#EF4444')
                }
                Text(field.label)
                    .fontSize(13)
                    .fontColor('#374151')
                    .fontWeight(FontWeight.Medium)

                Blank()

                if (field.hint) {
                    Text(field.hint)
                        .fontSize(11)
                        .fontColor('#3B82F6')
                }
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            // 输入区行
            Row({ space: 8 }) {
                Text(field.iconLeft)
                    .fontSize(16)
                    .fontColor(this.hasError(field.key, value) ? '#EF4444' : '#9CA3AF')
                    .width(20)
                    .textAlign(TextAlign.Center)

                TextInput({
                    placeholder: field.placeholder,
                    text: value
                })
                    .layoutWeight(1)
                    .backgroundColor(Color.Transparent)
                    .border({ width: 0 })
                    .fontSize(14)
                    .placeholderColor('#C4C9D4')
                    .type(field.type === 'password'
                        ? (field.key === FormFieldKey.Password ? (this.showPassword ? InputType.Normal : InputType.Password)
                            : (this.showConfirmPassword ? InputType.Normal : InputType.Password))
                        : (field.type === 'email' ? InputType.Email : InputType.Normal))
                    .onChange((v) => {
                        if (field.key === FormFieldKey.Username) {
                            this.username = v
                        } else if (field.key === FormFieldKey.Email) {
                            this.email = v
                        } else if (field.key === FormFieldKey.Password) {
                            this.password = v
                        } else {
                            this.confirmPassword = v
                        }
                    })
                    .onFocus(() => { this.focusedField = field.key })
                    .onBlur(() => { this.focusedField = FormFieldKey.None })

                if (field.type === 'password') {
                    Text(field.key === FormFieldKey.Password ? (this.showPassword ? '🙈' : '👁') : (this.showConfirmPassword ? '🙈' : '👁'))
                        .fontSize(16)
                        .fontColor('#9CA3AF')
                        .onClick(() => {
                            if (field.key === FormFieldKey.Password) this.showPassword = !this.showPassword
                            else this.showConfirmPassword = !this.showConfirmPassword
                        })
                }

                // 校验状态图标
                if (this.attempted) {
                    Text(this.hasError(field.key, value) ? '❌' : '✅')
                        .fontSize(14)
                }
            }
            .height(48)
            .padding({ left: 14, right: 14 })
            .backgroundColor(this.hasError(field.key, value) ? '#FEF2F2' : '#F9FAFB')
            .borderRadius(10)
            .border({
                width: 1.5,
                color: this.hasError(field.key, value) ? '#EF4444'
                    : this.focusedField === field.key ? '#3B82F6' : '#E5E7EB'
            })
            .animation({ duration: 150 })

            // 错误提示
            if (this.attempted && this.hasError(field.key, value)) {
                Row({ space: 4 }) {
                    Text('⚠').fontSize(11).fontColor('#EF4444')
                    Text(this.getErrorMsg(field, value))
                        .fontSize(11)
                        .fontColor('#EF4444')
                }
            }
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
    }

    build() {
        Scroll() {
            Column({ space: 0 }) {
                // 卡片容器
                Column({ space: 24 }) {
                    // 标题
                    Column({ space: 6 }) {
                        Text('创建账号')
                            .fontSize(24)
                            .fontWeight(FontWeight.Bold)
                            .fontColor('#111827')
                        Text('加入 HarmonyOS 开发者社区')
                            .fontSize(14)
                            .fontColor('#6B7280')
                    }
                    .alignItems(HorizontalAlign.Start)
                    .width('100%')

                    // 表单字段
                    ForEach(this.fields, (field: FieldConfig) => {
                        this.formField(field, this.getFieldValue(field.key))
                    })

                    // 提交按钮
                    Column({ space: 12 }) {
                        Button('注册账号')
                            .width('100%')
                            .height(48)
                            .backgroundColor(this.isFormValid ? '#3B82F6' : '#9CA3AF')
                            .borderRadius(10)
                            .fontSize(15)
                            .fontWeight(FontWeight.Medium)
                            .onClick(() => { this.attempted = true })

                        Row({ space: 6 }) {
                            Text('已有账号?').fontSize(13).fontColor('#6B7280')
                            Text('立即登录').fontSize(13).fontColor('#3B82F6').fontWeight(FontWeight.Medium)
                        }
                        .justifyContent(FlexAlign.Center)
                    }
                    .width('100%')
                }
                .padding({ left: 40, right: 40, top: 40, bottom: 40 })
                .backgroundColor(Color.White)
                .borderRadius(20)
                .shadow({ radius: 24, color: '#10000000', offsetY: 8 })
                .width('100%')
                .constraintSize({ maxWidth: 480 })
                .margin({ left: 'auto', right: 'auto' })
            }
            .padding({ left: 24, right: 24, top: 48, bottom: 48 })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#F9FAFB')
    }
}


密码强度的派生计算

密码强度是一个纯计算结果,不需要 @State,用 get 派生:

get strengthLevel(): number {
  const p = this.formData.password
  let score = 0
  if (p.length >= 8) score++
  if (/[A-Z]/.test(p)) score++
  if (/[0-9]/.test(p)) score++
  if (/[^A-Za-z0-9]/.test(p)) score++
  return score  // 0~4
}

四条规则每满足一条 +1 分,满分 4 分。密码变化时 strengthLevel 自动重算,强度条跟着变色。

agreeTerms 和 isFormValid 的关系

勾选协议是注册的必要条件。在 isFormValid 里加上 && this.agreeTerms,协议没勾选时注册按钮始终灰色:

get isFormValid(): boolean {
  return fieldRulesAllValid && this.agreeTerms
}

用户没有勾选协议就点注册,attempted 设为 true,错误提示出来了,但协议那行没有专门的错误提示——让协议 Checkbox 本身颜色变红就够了,不需要额外的提示文字。

小结

四个状态:formData 存数据、agreeTerms 存协议状态、showPassword 控制密码可见性、attempted 控制错误显示时机。派生计算不用状态存:isFormValidstrengthLevel 都是 get 属性,自动跟着状态变化。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐