1. 前言
在前端开发工作中尤其是项目起步阶段,经常会选取第三方组件库快速构建,拿Vue
生态来说,大众普遍对element-ui
和iview
情有独钟,其中element-ui
多用于对接前台项目,iview
多用于对接后台管理项目。可不论是用哪种组件库,只要其易用性、扩展性和感官性足够强,通常都会为我们带来良好的体验。然而,当我们过分对第三方组件库产生依赖后,它也可能带来一些问题。比如A项目依赖element-ui
,B项目依赖iview
,且为了避免出现组件样式污染,通常一个项目只引用一个组件库。这样当在A项目中通过使用element-ui
的一些基础组件(如select)“封装”了一个新组件,我就不能直接在B项目中使用,而还是需要依赖iview
去重新写一套。久而久之,如果两个项目的共用组件越来越多,那将是不小的工作量。所以,这个时候就需要我们自己动手丰衣足食了。
当前各个论坛上已经有了很多如何搭建组件库项目的文章,但却鲜有具体组件开发思路的探讨和总结,因此本文会把重点内容放在后者。对于具体组件库项目的搭建方式,我比较推崇基于vuePress
来实现。在此也推荐掘友 @_安歌 的一篇文章【Vue进阶】青铜选手,如何自研一套UI库?。本文带大家看几个经典的例子,相信新手朋友一定会有一些收获。希望大家多多支持点赞,谢谢大家。
先看看我目前做的一个组件库,虽然还存在很大的提升空间,但只要时间充裕,我还是可以做地更好:
块级选择器:
树形组件:
想来想去,个人总结一下写自己组件库的一些好处:
- 更加深入组件的生命周期钩子,深刻掌握不同钩子下组件的状态;
- 更加使用一些平时写业务代码时比较少用的方法,如
<slot>
插槽、双向绑定、组件递归等; - 更加充分考虑组件的扩展性和复用性,提高业务驱动开发的能力;
- 更加充分考虑组件的唯一性,确保不会来自其他第三方组件库的样式所覆盖。
接下来我以选择器和树形组件作为例子,深入讲解一下实现过程和思路
2. 选择器 seletor
<select>
选择器是最常见的组件之一,具体又可分为一般选择器、多选选择器、远程搜索选择器等。当我们使用选择器时可能会想,会不会很多第三方组件库的选择器就是建立在传统<select><option>
元素上。其实不然,因为这样做太局限,针对复杂场景时的拓展性太差。别看只是一个小小的选择器,它所涉及到的问题有时比我们想象的要多,我们应该至少解决下述场景:
- 实现对option做适配,满足不同数据的兼容;
- 实现数值结果value和显示结果label的实时关联;
- 实现value的双向绑定,方便value的实时更新和获取。
2.1 结构优化
针对上述问题,我们会发现下面这种方式布局组件是存在很大缺陷的:
<template>
<kd-select
:options='optionList' // 传入选项集
@getResult='getResult'
/>
</template>
export default {
data() {
return {
result: ''
getResult: [
{
value: 'Juventus',
label:'尤文图斯',
},
{
value: 'RealMadrid',
label:'皇家马德里',
},
{
value: 'Barcelona',
label:'巴塞罗那',
}
]
}
},
methods: {
getResult(val) {
this.result = val;
}
}
}
复制代码
该方式固然可以实现,但太过局限,首先是<option>
完全没有暴露到外部,造成绑定<option>
的label
和 value
无法配置,即没解决问题1。其次result
也没有做双向绑定,不够简洁。因此,针对此类问题,对组件进行结构优化,代码如下:
<template>
<kd-select v-model="teamName">
<kd-option
v-for="(item,i) in teams"
:key="i"
:value='item.value'
:label='item.label'
/>
</kd-select>
</template>
export default {
data() {
return {
teamName: '',
teams: [
{
value: 'Juventus',
label:'尤文图斯',
},
{
value: 'RealMadrid',
label:'皇家马德里',
},
{
value: 'Barcelona',
label:'巴塞罗那',
}
]
}
}
}
复制代码
这样就直观地解决了问题1,从而也将select选择器划分为由两个子组件构成的形式。下述为<select>
的实现思路:
// select.vue
<template>
<div class="kd-input-select">
<div class="kd-input" v-outsideClick='showDropdown=false'>
<input
class="kd-input-inner"
type="text"
readonly
:value="result"
placeholder="请选择"
@click="showDropdown = !showDropdown"
/>
</div>
<div v-show="showDropdown">
<ul ref="dropdownList" class="kd-select-dropdown_list">
<slot></slot>
</ul>
</div>
</div>
</template>
<script>
export default {
model: {
prop: 'bindVal',
event: 'bindEvent'
},
props: {
bindVal: [Number,String],
},
data() {
return {
result: '',
showDropdown: false,
}
},
methods: {
getChoice(val) {
this.result = val.label; // result只作为选取的label,不作为绑定值
this.$emit('bindEvent', val.value);
}
}
};
</script>
复制代码
组件解析:
- 通常选择器的label和value是不同的,因此result只是作为显示label,真正是要通过bindVal做value,并通过
v-model
实现双向绑定;
下述代码为与之对应的option的简单实现:
// option.vue
<template>
<div>
<li
class="kd-select-dropdown__item"
@click="choice({ value:value, label:label })"
>
{{label}}
</li>
</div>
</template>
<script>
export default {
props: ['value', 'label'],
watch: {
'$parent.bindVal': {
handler(newVal , val) {
if(newVal && newVal === this.value) {
this.$parent.result = this.label;
}
},
immediate: true
}
},
methods: {
choice(val) {
this.$parent.getChoice(val)
}
}
}
</script>
复制代码
其中通过监听父级组件select
的bindVal
,将label
赋值给父级的result,从而解决结果显示的问题。
上述就是一个最基本的选择器的实现方式,我们还可以做的更精致、更具扩展性。比如设置icon、设置只读和禁止属性,以及实现可远程搜索等功能。限于篇幅,这里不再详述。
3. 树形组件
树形组件的功能很强大,主要用于对层级划分鲜明且复杂的数据做直观的二维展示。年初当我看到业务需求时,起初也是想直接使用element
现成的组件,但后来发现我们的视觉设计和数据结构很复杂,无奈只好自己亲手来了。对于树形组件,起码也要解决如下问题:
- 数据的扩展性,即数据层级可无限划分,组件也可无限承载;
- 充分暴露组件各钩子状态,如数据的加载、就绪以及销毁等生命周期阶段;
- 任意层级的触发事件要充分、高效地暴露到组件最外部;
- 解决组件初始化状态的问题;
- 保证各层级标识的唯一性,从而实现对不同层级进行样式、初始化事件等的特殊操作。
先看一个数据结构例子:
[
{
"code": "1212",
"id": "1",
"name": "云产品系统",
"type": "product-line",
"childs": [
{
"name": "公有云产品",
"id": 1,
"code": "gyyxl",
"type": "product",
"childs": [
{
"name": "财务会计",
"id": 1,
"code": "cwkj",
"type": "domain",
"childs": [
{
"code": "GL",
"id": 4979,
"name": "总账",
"type": "module",
"childs": null
},
]
},
{
"name": "财务总览",
"id": 2,
"code": "cwzl",
"type": "domain",
"childs": null
}
]
}
]
}
]
复制代码
可见,各层有name、id、code、type、childs等字段,且通过childs字段定义子层级。俗话说的好,"人之初,性本善"。在产品经理不断和我确认并拍着胸膛保证数据层级一定是4层且永远不会改变的情况下,作为一个实诚的老实人,我开始真的就老老实实写个下面这种组件:
// tree.vue
<template>
<section class="kd-product-line" v-for="(productLine , pl) in data">
<p class="kd-product-bar">
{{productLine.name}}
</p>
<ul> <!-- 1层 -->
<section class="kd-product" v-for="(product , p) in productLine.childs">
<p class="kd-product-bar">
{{product.name}}
</p>
<ul class="kd-rank-domain"> <!-- 2层 -->
<section class="kd-domain" v-for="(domain , d) in product.childs">
<p class="kd-doamin-bar">
{{domain.name}}
</p>
<ul class="kd-rank-module"> <!-- 3层 -->
<section v-for="(moduleObj, d) in domain.childs">
<p class="kd-module-bar">
{{moduleObj.name}}
</p>
<ul class="kd-rank-bizentity"> <!-- 4层 -->
<p class="kd-bizentity-bar" v-for="(bizentity, b) in moduleObj.childs">
{{bizentity.name}}
</p>
</ul>
</section>
</ul>
</section>
</ul>
</section>
</ul>
</section>
</template>
复制代码
是不是已经看不进去了?没错,写出这样一种类似数据结果层级关系的组件,不仅不扁平,也不利于后期的扩展和维护,大家都知道,产品经理的承诺听听就好,认真你就输了。比如有一天数据突然从4层变成了5层,你就要再写深一层,那如果变成100层呢?那岂不是可以直接撂挑子了。
3.1 组件递归
这个时候我们就需要用到组件递归了,即通过对上面的层级组件进行分析,找到可以提取的公共部分作为子组件(下述代码中的<tree-item>
组件),之后通过子组件递归的方式实现结构定义的抽象和扁平。代码如下:
// tree.vue
<div class="kd-tree card root">
<tree-item
v-for="item in data"
:key="item.id"
:item='item'
>
</tree-item>
</div>
import TreeItem from './treeItem'
export default {
name: 'kd-tree',
props: {
data: [Array],
},
components: {
TreeItem
},
}
</script>
复制代码
tree.vue
主要充当一个容器,用于承载子组件<tree-item>
及传递数据,且在此获取组件各周期。下面主要看一下<tree-item>
的实现:
// TreeItem.vue
<template>
<section class="tree-item">
<div
class="kd-bar">
<span>{{item.name}}</span>
</div>
<ul v-if="item.childs && item.childs.length > 0">
<!-- 组件递归 -->
<tree-item
v-for="secondItem in item.childs"
:key="secondItem.id"
:item='secondItem'
></tree-item>
</ul>
</section>
</template>
<script>
export default {
name: 'tree-item',
props: {
item: [Object],
},
}
</script>
复制代码
3.2 层级唯一性
通过组件递归的形式,即可实现代码的扁平和简洁。那如何实现各层级样式的差异?这里就需要定义各层级样式的唯一性。可见,最显而易见的方式就是定义每个层级的class的差异,这里我限定各层级的顶级class名为“kd-层级数”,依次类推,分别定义"kd-层级数-bar"、"kd-层级数-name",实现方式为:
// tree.vue
<template>
<div class='kd-tree'>
<tree-item
v-for="item in data"
:rankNum='initRank'
:key="item.id"
:item='item'
:initFold='initFold ? initFold : true'
>
</tree-item>
</div>
</template>
export default {
props: {
data: [Array], initFold: [Boolean]
},
data() {
return {
initRank: 1, // 最外层初始化层级数为1
}
}
}
// treeItem.vue
<template>
<section class="tree-item" :class="[outClassName]">
<div
class="kd-bar"
:class="[barClass]"
>
<span :class='[nameClass]'>{{item.name}}</span>
</div>
<ul v-if='item.childs && item.childs.length > 0'>
<tree-item
v-for='secondItem in item.childs'
:key='secondItem.id'
:item='secondItem'
:rankNum='(rankNum + 1)'
:initFold='initFold'
></tree-item>
</ul>
</section>
</template>
<script>
export default {
name: 'tree-item',
props: {
item: [Object],
parentNode: [Object],
rankNum: [Number],
initFold: [Boolean]
},
data() {
return {
outClassName: `kd-${this.rankNum}`,
barClass: `kd-${this.rankNum}-bar`,
nameClass: `kd-${this.rankNum}-name`,
foldChildNodes: true, // 是否折叠子节点
}
},
created() {
this.foldChildNodes = this.initFold;
},
}
复制代码
这样就实现了层级样式差异,且暴露给外部初始化状态的方式。看看解析出来的元素:
3.3 事件传递
之后就是<tree-item>
事件的暴露了。由子是<tree-item>
的递归,造成组件的抽象化,因此通过传统$emit
方式不断向父级传递事件的方式显得格外低效和繁琐。在此,相信很多人都有了答案,那就是通过eventBus的跨组件通信的方式实现事件直接暴露在tree
上:
// bus.js
import Vue from 'vue'
export default new Vue({ })
// treeItem.vue
<template>
<section>
<div>
<span
@click="controlChildNodes"
>{{item.name}}</span>
</div>
<ul
v-if="item.childs && item.childs.length > 0 && !foldChildNodes">
<tree-item
v-for='secondItem in item.childs'
:key='secondItem.id'
:item='secondItem'
:rankNum='(rankNum + 1)'
></tree-item>
</ul>
</section>
</template>
<script>
import eventBus from '../../../libs/utils/bus.js';
export default {
name: 'tree-item',
props: {
item: [Object],
rankNum: [Number]
},
methods: {
controlChildNodes() {
this.foldChildNodes = !this.foldChildNodes;
eventBus.$emit("node-click", this.item);
}
},
}
// tree.vue
<div class="kd-tree card root">
<tree-item
v-for="item in data"
:rankNum='initRank'
:key="item.id"
:item='item'
>
</tree-item>
</div>
<script>
import eventBus from '../../../libs/utils/bus.js';
import TreeItem from './treeItem'
export default {
props: {
data: [Array],
initFold: [Boolean],
},
data() {
return { initRank: 1 }
},
mounted() {
eventBus.$on("node-click",(itemData) => {
this.$emit("node-trigger", itemData);
})
},
}
</script>
复制代码
这样,外部调用组件时,就可以通过node-trigger
获取到触发节点。之后,针对具体业务场。
最后,菜炒好了,看一下怎么吃吧:
<template>
<div>
<kd-tree
:data='testData'
@node-trigger="handleNodeClick">
</kd-tree>
<section v-show="curNode">
当前点击的节点是:{{curNode.name}},
类型是:{{curNode.type}}
</section>
</div>
</template>
<script>
import Tree from '@components/tree'
export default {
name: 'kd-tree-demos',
components: {
'kd-tree': Tree
},
data() {
return {
testData: require('../public/treeData.json'),
curNode:''
}
},
methods: {
handleNodeClick(nodeVal) {
if(nodeVal) {
this.curNode = nodeVal;
}
}
}
}
</script>
复制代码
这样使用的感觉还是很简介鲜明的。
4. 总结
从自写一套组件库,我们会学习和领悟到很多平时写业务代码中很少用到的方法,强化我们的知识体系和开发能力。对于组件库,我们也要抱有持续开发和扩展的长线作战准备,只有不断优化,才会不断适应各种复杂新颖的业务。最后借用腾讯 @当耐特 大神的一句话:
你写的越好,头发掉的越多,别人就越方便,头发就掉的越少。