在VUE中使用TypeScript装饰器来实现表单验证

前言

最近接触了关于很多TypeScript装饰器的知识,以及class-validator这个用装饰器来做表单验证的包,就萌生了想在vue中使用装饰器来做表单验证的想法。class-validator允许我们在类上通过使用装饰器来完成表单的验证,并且可在浏览器端和node端同时使用。那么接下来先简单介绍一下装饰器和class-validator的用法。

装饰器

装饰器的语法十分简单,只需要在想使用的装饰器前加上@符号,装饰器就会被应用到目标上。 通过装饰器我们可以轻松实现代理模式来使代码更简洁以及实现其它一些更有趣的能力。 关于装饰器的用法我用代码来简单的举例几个,更详细的信息大家可自行去网络上查找。

// 比如我们有一个创建用户的表单,在上面已经应用了一个Reactive类装饰器
// 用了这个装饰器后,这个类实例出来的对象会是响应式对象了。
@Reactive()
export class CreateUserForm {
  username:string
  email:string
  password:string
  confirmPassword:string
}
复制代码

之后我们在setup中使用它

setup() {
  // 如果没有用装饰器,则需要 const form = reactive(new CreateUserForm())
  const form = new CreateUserForm()
  return {
    form
  }
}
复制代码

那么这个类装饰器是怎么写的呢,其实很简单

import {reactive} from 'vue-demi'
// vue-demi可以让你的库同时在vue2(@vue/composition-api)和vue3中使用
//下面这个函数Reactive就是类装饰器,返回的是继承之后的类。实例化之后返回的是reactive对象
function Reactive() {
  return function <T extends { new (...args: any[]): {} }>(constructor: T){
    return class extends constructor { 
      constructor(...args: any[]) {
        super(...args) 
        return reactive(this)
      }
    }
  }
}
复制代码

class-validator

然后我们通过class-validatorgithub.com/typestack/c… 这个库给我们的表单加上表单验证

import { IsEmail, IsMobilePhone, Length } from 'class-validator'
@Reactive()
export class CreateUserForm {
    // 下面这些是属性装饰器,用来标记这些属性的验条件。
    // 在验证的时候会通过Reflect拿到这些元数据来验证
    // 我们也可以创建自定义的装饰器
    @Length(4, 12)
    username: string
    
    @IsEmail()
    email: string

    @IsMobilePhone('zh-CN')
    phone: string

    @Length(4, 12)
    password: string
}
复制代码

之后在setup中使用,但还是显得有点粗糙

import { validate } from 'class-validator'
setup() {
  const form = new CreateUserForm()
  const errors = reactive< { [x in keyof CreateUserForm]: string} >({})
  const validate = async () => {
    const err = await validate(form)
    err.forEach(e => {
      if (e.constraints) {
        errors[e.property] = Object.values(e.constraints)[0]
      }
    })
  }
  return {
    form,
    errors
  }
}

// 用的是jsx,看个人习惯
render(){
  const {form,errors} = this
  return <div>
            <div>
                <p>
                    <span>用户名</span>
                    <input v-model={form.username}></input>
		</p>
                {!!errors.username && <p>错误提示:{errors.username}</p>}
            </div>
            // ...一些其他表单
            <button onClick={() => this.validate()}>验证</button>
        </div>
}
复制代码

这里会有一些需要优化的地方

  • 在用的时候每次需要声明errorsvalidate方法,不方便
  • 需要手动点击验证才会有表单验证
  • 在输入表单的时候没有响应式的显示当前字段的错误提示

封装Validator

有许多种方法可以优化这个,这里我选择封装一个Validator类,有获取错误消息和验证的功能,然后让我们的表单类继承它。

import { instanceToPlain } from 'class-transformer'
import { validate, ValidationError } from 'class-validator'
import { toRef, watch } from 'vue-demi'
const ERROR = Symbol('error') 
const IS_VALID = Symbol('isValid')
// 本来我打算error的类型简单的写成Record<string,any>
// 但是代码提示太不友好了,写成这样的话,可以完美的提示
// 这个接口写起来很麻烦
type ValidatorError < T > = { 
    [x in Exclude < keyof T, keyof Validator >] ?:
    T[x] extends PropertyKey ? string: ValidatorRequiredError < T[x] >
}
type ValidatorRequiredError < T > = { 
    [x in Exclude < keyof T, keyof Validator > ] : 
        T[x] extends PropertyKey ? 
        string | undefined:
        T[x] extends Function ? 
        T[x] :
        ValidatorError < T[x] > |undefined
}
type ValidatorJSON < T > = { 
    [x in Exclude < keyof T, keyof Validator > ] : 
    keyof T extends PropertyKey ? T[x] : ValidatorJSON < T[x] >
}

