从零开始实现一个 Vue 级联组件
本文实现级联组件需要用到 自定义指令 和组件通信 相关知识,最好先阅读以下两篇文章:
Vue 自定义指令
Vue 组件基础与通信
一、组件简介
本文实现的是一个省、市、县 …多级联动组件,当组件渲染完成后默认会加载出所有的省名称,当用户点击某个省的名称后,右边会自动添加一列显示该省下对应的市名称列表,当用户点击某个市后,右边又会自动添加一列显示该市下对应的县名称列表,同时支持级联列表的打开和关闭。
二、组件实现设计思路
① 组件所需要的数据,数据结构非常简单,对象里面只有两个属性,一个是 label(标签名),如果当前标签下还有子标签,则会多一个children 属性,children 属性值为一个 数组 , 每个数组元素为其下的一个子标签。
// data.json,为避免数据占用太多篇幅,这里只列举了一条数据
[
{
"label": "江西",
"children": [
{
"label": "赣州",
"children": [
{"label": "全南县"},
{"label": "龙南县"}
]
}
]
}
]
② 我们的级联组件分为上下两部分组件,上部分显示用户选择的路径,下部分显示用户选择列表,同时支持点击级联组件的上部分可以实现下半部分的打开和关闭,点击组件外面关闭组件的下半部分,这里需要用到 v -click-outside 指令,这里自定义指令的代码就不再重复,请参考 Vue 自定义指令
// Cascader.vue 新建一个 Cascader.vue 组件
<template>
<div class="cascader" v-click-outside="close"> <!-- 实现点击组件外面关闭组件下半部分 -->
<div class="title" @click="toggle">{{resultPath}}</div> <!-- 点击上半部分可以实现下部分的显示和隐藏切换 -->
<div class="content" v-if="isVisible">
<!-- 组件下半部分,即选择列表部分 -->
</div>
</div>
</template>
<script>
import clickOutside from "./../directives/clickOutside";
export default {
name: "Cascader",
directives: { // 在当前组件上注册 clickOutside 指令
clickOutside
},
props: ["options"], // 定义一个 options 属性用于接收外部传递给级联组件的数据,即选择项列表
data() {
return {
isVisible: false,
selectedItems: [] // 用户已选择项}
},
computed: {resultPath() { // 通过用户已选择项计算出用户的选择路径
return this.selectedItems.map((item) => item.label).join("/");
}
},
methods: {close() {// 关闭下半部分(选择列表部分)
this.isVisible = false;
},
toggle() { // 下半部分 (选择列表部分) 显示和隐藏的切换
this.isVisible = !this.isVisible;
}
}
}
</script>
注意到组件中有一个 selectedItems 数据,这是一个 数组 ,默认值为 空数组 ,因为当级联组件渲染完成后,默认用户是没有点击选择其中任何一项的,只有当 用户点击了某一项后 ,才会将 点击的 这一项添加到 selectedItems 数组中,其就是 记录用户的选择项 。这里需要理解清楚选择项的概念:
比如我们的级联组件有三列,省、市、县三列 ,结合上面的数据结构,整个省是一个大对象,即 省对象 ,省对象中有 children 属性,里面包括多个子对象,即 市对象 ,市对象中又包括 children 属性,里面包括多个子对象,即 县对象 ,县对象中不再有 children 了,具体表示就是:
省对象:
{“label”: “ 江西 ”, children: [省略 …]}
市对象:
{“label”: “ 赣州 ”, children: [省略 …]}
县对象:
{“label”: “ 全南县 ”}
当用户点击 第一列 ,那么就 将整个省对象 添加到 selectedItems 数组中的 第一项 位置,当用户接着点击了 第二列 ,如省对象中的 label 为 ” 赣州 ” 的市对象,则 将整个市对象 添加到 selectedItems 数组中的 第二项 位置,当用户又点击了 第三列 ,如 ” 赣州 ” 市对象下的 label 为 ” 全南县 ” 的县对象,则 将整个县对象 添加到 selectedItems 数组中的 第三项 位置,这样 selectedItems 数组中就保存了用户选择的三列数据了,然后 将三列数据中的 label 取出通过 ”/” 连接起来,即用户的选择路径 ” 江西 / 赣州 / 全南县 ”。
③ 接下来就是考虑组件拿到数据后,如何渲染的问题了 ?
这里需要用到 组件内递归组件 ,我们可以左右两列抽象成一个单独的组件 CascaderItem.vue,但是 右边这一列会不会显示得看用户有没有选择左边的项,如果点击了左边的项则显示右边的列,如果没有点击左边的项则不显示右边的列。
还是以省、市、县三列为例,中间的市这一列,既是省的右列,也是县的左列 ,我们已经将左右两列抽象了一个单独的 CascaderItem 组件,关键是理解 省这一列的右边部分到底是什么 ?,从表面上看,省这一列的右边就是一个市列,但是如果右边仅仅是市这一列的话,那么 当用户点击市这一列中的某项的时候,就无法显示市右边的县列了 ,所以 省这一列的右边其实又是一个 CascaderItem 组件 ,只有这样点击市列中的某一项的时候,其右边的县列才会显示出来。所以我们需要 在 CascaderItem 组件内递归自己 ,而组件内递归自己,那么 必须给组件添加 name 属性 ,即 给组件取一个名字,如:
// CascaderItem.vue
<template>
<div class="cascader-item">
<!-- 首先渲染出级联组件的最左边部分 -->
<div class="content-left">
<div v-for="(item, index) in options" :key="index">
<div class="label" @click="select(item)"> {{item.label}}</div>
</div>
</div>
<!-- 点击左边中的某个选项后,lists 才会有值才会渲染右边部分,同样渲染右边部分的时候,也是先渲染左边部分 -->
<div class="content-right" v-if="lists && lists.length">
<CascaderItem :options="lists" :selectedItems="selectedItems" :level="level + 1" @change="change"></CascaderItem>
</div>
</div>
</template>
<script>
export default {
name: "CascaderItem", // 给组件起个名字,方便组件内部递归调用,即组件内部自己调用自己
props: ["options", "selectedItems", "level"],
computed: {lists() {
// 根据内容 value 的变化显示列表,根据当前点击位置对应的 level 去获取要显示的列表
return this.selectedItems[this.level] && this.selectedItems[this.level].children;
}
},
}
</script>
CascaderItem 组件组件的渲染数据来自于顶层父组件 Cascader 中的 selectedItems 数据,因为用户点击了左侧列中的项后,会将点击的 item 项添加到 selectedItems 中 ,selectedItems 中数据变化之后才会显示右侧的列。
CascaderItem 组件需要接收一个level 属性,用来记录当前 CascaderItem 组件所属层级,即第几列,为了方便,我们 从 0 开始表示第一列 ,即第一层所以 Cascader.vue 中 level 传入 0, 后面没加一层 level 会加 1 ,如:
// 补全上面的 Cascader.vue,渲染出下半部分
<template>
<div class="cascader" v-click-outside="close">
<div class="title" @click="toggle">{{resultPath}}</div>
<div class="content" v-if="isVisible">
<!-- 将左右两部分封装为一个组件,然后循环输出组件 -->
<CascaderItem :options="options" :selectedItems="selectedItems" :level="0" @change="change"></CascaderItem><!-- 传入 level 从 0 开始 -->
</div>
</div>
</template>
CascaderItem 组件的左边部分都监听了一个 click 事件,当用户点击左边的列选项后,需要 将当前所在 level 和 item 对象数据传递到 Cascader 父组件中的 selectedItems 数组中 ,以便获取用户的选择路径,因为 单向数据流 ,子组件不能直接修改父组件传递过来的数据,所以需要 去父组件中修改数据 ,这里 以事件的方式通知顶层父组件自己更新数据。
// CascaderItem.vue 给 CascaderItem 组件添加一个 select()方法
export default {
methods: {select(item) { // 处理 CascaderItem 组件内左侧列点击事件,item 为当前点击的对象
// 向上一级发射一个 change 事件,通知上层进行修改,并将当前点击的层级 level 和 item 传递过去
this.$emit("change", {level: this.level, item: item});
}
}
}
由于 CascaderItem 是递归调用的,所以现在的组件调用关系为: Cascader –> CascaderItem –> CascaderItem –> CascaderItem –> ……
顶层父组件为 Cascader,所以CascaderItem 也可能是 CascaderItem 的父组件,CascaderItem 组件自身也需要监听 change 事件,主要就是负责将数据改变信号传递到 Cascader 顶层父组件上,如:
// CascaderItem.vue 给 CascaderItem 组件添加一个 change 事件处理方法
export default {
methods: {change(newValue) { // 向顶层传递数据改变信息
this.$emit("change", newValue);
}
}
}
顶层父组件 Cascader 接收到数据改变信号后,就需要改变 selectedItems 数据了,即将用户的选择项添加到对应的位置,如:
// Cascader.vue 添加 change 事件处理函数
export default {
methods: {change(newValue) {this.selectedItems.splice(newValue.level, 1, newValue.item); // 替换当前点击位置信息
this.selectedItems.splice(newValue.level + 1); // 删除当前点击位置之后的数据
}
}
}
Cascader 组件除了替换掉指定 level 中的数据外,还需要将当前 level 之后的数据删除掉,否则当前 level 之后的数据还在,导致右侧路径仍然保留而显示不一致。
至此,一个简单的级联组件就实现了,可以在 App.vue 中直接使用,如:
// App.vue
<template>
<div>
<Cascader :options="options"></Cascader> <!-- 直接将数据传递给级联组件即可 -->
</div>
</template>
<script>
import Cascader from "./components/Cascader";
import dataList from "./data/data.json";
export default {
components: {Cascader},
data() {
return {options: dataList}
}
}
</script>
三、总结
整个 Cascader 组件设计思路就是: 在顶层父组件 Cascader 中 添加一个 selectedItems 数组 ,用于保存用户点击的level 层级(列序号) 和对应的 item 对象,同时用于 生成用户的选择路径 ,当用户点击了 CascaderItem 组件的左侧列中某项后, 通过层层传递事件的方式通知顶层父组件 Cascader 对其数据进行更新 ,顶层父组件 Cascader 更新数据后,CascaderItem 组件 从 selectedItems 中取出对应 level 的 item 对象,然后获取 item 的 children 并遍历显示右侧列