vue3优雅实现移动端登录注册模块

4,569 阅读12分钟

前言

近期开发的移动端项目直接上了vue3,新特性composition api确实带来了全新的开发体验.开发者在使用这些特性时可以将高耦合的状态和方法放在一起统一管理,并能视具体情况将高度复用的逻辑代码单独封装起来,这对提升整体代码架构的健壮性很有帮助.

如今新启动的每个移动端项目基本上都包含注册登录模块,本次实践过程中针对登录注册中的表单控件做了一些经验上的总结,通过抽离提取共性代码来提升代码的可维护性和开发效率.

接下来观察一下美工同学提供的图片.

注册页面

1.png

登录页面

2.png

忘记密码页面

3.png

修改密码页面

4.png

通过观察上面几张产品图片,可以清晰看出构成整个登录注册模块的核心组件就是input输入框.只要把输入框组件开发完备,其他页面直接引用就行了.

输入框开发完了只实现了静态页面的展示,另外我们还要设计一套通用的数据校验方案应用到各个页面中的表单控件.

输入框组件

从上面分析可知,输入框组件是整个登录注册模块的核心内容.我们先看一下输入框组件有哪几种UI形态.

  • 形态一

5.png

左侧有文字+86,中间是输入框,右侧如果检测到输入框有数据输入显示叉叉图标,如果没有数据为空隐藏图标.

  • 形态二

6.png

左侧只有一个输入框,右侧是文案.文案的内容可能是验证码,也可能是点击验证码后显示的倒计时文案.

  • 形态三

7.png

左侧依旧只有一个输入框,右侧如果检测到输入框有内容显示叉叉图标,如果内容为空隐藏图标.

布局

依据上面观察而来的现象分析,我们设计这款input组件时可以将其分为左中右三部分.左侧可能是文案,也可能是空.中间是一个输入框.右侧可能是文案也可能是叉叉图标.

模板内容如下:

<template>
    <div class="input">
        <!--左侧,lt是左侧内容-->
          <span class="left-text">
            {{ lt }}
          </span>
        
        <!--中间-->
        <input class="content" v-bind="$attrs" :value="value" @input="onChange" />  
        
        <!--右侧,rt判端是验证码还是叉叉图标-->
          <div v-if="rt == 'timer'" class="right-section">
             {{ timerData.content }} <!--可能是'验证码',也可能是倒计时 -->
          </div>
          <div
            v-else-if="rt == 'close'"
            class="right-section"
          >
            <van-icon name="close" />  <!--叉叉图标-->
          </div>
    </div>  
</template>

布局上将左中右的父级设置为display:flex,子级的三个元素全部设置成display:inline-block行内块模式,目的是为了让左侧和右侧依据自身内容自适应宽度,而中间的input设置成flex:1充满剩余的宽度.

理论上这样的布局是可行的,但实践中发现了问题.

Demo效果图如下:

1.gif

右侧持续增加宽度时,中间input由于默认宽度的影响导致让右侧向外溢出了,这并不是我们想要的.

解决这个问题的办法很简单,只需要将中间inputwidth设置为0即可,如下便达到了我们想要的效果.

2.gif

v-model

外部页面引用上述封装的组件结构如下:

 <InputForm
        lt="+86"   <!--左侧显示+86--> 
        rt="close" <!--右侧显示叉叉图标-->
        placeholder="请输入手机号码"
 />

外部页面创建了一个表单数据form_data如下,但希望能通过v-model的形式将form_data的数据与子组件输入框的值建立双向数据绑定.

  const form_data =  reactive({
    number_number: '', //用户名
    password: '', //密码
    ppassword: '', //重复密码
    captcha: '', //验证码
  })

vue3实现v-model非常简便,在父组件中使用v-model:xx完成绑定,这里的xx对应着子组件要绑定的状态名称,如下所示.

 <InputForm
        lt="+86"   <!--左侧显示+86--> 
        rt="close" <!--右侧显示叉叉图标-->
        placeholder="请输入手机号码"
        v-model:value="form_data.password"
 />

接下来子组件里首先声明要绑定的属性value,并监听输入框的oninput事件.代码如下:

<template>
    <div class="input">
        ...
            <input class="content" v-bind="$attrs" :value="value" @input="onChange" />  
        ...
    </div>  
</template>
export default defineComponent({
  props: {
    lt:String,
    rt: String,
    value: String
  },
  setup(props, context) {
    const onChange = (e:KeyboardEvent) => {
      const value = (e.target as HTMLInputElement).value;
       context.emit("update:value",value);
    };
    return {
       onChange
    }
  }
 })

