19

从零开始实现一个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并遍历显示右侧列

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师