Vue封装组件系列文章
组件背景
根据产品原型实现一个级联组件,下面看演示图
应用场景很多,如:后台管理系统,旅游系统,广告投放系统,营销系统...等,现在流行Vue
,React
,Anagular
三大框架,下面看看怎么使用Vue
实现
实现逻辑
产品经理的评审功能需求如下
- 根据大分类到子分类层级选择,无层级限制(根据UI的横板宽度,适合做多级,但深度很深的场景并不多)
- 每个层级支持全选,根据子级可以推导全选项选中,并对其父级执行选中操作
- 已选层级可显示出结果列表,可对其结果操作,并有快速清空结果功能
- 分类名称字数并不做限制,待选区域分类名称应在该项中居中显示,长度过长换行显示
- 结果选项结构简化,每项固定一行,过长在尾部出现
...
代表过长,鼠标移上时显示全部内容
思路
Vue.js 的核心包括一套“响应式系统”。
"响应式",开发思路跟Jquery的开发思路完全不同。
“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。
根据地区数据 JSON
可以看出其结构
[
{
"value": "中国",
"key": 1156,
"id": 1156,
"children": [
{
"value": "北京市",
"id": 10000,
"key": 10000,
"children": []
},
{
"value": "河北省",
"key": 200107,
"id": 200107,
"children": [
{
"value": "石家庄",
"key": 20010701,
"id": 20010701
},
{
"value": "唐山市",
"key": 20010702,
"id": 20010702,
"children": [
{
"value": "路南区",
"key": 2001070201,
"id": 2001070201,
"children": []
}
]
}
]
}
]
-
中国
- 直辖市
-
xx省
-
xx市
- xx区
-
xx市
- xx县
-
待选数据组件
这是一个循环嵌套的数据对象,而组件嵌套似乎不能满足产品需求,如果使用数组来代替层级,似乎可以解决数据嵌套的问题
array => level 1 -> level 2 -> level 3 -> level 4
level 1 => current, children => level 2 (array)
level 2 => current, children => level 3 (array)
...
每个level
都是一个整体,
有标题title
有全选 计算data中是否都选中select
子集的集合数据data
有当前选中current
标记当期层级 数组的索引level
首先定义个空的数组代表组件
const array = []
把数据处理成数组格式就能展开这个组件,那怎么处理数据呢
初始化组件时不是所有都显示,必须让用户选择当前一个顶级大类
拿到所有顶级大类,并构建第一个元素
title = 省级
data = 顶级大类
current = 空
level = 1
select = false
array.push({title, select, data, current, level})
在选择顶级大类时,给这个数组增加其一个子集元素
array.push({title, select, data, current, level})
...
依次类推
结果选择器
获取组件的选择结果,
可以过滤数据的check 属性得到,
可使用Vue的计算属性得知随时的结果
结果选择框可以直接绑定已选的计算组件,可构建结果UI
组件构想
- 主组件
- 布局组件
- 选择项
主组件 Selecter
用来负责组件框架, 左右分栏,
左边是选择区域, 右边是结果区域
这个是组件引用层,统一对外提供导入props 数据
和 导出的 emit 事件
组件需要做到完全配置化,内部所以参数需要被抽象
- 选择区
更具层级平均分配空间,所有在横向固定空间中,不能做过多的层级,太窄了没法显示
因为需要循环显示其层级,抽离层级为布局组件,布局组件由 标题
和 滚动的选择区域
组成
<Row>
<Col :span="col" v-for="(box, idx) in resource" :key="idx">
<select-item :title="box.title">
<select-box v-model="box.current" :data="box.data" :level="box.level" @on-child="pushChild" @on-select="selectAll" />
</select-item>
</Col>
</Row>
- 结果区
在有选择时才显示,有标题栏显示,结果区可统计结果个数,选择项使用Tag标签,支持快速删除,建立纵向滚动条
可使用布局组件 与选择区保持风格统一,
<Col span="7" offset="1">
<select-item v-if="resultLen && transfer" title="已选" clear @on-clear="$emit('on-clear', {list: data})">
<div v-for="item in result" :key="item.id" class="c-pop-tip">
<Tag :name="item.value" closable class="c-tag-item" @on-close="handleClose">{{item.value}}</Tag>
</div>
</select-item>
</Col>
布局组件 item
要兼容选择区与结果区使用,所以统计个数得有开关控制,
边框,颜色 UI 控制
全选状态按钮 CheckBox
搜索输入框组件带搜索按钮
抽象 清空按钮UI
抽象 统计个数UI
选择项 box(子组件)
最关键的组件就算这个了
选择项应该可以类分成两种,
- 一种是到这一层级就没有子级的
- 一种是到这一层级还有子级的
使用条件判断即可实现分支显示,但是用 CheckBox
组件,他本身有change功能,如果是v-model
绑定的,他的值改变,会让主树上通知到这次更新,
这针对于上面的第二种,在这层级没有子级可以完成他的工作,他的更新,他的父级可以计算半选状态,也可以在父级计算选择的个数,但是如果是有子级,这里要响应他的所有子集也要选中,如果子集选中后,子集的全选也是选中状态
在开发的过程中,这里的变化关系很复杂,不用图形可解释不清楚
- 事件
点击行可以更改子集变化,
选中子集也要更改数据变化
- UI排版
逻辑
双向绑定
v-model 绑定数据的好处是: 数据在内部发生了改变,而在原始端同样改变了,只要使用就可以了,
当然在使用上也有些不方便的地方,
props导入的数据,通过什么props 属性接收呢, value
...
props: {
value: {
type: Array
}
}
...
在组件内部是不能Set 改变的,只能通过事件传到父组件中来
通过什么方法名来传呢, input
(初级很多人不知道) this.$emit('input', val)
原始数据构建选择层级组件
在初始化过程中,构建第一层级组件的 title
data
current
level
假使省市json 数据为 cityJson
构建第一层级的data
const data = this.cityJson.map(ret => {
delete ret.children
return ret
})
当用户选择层级的 item
时触发 动作新增层级数据
当用户选中层级的 item
时触发 动作新增层级数据 选中该层级下所有数据
全选
selectAll ({level, check, cat}) {
let index = level - 2
let current = index > -1 ? this.resource[index].current : ''
cat && (current = cat)
this.$emit('on-select', {
check,
current,
list: this.data
})
}
抛到根组件引用处处理,主要是循环当前层级的数据的check 属性为true
全选的checkbox 要屏蔽不能选择,让其选择事件通讯子组件中
搜索
搜索有两种实现,一种是前端正则实现,这里比较考验前端的正则能力,还有优化循环速度
另一种解法,就是通过后台查询结果,在根据结果筛选出数据显示,不能直接使用后端数据,因为破坏了树根数据,是没法计算选择的,在搜索里有清空功能,清空后的选择搜索前的当前项,代码如下
clearBox (level) {
let current
const index = level - 2
// 还原原来所有的data
if (index > -1) {
current = this.resource[index].current
this.pushChild({ level: index + 1, current })
} else this.resource[0].data = this.data
}
删除
结果框的清空的逻辑相对比较简单,只要把所有选择的数据 check 属性为 false
当然也可以用循环都设置一遍,但设置这里都要使用$set 去更新数据
<select-item
v-if="resultLen && transfer"
title="已选"
clear
@on-clear="$emit('on-clear', {list: data})">
<div
v-for="item in result"
:key="item.id"
class="c-pop-tip">
<Tag
:name="item.value"
closable
class="c-tag-item"
@on-close="handleClose">{{item.value}}</Tag>
</div>
</select-item>
事件是组件的关键的开发,事件的响应在引用的组件里处理
代码
贴上所有源代码,难免里面有些引用的文件,如果不能直接使用,请不要喷,因为这篇文章不是送个伸手党的,是你有一定的基础,想提升一下技能的你
主组件 Selecter
<template>
<div class="c-selecter">
<Row :gutter="12">
<Col span="16">
<Row>
<Col
:span="col"
v-for="(box, idx) in resource"
:key="idx">
<select-item :title="box.title">
<select-box
v-model="box.current"
:data="box.data"
:level="box.level"
@on-child="pushChild"
@on-select="selectAll" />
</select-item>
</Col>
</Row>
</Col>
<Col span="7" offset="1">
<select-item
v-if="resultLen && transfer"
title="已选"
clear
@on-clear="$emit('on-clear', {list: data})">
<div
v-for="item in result"
:key="item.id"
class="c-pop-tip">
<Tag
:name="item.value"
closable
class="c-tag-item"
@on-close="handleClose">{{item.value}}</Tag>
</div>
</select-item>
</Col>
</Row>
</div>
</template>
<script>
import SelectItem from './select-item.vue'
import SelectBox from './select-box.vue'
export default {
name: 'selecter',
components: { SelectItem, SelectBox },
props: {
value: {
type: Array
},
title: {
type: Array
},
data: {
type: Array
},
transfer: {
type: Boolean,
default: true
}
},
data () {
return {
resource: []
}
},
computed: {
col () {
return 24 / this.resource.length
},
result () {
return this.value
},
resultLen () {
return Boolean(this.value.length)
}
},
watch: {
data (nVal) {
if (nVal && nVal.length) this.updateResource()
else this.resource = []
}
},
methods: {
updateResource () {
this.resource = []
this.resource.push({
data: this.data,
current: '',
level: 1,
title: this.title[0]
})
},
handleClose (event, name) {
this.$emit('on-delete', {list: this.data, name})
},
selectAll ({level, check, cat}) {
let index = level - 2
let current = index > -1 ? this.resource[index].current : ''
cat && (current = cat)
this.$emit('on-select', {
check,
current,
list: this.data
})
},
pushChild (params) {
const {item, level} = params
const len = this.resource.length
if (level <= len - 1) {
this.resource.splice(level, len - level)
}
this.resource.push({
data: item.children,
current: '',
level: level + 1,
title: this.title[level] || item.value
})
this.resource[level - 1].current = item.value
}
},
created () {
this.updateResource()
}
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"
.c-pop-tip
width 100%
.c-tag-item
width 90%
margin 8px 8px 0
padding 2px 6px
display block
font-size 14px
height 28px
>>>span.ivu-tag-text
$no-wrap()
width calc(100% - 22px)
display inline-block
>>>.ivu-icon-ios-close
top -8px
</style>
布局组件 item
<template>
<div class="c-select-item">
<div class="c-header">
<span class="c-header-title">{{title}}</span>
<span class="c-header-clear" v-if="clear" @click="$emit('on-clear')">清空全部</span>
</div>
<div class="c-selecter-content">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'selectItem',
props: {
title: {
type: String
},
clear: {
type: Boolean
}
}
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"
.c-select-item
background-color #fff
border solid 1px #dee4f5
.c-header
padding 0 12px
height 34px
font-size 14px
color #333
border-bottom solid 1px #dee4f5
background-color #fafbfe
.c-header-title, .c-header-clear
height 34px
line-height 34px
vertical-align middle
.c-header-clear
color #598fe6
float right
cursor pointer
.c-selecter-content
$scroll()
height 246px
width 100%
padding-bottom 8px
</style>
选择项(子组件)box
<template>
<div class="c-select-box">
<div class="c-check-all">
<div class="c-item-select c-cataract" @click="selectAll"></div>
<Checkbox class="c-check-item" v-model="all">全选</Checkbox>
</div>
<div v-for="item in data" :key="item.id">
<div v-if="item.children && item.children.length" :class="itemClasses(item)" @click="$emit('on-child', {item, level})">
<Checkbox v-model="item.check" :indeterminate="itemIndeterminate(item)"></Checkbox>
<span>{{item.value}}</span>
<Icon type="ios-arrow-forward" class="c-check-arrow" size="14" color="#c1c1c1" />
<span class="c-item-checkbox c-cataract" @click="selectItem(item)"></span>
</div>
<Checkbox v-else class="c-check-item" v-model="item.check">{{item.value}}</Checkbox>
</div>
</div>
</template>
<script>
const computeChild = (list, Vue) => {
list.forEach(item => {
if (item.children && item.children.length) {
const child = item.children
if (child.every(ret => ret.check)) Vue.$set(item, 'check', true)
else Vue.$set(item, 'check', false)
computeChild(child, Vue)
}
})
}
export default {
name: 'selectBox',
props: {
value: {
type: [String, Number]
},
data: {
type: Array
},
level: {
type: Number
}
},
computed: {
itemClasses () {
return item => {
const cls = ['c-check-item']
item.value === this.value && cls.push('active')
return cls
}
},
all () {
const len = this.data.filter(ret => ret.check).length
return this.data.length === len
}
},
methods: {
selectAll () {
this.$emit('on-select', {
check: !this.all,
level: this.level
})
},
selectItem (item) {
this.$emit('on-select', {
check: !item.check,
level: this.level,
cat: item.value
})
},
itemIndeterminate (child) {
const hasChild = (meta) => {
return meta.children.reduce((sum, item) => {
let foundChilds = []
if (item.check) sum.push(item)
if (item.children) foundChilds = hasChild(item)
return sum.concat(foundChilds)
}, [])
}
const some = hasChild(child).length > 0
const every = child.children && child.children.every(ret => ret.check)
return some && !every
}
},
watch: {
data: {
handler (nVal, oVal) {
computeChild(nVal, this)
},
deep: true
}
},
mounted () {
computeChild(this.data, this)
}
}
</script>
<style lang="stylus" scoped>
@import "~assets/styles/mixin.styl"
.c-cataract
display block
position absolute
top 0
left 0
z-index 8
cursor pointer
.c-check-all
width 100%
height 36px
position relative
z-index 9
&:hover
.c-check-item
background-color #f8f8f8
.c-item-select
width 100%
height 100%
.c-check-item
margin 0
padding 0 12px
display block
position relative
height 36px
line-height 36px
&:hover
background-color #f8f8f8
&.active
color #598fe6
background-color #f8f8f8
.c-check-arrow
color #598fe6 !important
.c-check-arrow
float right
margin-top 10px
.c-item-checkbox
width 36px
height 36px
.c-select-box >>>.ivu-checkbox-indeterminate
.ivu-checkbox-inner
background-color #6fb3fb
border-color #6fb3fb
</style>
优化体验
- 半选功能
在一个大分类的子分类里选择的分类,但是切到别的大类项,虽然结果框里有选择的分类,但是待选的框里还是不能显示子集,需求上线后,客户反应体验不好,所以就研究了复选框的 半选
状态,其实改起来很简单,只要在计算属性的加个布尔值显示半选,布尔值就是该分类的data
里是否有选中的项check = true
-
行内文本过长,换行显示优化
因为分类的字数没有限制,做前端其实不能相信用户,同时也不能相信后端返回给的数据,也不能相信产品,在产品没有碰到过字数限制的功能时候产生的问题时,都是期待着用户是个正常的用户的。-
文本过长有两种方式解决:
- 在文本区域设置固定宽度,在超过长度显示... (如果要显示全,只能增加鼠标悬停显示功能了)
- 在
item
行的高度不使用line-height
的参数,用padding
做上下间隔后,让文本自动换行 (这样的问题是,右手边图标的居中问题,字数太多就会加高item
项,美观度没那么统一)
-
经验总结
很多前端新人都接触Vue一年、甚至两年多才会使用像element ui
、iview
、vant
开源的UI基础库,但细心的你可能发现,这些只适合参照原型图实现html编码,但业务的层次抽离、逻辑的复用、组件化业务层方面都没有手把手教我们上路。
三大流行框架的核心是快速地组件化开发,而我们只是简单的在路由组件页面堆积UI库的组件吗,显然这不是我们想要的高效开发。一个项目可以大到100多个页面,如果不抽离组件,重复工作量不可预估,效率更是谈不上了。那么如何像作者一样能更深层次使用Vue呢,其实element ui的开源库,每一个组件的实现其实都是很基础的方法实现的,假如你要实现这样的基础库,你就会想办法去看源代码,看着看着你就学会了作者的很多思想,那还会有什么的组件实现不了了?
师傅领进门,修行靠个人,人人都是我们的老师。不知你是否赞成...
以上,欢迎拍砖~
欢迎关注我的开源仓库
GITHUB:xiejunping (Cabber) · GitHub
微信二维码: 扫码添加好友,交个朋友
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。