技术铺垫

本节内容以技术铺垫为主,如果你对这里的内容非常自信可以跳到下一节,当然也可以帮我找找错😂

必要性

随着Vue在国内市场的占有率越来越高,组件库同时也火了起来,很多公司内部也为了适应其自身业务而开始开发其自有的组件库,我们现在使用的Element UI(饿了么)、Nut UI(京东)等组件库都是大厂开源出来的组件库。与此同时,拥有组件库开发经验也已经成为了我们在应聘面试中的加分项。

组件库中的组件开发其实给跟我们平常开发组件本质是相同的,只不过我们需要尽可能地考虑到组件的复用性,使组件可以面对多种情况下的业务需求,而且组件库不能和业务耦合,应该和业务完全脱离,即使切换另一种业务也可以使用。

基础API

组件的开发脱离不开prop、slot和event这三个API,此外还要考虑组件之间的通信,比如form和form-item、checkbox和checkbox-group等,他们之间其实是有联系的

prop

prop在我们平常的开发任务中也是经常使用的一个API,用于子组件接受父组件传递的参数,尽管日常使用,但是还是有人在规范性等方面用得并不是很好。

抛弃数组写法:虽然支持数组写法,但是官方都不推荐的数组写方法就扔掉吧

// 摒弃吧
// props: ['status']
// 至少也这样吧
props: {
    status: String
    // 等同于
    // status: {
    //     type: String
    // }
}

键值的写法只是提供了类型限制,除此之外,prop还有其他的属性,比如必填项以及内容验证等;validator的值是一个函数,可以将当前prop接受的值传递给函数,返回值如果是一个失败类型的值就会在控制台抛出异常

下面的验证规则限制了status必须是’success’, ‘warning’, ‘error’中的一个

props: {
  status: {
    type: String,
    validator: function(prop) {
      return [
        'success',
        'warning',
        'error'
      ].indexOf(prop) >= 0
    },
    default: 'success',
    required: true,
    // 一般默认值不会和必填项同时出现
  }
}

然后我在使用组件时传入一个错误的值

<message status="warning1" />

QQ截图20210426153900-2

此外还有一点,子组件不能直接修改接收的prop值

slot

我理解的slot作用就是在子组件中插入父组件的内容,子组件在定义时提供一个插槽,父组件在使用子组件时可以向插槽中插入内容。相当于插座和插头,下面这张图类似于具名插槽,父组件使用的插槽名和子组件中的插槽名不匹配,就无法使用

QQ截图20210426160950-3

普通的插槽很简单,在子组件中声明插槽,父组件直接添加内容即可,插槽可以设置默认内容,当父组件没有内容传进来时,就会使用默认内容,类似于prop的default属性

<!-- 父组件 -->
<message>
    我是父组件的内容
</message>
<!-- 子组件 -->
<div>
    接受插槽:
    <slot>后备内容</slot>
</div>

QQ截图20210426161838-4

在上面我们提到了具名插槽,其实就是为了有多个插槽内容时区分内容。使用方法也很简单,在子组件声明slot时添加name属性即可,父组件在使用时添加v-slot:[name]属性(原来是slot=”name”,已废弃但没有删除,现阶段这样用也可以)

<!-- 父组件 -->
<message status="warning">
  <template v-slot:header>我是父组件的头部内容</template>
  <template v-slot:footer>我是父组件的底部内容</template>
</message>
<!-- 子组件 -->
<div>
  头部插槽:
  <slot name="header"></slot>
  <br />
  底部插槽:
  <slot name="footer"></slot>
</div>

QQ截图20210426162400-5

不添加name属性时,默认会赋值name=”default”,父组件在使用时不添加v-slot属性默认就是v-slot:default,所以普通插槽也相当于具名插槽,只是默认名。下面两种用法是一样的

<message status="warning">
  <template v-slot:default>我是父组件的内容</template>
</message>

<message status="warning">
  <template>我是父组件的内容</template>
</message>

还有一种作用域插槽,用简单的话来说就是,在父组件中使用子组件的数据,这里的作用域也就是指的父组件接收子组件作用域

子组件在声明插槽时绑定一个变量,父组件在使用插槽时使用v-slot=”props”接受这个变量(原来是slot-scope,同样已废弃),然后使用变量我们继续使用上面的例子稍加改造

<!-- 父组件 -->
<message status="warning">
  <template v-slot:header="sonData">{{sonData.user.name}}今年{{sonData.user.age}}岁了</template>
  <template v-slot:footer>我是父组件的底部内容</template>
</message>
<!-- 子组件 -->
<div>
  头部插槽:
  <slot name="header" :user="user"></slot>
  <br />
  底部插槽:
  <slot name="footer"></slot>
</div>

QQ截图20210426164339-6