export default abstract class Validator {
    // 这里属性用symbol是为了防止跟表单属性重复
    private [ERROR]: ValidatorError <this> ={}
    private [IS_VALID] : boolean = false

    public getError() {
      return this[ERROR]
    }
    public isValid() {
      return this[IS_VALID]
    }
    public toJSON() {
      return instanceToPlain(this) as ValidatorJSON < this >
    }
    public async validate() {
        // 一些验证的代码
    }
    public clearError() {
      this[ERROR] = {}
    }
    private setError(result: ValidationError[]):Record <string,any > {
       // 将error设置到this[ERROR]上
    }
    private watchFields(parentKeys ? :string[]) {
       // 这里做了单独watch每个属性,然后单独设置错误消息
    }
}
    -----------------------------------------    
//上面watchFields这个方法需要实例化的时候单独调用
//所以我们可以放到Reactive装饰器上,就不需要再手动调用一次了
function Reactive() {
  return function <T extends { new (...args: any[]): {} }>(constructor: T){
    return class extends constructor { 
      constructor(...args: any[]) {
        super(...args) 
        const target = reactive(this)
        if (target.watchFields) {
            target.watchFields()
        }
        return target
      }
    }
  }
}
复制代码

如果将error的类型简单的写成Record<string,any>,

Kapture 2022-03-19 at 14.33.55.gif

要是写成代码里的那样子,

rrr.gif 具体的代码可以到(github.com/AndSpark/vu…) 这里看下。

具体使用

好了,现在我们的代码可以变成这个样子。

// ./form.ts
import { Type } from 'class-transformer'
import { IsEmail, IsMobilePhone, IsOptional, Length,
	MaxLength, MinLength,  ValidateNested } from 'class-validator'
import 'reflect-metadata'
// 需要引入 reflect-metadata 来使用metadata

class Profile {
    @IsOptional()
    avatar?: string

    @Length(2, 4, {message: '姓名长度应在2到4间'})
    realName: string

    @IsOptional()
    description?: string
}

// 在表单上可以设置初始值,现在是固定的。
// 那也可以通过属性装饰器调用api来设置动态初始值。大家可以自己实现试试看 
@Reactive()
export class CreateUserForm extends Validator {
    @Length(4, 12, { message: '用户名长度应在4到12间' })
    username: string = ''

    @IsEmail({}, { message: '请填写正确的邮箱' })
    email: string = ''

    @IsMobilePhone('zh-CN', null, { message: '请输入正确的手机号码' })
    phone: string = ''

    @MinLength(4, { message: '密码长度不应低于4' })
    @MaxLength(12, { message: '密码长度不应大于12' })
    password: string = ''

    // 也可以关联其他表单类,但需要下面两个装饰器,用来关联
    @Type(() => Profile)
    @ValidateNested()
    profile: Profile = new Profile()
}
复制代码
export default defineComponent({
setup() {
    const form = new CreateUserForm()
    return {
        form
    }
},
render() {
    const { form } = this
    return (
        <div>
            <field label='用户名' v-model={form.username} error={form.getError().username}></field>
            <field label='姓名' v-model={form.profile.realName} error={form.getError().profile?.realName}></field>
            <field label='邮箱' v-model={form.email} error={form.getError().email}></field>
            <field label='手机' v-model={form.phone} error={form.getError().phone}></field>
            <field label='密码' v-model={form.password} error={form.getError().password}></field>
            <button onClick={() => form.validate()}>验证</button>
            <button onClick={() => form.clearError()}>清空错误</button>
        </div>
    )
}
})
复制代码

下面是简单的页面演示。

err.gif

小结

其实装饰器能做的东西很多,也比较好玩。class-validator这个库里的装饰器还有很多,大家可以去github上看看。然而它提供的装饰器可能并不能完全满足我们的需求,所以还是需要自己去研究装饰器,去封装它。像这种表单,如果要设置初始值,我们也可以使用装饰器动态的调用接口来设置。

而且我们也许不需要通过继承的方式来实现验证器,而是通过将表单类传入到一个函数中,返回验证器。或者把验证器注入到表单中,有很多种方式来实现。

表单里的装饰器大多只用到了属性装饰器,其实方法装饰器也很有意思,类似拦截器,可以在方法调用前后执行你想要的操作,例如设置loading状态,完成错误处理,防抖节流等等。

如果大家想要尝试的话可以在vue中npm install vue-class-validator class-validator class-transformer reflect-metadata,或者去(github.com/AndSpark/vu…) 上看看(点个star)。

我也只是个前端萌新,如果哪里有不对的地方希望大佬能指正。

Guess you like

Origin juejin.im/post/7076701579222450190