oninput事件的回调函数将获取到的值使用context.emit("update:value",value)返回回去.

其中update:value里前面部分update:为固定写法,后面填写要建立双向绑定的状态名称.如此一来就轻易的完成了v-model的绑定.

数据校验

一般来说只要页面上涉及到表单控件(比如输入框),那么就要针对相应的值做数据校验.如果按照原始的方法,当用户点击按钮,js接受响应依次获取每个表单项的值一一校验.

这样的做法当然可以实现功能,但并不高效和精简.因为很多页面都要做校验,大量的校验逻辑是重复书写的.

我们接下来设计一套通用的校验方案,将那些可以复用的逻辑代码都封装起来,并且能够快速的应用到每个页面上,提升开发效率.

依注册页面为例,模板代码如下.创建四个输入框组件:手机号,手机验证码,密码和确认密码.最后面再放置一个注册按钮.(为了看起来更清晰,下面的代码将所有ts类型删除)

 <Form ref="form" :rules="rules">
 
      <InputForm
        lt="+86"
        rt="close"
        v-model:value="form_data.number_number"
        placeholder="请输入手机号码"
        propName="number_number"
      />
      
      <InputForm
        rt="timer"
        v-model:value="form_data.captcha"
        placeholder="请输入手机验证码"
        propName="captcha"
      />

      <InputForm
        rt="close"
        v-model:value="form_data.password"
        placeholder="请输入密码"
        type="password"
        propName="password"
      />

      <InputForm
        rt="close"
        v-model:value="form_data.ppassword"
        placeholder="请输入确认密码"
        type="password"
        propName="ppassword"
      />

      <Button text="注 册" @sub="onSubmmit" /> <!--注册按钮-->

    </Form>

在借鉴了一些其他优秀框架的表单实践后,我们首先是在最外层增加了一个组件Form,其次给每个输入框组件增加了一个属性propName.这个属性是配合rules一起使用的,rules是手动定义的校验规则,当它传递给Form组件后,子组件(输入框组件)就能通过propName属性拿到属于它的校验规则.

整体的实现思路可以从头串联一遍.首先是前端开发者定义好当前页面的校验规则rules,并将它传递给Form组件.Form组件接受到后会将校验规则分发给它的每个子组件(输入框组件).子组件拿到校验规则后就能够针对输入框的值做相应的数据校验.

当用户点击注册按钮时,点击事件会获取Form组件的实例,并运行它的validate方法,此时Form组件就会对它旗下的每个子组件做一轮数据校验.一旦所有校验成功了,validate方法返回true.存在一个校验没通过,validate方法就返回false,并弹出错误信息.

注册页面逻辑如下:

export default defineComponent({
  components: {
    InputForm,  //输入框
    Button, //注册按钮
    Form,  //Form组件
  },
  setup(props) {
  
    const form_data = ...; //省略
    
    const rules = ...;
    
    //获取最外层Form组件的实例
    const form = ref(null);
    
    const onSubmmit = ()=>{
      if (!form.value || !form.value.validate()) {
         return false;
      }
      //校验通过了,可以请求注册接口了
    }

    return {
      form,
      rules,
      onSubmmit,
      form_data
    };
  },
});

定义一个变量form,用它来获取Form表单的实例.模板上<Form ref="form" :rules="rules">只需要加上一个ref属性就可以了.

用户点击注册按钮触发onSubmmit函数,因为form是使用ref创建的变量,获取值要调用.value.运行form.value.validate()函数,就能让Form表单下面的每一个子组件开始执行校验逻辑,如果全部通过就会返回true,存在一个没通过返回false.

从上面分析可知,Form控件只对外暴露一个validate函数,通过调用该函数就能知道校验是否通过.那么validate如何知道该采用什么规则来校验呢?所以我们要先设计一套校验的规则rules,把它传给Form组件,那么它内部的validate函数就能采用规则来执行校验.

rules设计

rules是一个对象,例如上述注册页面的rules定义如下:

const rules = {
      number_number:[{
                type: 'required',
                msg:"请输入正确的手机号" 
            }
                "phone"
            ],
      captcha:[
        {
          type: 'required',
          msg: '验证码不能为空'
        }
      ],
      password: [
        {
          type: 'required',
          msg: '请输入密码',
        },
        {
          type: 'minLength',
          params: 6,
          msg: '密码长度不能小于6位',
        },
      ],
      ppassword:[
        {
          type: 'custome',
          callback() {
            if (form_data.password !== form_data.ppassword) {
              return {
                flag: false,
                msg: '两次输入的密码不一致',
              };
            }
            return {
              flag: true,
            };
          },
        },
      ]
    }