v-slot指令可以缩写为#,比如v-slot:header可以缩写为#header,默认插槽不可以简写,但是你可以使用#default

event

事件这一块内容虽然很简单,但说起来比较琐碎,看文档吧😂

此外你还需要了解组件之间的通信,组件之间的通讯大致有ABCD这几种情况(大体列一下,不展开细说了)

3174701-7a15ec352b4e7d84-7

AB之间父子关系,AC和AD时祖先和后代的关系,CD之间是兄弟关系

  • props/$emit,用于父子之间通信
  • $on/$emit,可以在任意组件之间通信
  • $children/$parent,可用于父子组件
  • $ref,通过标记来获取组件实例
  • $attrs/$listeners,attrs包含了父作用域中不被 prop 所识别的属性,listeners包含了父作用域中的 (不含 .native 修饰器的) 事件监听器
  • provide/inject,可以在祖孙之间传递数据

实战一,Form组件

上一节我们为组件开发做了技术铺垫,这一节开始我们来进入实战环节

首先我们来实现一套Form组件,如果你使用过市面上比较流行的组件库,你应该知道一套Form组件应该包含这样的内容

QQ截图20210427095149-8

那么我们首先下一个用例,从用例出发,倒推组件

<f-form :model="userForm" :rules="rules">
  <f-form-item label="用户名" prop="username">
    <f-input v-model="userForm.username" placeholder="请输入用户名" />
  </f-form-item>
  <f-form-item label="密码" prop="password">
    <f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
  </f-form-item>
</f-form>

<script>
export default {
  data() {
    return {
      userForm: {
        username: '',
        password: ''
      },
      rules: {
        username: [
          {required: true, message: '请输入用户名'}
        ],
        password: [
          {required: true, message: '请输入密码'}
        ]
      }
    }
  }
}
</script>

从内而外实现,从input开始,然后form-item,最后form,话不多说开搞

f-input

首先来输入框,你可能会觉着这东西很简单,但是不要忘了我们之前说的,子组件不能直接修改prop接收的值,所以赶紧抛弃你直接v-model绑定prop的想法。

如果你不关闭警告你会看到这样的警告

QQ截图20210427103333-9

这里你需要知道,其实v-model是一个语法糖,下面这两行其实作用是一样的

1<input v-model="username" />
<br/>
2<input :value="username" @input="username = $event.target.value" />
<br/>
{{username}}

运行一下看下结果

QQ录屏20210427104125-10

所以在f-input中你可以这样来实现绑定的传递

<template>
  <div>
    <input :value="value" @input="inputHandler" />
  </div>
</template>

<script>
  export default {
    props: {
      value: {
        type: String,
        default: ''
      }
    },
    methods: {
      inputHandler(e) {
        this.$emit('input', e.target.value)
      }
    }
  }
</script>

这时候再去试试,发现已经可以了,控制台也没有报警告

QQ录屏20210427104853-11

然后你又发现了一个问题,设置的placeholder没有生效,因为他被添加到f-input上,而不是f-input里面的input上

QQ截图20210427105208-12

官网API文档有这么一段

QQ截图20210427105342-13

它的作用总结下来就是,如果开启此选项,父组件绑定的属性没有被props接收的,将会存在$attrs里面,可以使用v-bind将$attrs绑定在任意非根组件上,然后把我们的f-input稍加改造

<template>
  <div>
    <input :value="value" @input="inputHandler" v-bind="$attrs" />
  </div>
</template>

<script>
  export default {
    inheritAttrs: true,
    // 省略部分代码
</script>

完美,可以看到设置的密码型输入框也已经生效了

QQ录屏20210427105835-14

f-form-item

到这里就比上面的输入框稍微有点难度了,但也很简单,这里需要实现的点是

  • label文本
  • 给表单组件预留插槽
  • 按规则校验并提示错误

前两点都很简单,你应该比较畏惧第三点,其实这个也很简单,有现成的“轮子”——async-validator,作为程序员要杜绝重复造轮子😂

QQ截图20210427112200-15

想一下我们之前的用例中form-item的属性:label和prop,然后配置要接收的参数

<template>
  <div>
    <label v-if="label">{{label}}</label>
    <slot></slot>
    <p v-if="errorMessage">{{errorMessage}}</p>
  </div>
</template>

<script>
  export default {
    props: {
      label: {
        type: String,
        default: ''
      },
      prop: {
        type: String,
      }
    },
    data() {
      return {
        errorMessage: ''
      }
    },
  }
</script>

这样我们第一步和第二步都完成了,接下来就是validate了,接受的prop的作用就是为了从form组件中绑定的rules中获取需要使用的验证规则,同时也使用prop从form绑定的表单内容中获取当前表单项的值。这里可以使用provide/inject来传递数据

<script>
import Schema from 'async-validator'
  export default {
    inject: ['form'],
    props: {
      label: {
        type: String,
        default: ''
      },
      prop: {
        type: String,
      }
    },
    data() {
      return {
        errorMessage: ''
      }
    },
    methods: {
      validate() {
        const rule = this.form.rules[this.prop]
        const value = this.form.model[this.prop]
        // 获取验证规则实例
        const description = {[this.prop]: rule}
        const schema = new Schema(description)

        // 使用验证规则实例的验证方法
        return schema.validate({[this.prop]: value}, (error, field) => {
          if(error) {
            this.errorMessage = error[0].message
            console.log(`${field}验证未通过`);
          } else {
            this.errorMessage = ''
          }
        })
      }
    },
  }
</script>

然后添加验证事件的监听器,在内部表单项值修改时动态验证

mounted() {
    this.$on('validate', this.validate)
}

再顺便修改一下f-input的内容

methods: {
  inputHandler(e) {
    this.$emit('input', e.target.value)
    // 通知验证
    this.$parent.$emit('validate')
  }
}

OK~~form-item齐活

f-form

在完成了form-item和input组件之后,form组件就简简单单了,再来看一下form要做的事

  • 接收验证规则和表单数据
  • 给form-item预留插槽
  • 全局校验方法

首先我们来实现前两步,很简单,不要忘了之前form-item使用inject接收的数据,这里需要绑定上

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script>
  export default {
    provide() {
      return {
        form: this
      }
    },
    props: {
      model: {
        type: Object,
        required: true
      },
      rules: {
        type: Object
      }
    }
  }
</script>

接下来全局的校验,接受一个回调函数,回调函数的参数是一个布尔值,代表校验是否通过,使用Promise.all来执行多个验证器,只要有失败的就返回false

methods: {
  validate(cb) {
    const validators = this.$children
    .filter(item => item.prop)
    .map(item => item.validate())

    Promise.all(validators)
    .then(() => cb(true))
    .catch(() => cb(false))
  }
},

然后在修改一下用例,添加一个提交按钮,绑定全局校验

<template>
  <div>
    <f-form :model="userForm" :rules="rules" ref="formRef">
      <f-form-item label="用户名" prop="username">
        <f-input v-model="userForm.username" placeholder="请输入用户名" />
      </f-form-item>
      <f-form-item label="密码" prop="password">
        <f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
      </f-form-item>
      <button @click="submit">提交</button>
    </f-form>
  </div>
</template>

<script>
  // 省略部分代码
  methods: {
    submit() {
      this.$refs.formRef.validate(valid => {
        if(valid) {
          console.log('通过验证');
        } else {
          console.log('验证失败');
        }
      })
    }
  },
}
</script>

到现在我们就已经粗略的完成了一套form组件,当然他的功能并不止于此,这只是起了个头具体功能完善也就不在这里细说了,效果展示如下,样式细节也不在这里扣了

QQ录屏20210427164449-16

实战二,Message组件

上一节我们实现了写在模板里面的组件,还有一种组件比如message消息提示,它的使用要求比较灵活,不确定在什么组件中出现,所以不能写死在模板中,要使用js动态控制

这种组件就需要手动地实现挂载与卸载了,在开始之前需要做一下铺垫Vue.extend(options),创建一个子类包含配置的选项

首先我们来构建一个消息的模板

<template>
  <div class="message">
    <p>{{ message }}</p>
  </div>