我们定义的rules是一个键值对形式的对象.key对应着模板上每个输入框组件的propName,值是一个数组,对应着该输入框组件要遵守的规则.

现在细致的看下每个对象下的值的构成,值之所以组织成数组形式,是因为这样可以给输入框增加多条规则.而规则对应着两种形式,一种是对象,另外一种是字符串.

字符串很好理解,比如上面的number_number属性,它就对应着字符串phone.这条规则的意义就是该输入框的值要遵守手机号的规则.当然字符串如果填email,那就要当做邮箱来校验.

规则如果为对象,那么它包含了以下几个属性:

 {
   type, // 类型
   msg, //自定义的错误信息
   params, //传过来的参数值 比如 {type:'minLength',params:6},值最小长度不能低于6位
   callback  //自定义校验函数
 }

type是校验类型,它如果填required,表示是必填项.如果用户没填,点击注册按钮提交时就会报出msg定义的错误信息.

另外type还可以填minLength或者maxLength用来限定值的长度,那到底限定为几位呢,可以通过params传递过去.

最后type还可以填custome,那么就是让开发者自己来定义该输入框的校验逻辑函数callback.该函数要求最后返回一个带有flag属性的对象,属性flag为布尔值,它会告诉校验系统本次校验是成功还是失败.

Form表单

rules被定义好后传给Form组件,Form组件需要将校验逻辑分发给它的子组件.让其每个子组件都负责生成自己的校验函数.

<!-- 表单组件 -->
<template>
  <div class="form">
    <slot></slot>
  </div>
</template>

<script lang="ts">
import { ref, provide } from "vue";
export default defineComponent({
  name: "Form",
  props:{
    rules:Object
  },
  setup(props) {
    
    ...//省略

    provide("rules",props.rules); // 将校验规则分发下去
    
    const validate = ()=>{
      //向外暴露的校验函数
    }
    
    return {
      validate
    } 
  }
 })  
</script>    

从上面结构可以看出,Form组件模板提供了一个插槽的作用,在逻辑代码里利用provide将校验规则传给后代,并向外暴露一个validate函数.

子组件生成校验函数

这一次又回到了登录注册模块的核心组件InputForm,我们现在要给该输入框组件添加校验逻辑.

import { inject,onMounted } from "vue";
...

setup(props, context) {

  const rules = inject("rules");
  
  const rule = rules[props.propName];// 通过propName拿到校验规则
  
  const useValidate = () => {
            const validateFn = getValidate(rule); // 获取校验函数
            const execValidate = () => { 
               return validateFn(props.value); //执行校验函数并返回校验结果       
            };
            onMounted(() => {
                const Listener = inject('collectValidate');
                if (Listener) {
                  Listener(execValidate);
                }
            });   
  };
  
  useValidate(); //初始化校验逻辑
  ...
}

rules结构类似如下.通过injectpropName可以拿到Form分发给该输入框要执行的规则rule.

{
   captcha:[{
      type: 'required',
      msg: '验证码不能为空'
    }],
    password:[{
      type: 'required',
      msg: '请输入密码',  
    }]
}

再将规则rule传递给getValidate函数(后面会讲)获取校验函数validateFn.校验函数validateFn传入输入框的值就能返回校验结果.在这里把validateFn封装了一层赋予execValidate给外部使用.

在上面的代码中我们还看到了onMounted包裹的逻辑代码.当组件挂载完毕后,使用inject拿到Form组件传递下来的一个函数Listener,并将校验函数execValidate作为参数传递进去执行.

我们再回到下面代码中的Form组件,看一下Listener是一个什么样的函数.