</template>
<script>
export default {
  name: "message",
  data() {
    return {
      message: "message",
    };
  },
</script>

然后再创建一个Message.js,用于渲染这个模板

import Vue from 'vue';
import Message from './Message.vue'

export default function() {
  const MessageConstructor = Vue.extend(Message)

  const component = new MessageConstructor().$mount()
  document.body.appendChild(component.$el)
}

上面的最后一段代码,我们调用了 $mount 方法对组件进行了手动渲染,这里渲染之后的结果是一个Node节点,我们需要手动将它插入到document中

使用组件也很简单,导入并执行这个函数。我们来使用一个按钮触发这个消息渲染

<template>
  <div>
    <button @click="alert">消息</button>    
  </div>
</template>
<script>
  import Message from './Message.js'
  export default {
    methods: {
      alert() {
        Message()
      }
    }
  }
</script>

来看下效果

QQ截图20210427174737-19

还行,我们来继续完善一下,首先需要自动删除节点,我们要在出现提示之后一定时间内取消展示,思路也很简单,在mounted之后添加一个延时卸载Node节点的方法

mounted() {
    setTimeout(() => {
        // duration 后通过父级移除子元素的方式移除该组件实例和DOM节点
        this.$destroy(true);
        this.$el.parentNode.removeChild(this.$el);
    }, this.duration);
},

现在我们来考虑一下通过配置控制消息内容

  1. 调用组件时传入一个配置对象
  2. 在创建渲染实例时将参数传递进去,覆盖掉继承来的属性

对我们的Message.js稍加改造,让参数覆盖data选项

export default function(options) {
  const MessageConstructor = Vue.extend(Message)

  const component = new MessageConstructor({data: options}).$mount()
  document.body.appendChild(component.$el)
}

然后我们来使用一下,配合我们之前的表单校验一起试一下

<template>
  <div id="app">
    <f-form :model="userForm" :rules="rules" ref="formRef">
      <f-form-item label="用户名" prop="username">
        <f-input v-model="userForm.username" placeholder="请输入用户名" />
      </f-form-item>
      <f-form-item label="密码" prop="password">
        <f-input type="password" v-model="userForm.password" placeholder="请输入密码" />
      </f-form-item>
      <button @click="submit">提交</button>
    </f-form>
  </div>
</template>

<script>
import FFormItem from './components/FFormItem.vue'
import FInput from './components/FInput.vue'
import FForm from './components/FForm.vue'
import Message from './components/Message'

export default {
  name: 'App',
  components: {
    FFormItem,FInput,FForm
  },
  data() {
    return {
      userForm: {
        username: '',
        password: ''
      },
      rules: {
        username: [
          {required: true, message: '请输入用户名', tigger: 'blur'}
        ],
        password: [
          {required: true, message: '请输入密码', tigger: 'blur'}
        ]
      },
      username: ''
    }
  },
  methods: {
    submit() {
      this.$refs.formRef.validate(valid => {
        if(valid) {
          Message({
            message: '通过验证',
            duration: 5000
          })
        } else {
          Message({
            message: '验证失败',
            duration: 2000
          })
        }
      })
    },
  },
}
</script>

可以看到时间、内容都已经生效了

QQ录屏20210427180232-20

然后为了使用方便我们可以在入口文件将Message挂载到Vue的原型上Vue.prototype.$message = Message,,然后其他组件内部再使用时就可以直接this.$message()

这里我们只实现了一个简单的message,他还可以有很多扩展方向,比如通知类型(根据类型控制message颜色样式不一样)或者内嵌icon等,我们在这就不说了,有了这个结构再去拓展就很简单了

实战三,树形组件和动态组件

之前我们介绍了两种类型的组件封装,现在我们来说一下另外一种组件,递归组件。这种组件可用于列表展开、树形表格等

递归组件

我们在使用组件时一般都是先使用import导入,然后在components选项中声明,最后在组件中使用;但其实还有一种使用组件的方法,当组件声明了选项name之后就可以使用name调用自身了,这也是递归组件的原理

但是不可以直接使用,需要有一个限制条件,不然就会调用栈溢出了,比如下面这样

image-20210427215454844

我们来实现一个简易的树形组件,用来展开和闭合列表

首先我们来创建一个模板

<template>
  <li>
    <div @click="toggle">
      {{model.title}}
      <span v-if="isFolder">[{{open ? '-' : '+'}}]</span>
    </div>
  </li>
</template>

这里只是做示范,在使用外层需要有个ul包裹,当有子组件时会显示+-,展开式显示-,关闭时显示+,isFolder就是一个计算属性,用来判断有没有子组件,通过事件来控制open的开闭

export default {
  name: "Tree",
  props: {
    model: {
      type: Object,
      required: true,
    },
  },
  data() {
    return { 
      open: false 
    };
  },
    computed: {
    isFolder() {
      return this.model.children && this.model.children.length;
    }
  },
  methods: {
    toggle() {
      if (this.isFolder) {
        this.open = !this.open;
      }
    },
  },
};

我们来添加一下数据来试一下效果

<template>
  <ul>
    <tree v-for="(model, index) in treeData" :key="index" :model="model"></tree>
  </ul>
</template>

<script>
    export default {
    data() {
      return {
        treeData: [
        {
          title: '打野',
          children: [
            {
              title: '刺客',
              children: [
                {
                  title: '兰陵王'
                },{
                  title: '李白'
                }
              ]
          // ……省略大段数据代码
      }
    }
  }
</script>

现在的效果是这样的

image-20210427230021328

接下来我们就来注入灵魂,实现组件递归,将tree组件做一下修改

<template>
  <li>
    <div @click="toggle">
      {{model.title}}
      <span v-if="isFolder">[{{open ? '-' : '+'}}]</span>
    </div>
    <ul v-show="open" v-if="isFolder">
      <tree class="item" v-for="model in model.children" :model="model" :key="model.title"></tree>
    </ul>
  </li>
</template>

然后再看效果

QQ录屏20210427230225

完成,当然这只是粗略版本,这样拿出去肯定是用不了的,还需要拓展功能调整样式


前端小白