setup(props) {

const list = ref([]);//定义一个数组

const listener = (fn) => {
  list.value.push(fn);
};

provide("collectValidate", listener); //将监听函数分发下去

//验证函数
const validate = (propName) => {
    const array = list.value.map((fn) => {
        return fn();
    });
    const one = array.find((item) => {
        return item.flag === false;
    });
    if (one && one.msg) {
        //验证不通过
        Alert(one.msg);//弹出错误提示
        return false;
    } else {
        return true;
    }
};
...

从上面可以看出,Form组件将listener函数分发了下去.而子组件在onMounted的生命周期钩子里,获取到分发下来的listener函数,并将子组件内部定义的校验函数execValidate作为参数传递进去执行.

这样一来就可以确保每个子组件一旦挂载完毕就会把自己的校验函数传递给Form组件中的list收集.而Form组件的validate方法只需要循环遍历list,就可以依次执行每个子组件的校验函数.如果都校验通过了,给外部页面返回true.存在一个不通过,弹出错误提示返回false.

走到这里整个校验的流程已经打通了.Form首先向子组件分发校验规则,子组件获取规则生成自己的校验函数,并且在其挂载完毕后将校验函数再返回给Form收集起来.这个时候Form组件向外暴露的validate函数就可以实现针对所有表单控件的数据校验.

接下来最后一步研究子组件如果通过规则来生成自己的校验函数.

校验

首先编写一个管理校验逻辑的类Validate.代码如下.我们可以不断的根据新需求扩充该类的方法,比如另外再增加email或者maxLength方法.

class Validate {

  constructor() {}

  required(data) { //校验是否为必填    
    const msg = '该信息为必填项'; //默认错误信息
    if (data == null || (typeof data === 'string' && data.trim() === '')) {
      return {
        flag:false,
        msg
      }
    }
    return {
        flag:true
    }
  }
  
  //校验是否为手机号
  phone(data) { 
    const msg = '请填写正确的手机号码'; //默认错误信息
    const flag = /^1[3456789]\d{9}$/.test(data);
    return {
      msg,
      flag
    }
  }
  
  //校验数据的最小长度
  minLength(data, { params }) {
   
        let minLength = params; //最小为几位
        
        if (data == null) {
          return {
            flag:false,
            msg:"数据不能为空"
          }
        }

       if (data.trim().length >= minLength) {
          return {flag:true};
       } else {
          return {
            flag:false,
            msg:`数据最小长度不能小于${minLength}位`
          }
       } 
   }

}

Validate类定义的所有方法中,第一个参数data是被校验的值,第二个参数是在页面定义每条rule中的规则.形如 {type: 'minLength', params: 6, msg: '密码长度不能小于6位'}.

Validate类中每个方法最终的返回的数据结构形如{flag:true,msg:""}.结果中flag就来标识校验是否通过,msg为错误信息.

校验类Validate提供了各种各样的校验方法,接下来运用一个单例模式生成该类的一个实例,将实例对象应用到真实的校验场景中.

 const getInstance = (function(){
    let _instance;
    return function(){
         if(_instance == null){
           _instance = new Validate();
         }
         return _instance;
      }
 })()

通过调用getInstance函数就可以得到单例的Validate实例对象.

输入框组件通过给getValidate函数传入一条rule,就能返回该组件需要的校验函数.接下来看一下getValidate函数是如何通过rule来生成校验函数的,代码如下:

/**
 * 生成校验函数
 */
export const getValidate = (rule) => {
    const ob = getInstance();//获取 Validate类 实例对象
    const fn_list = []; //将所有的验证函数收集起来
    //遍历rule数组,根据其类型获取Validate类中的校验方法放到fn_list中收集起来
    rule.forEach((item) => {
      if (typeof item === 'string') { // 字符串类型 
        fn_list.push({
          fn: ob[item],  
        });
      } else if (isRuleType(item)) { // 对象类型
        fn_list.push({
         //如果item.type为custome自定义类型,校验函数直接使用callback.否则从ob实例获取  
          ...item, 
          fn: item.type === 'custome' ? item.callback : ob[item.type],
        });
      }
    });
    //需要返回的校验函数
    const execuate = (value) => {
      let flag = true,
        msg = '';
      for (let i = 0; i < fn_list.length; i++) {
        const item = fn_list[i];
        const result = item.fn.apply(ob, [value, item]);//item.fn对应着Validate类定义的的校验方法
        if (!result.flag) {
          //验证没有通过
          flag = false;
          msg = item.msg ? item.msg : result.msg;//是使用默认的报错信息还是用户自定义信息 
          break;
        }
      }
      return {
        flag,
        msg,
      };
    };
    return execuate;
};

rule的数据结构形类似如下代码.当把rule传入getValidate函数,它会判端是对象还是字符串,随后将其类型对应的校验函数从ob实例中获取存储到fn_list中.

 [
    {
      type: 'required',
      msg: "请输入电话号码"
    },
    "phone"
 ]

getValidate函数最终返回execuate函数,此函数也正是输入框组件得到的校验函数.在输入框组件里是可以拿到输入框值的,如果将值传给execuate方法调用.方法内部就会遍历之前缓存的校验函数列表fn_list,将值传入每个校验方法运行就能获取该输入框组件对当前值的校验结果并返回回去.

以上校验的逻辑也已经走通了.接下来不管是开发登录页,忘记密码或者修改密码的页面,只需要使用Form组件和输入框InputForm组件组织页面结构,并写一份当前页面的rules校验规则即可.剩下的所有校验细节和交互动作全部交给了FormInputForm内部处理,这样会极大的提升开发效率.

最终效果

3.gif