Cascader级联选择器真的需要灵感

604 阅读9分钟

Hello大家好,我是小九九的爸爸。今天突然心血来潮,准备手撸一个Cascader级联组件。其实说实话,以前也写过这种组件,可是今天再写的时候我发现,看山不是山,看水也不是水。这个组件还是需要一些灵感的,区别于其他组件,它不是那种你能顺着任意思路都能完成的组件,如果思路不对,这个组件在实现上可能就特别难受。

现在来看一下这次的实现效果:

一、组件分析

Cascader级联组件1.png

一个最基本的级联组件,无论是单选还是多选,它都应该包括上面这2个最基本的功能。

二、功能拆解

2.1、展开多级菜单

什么时候去展开菜单?小编认为有2个时机,分别是:

  • 输入框focus 或者 显示框click。
  • 上级菜单面板click。

这样一来,我们需要将创建面板的功能单独抽成一个函数单元。

当这些时机被触发时,调用创建面板函数。

2.2、支持默认值并且支持默认展开

伪代码如下:

<cascader
   defaultValue = '1/1-1/1-1-1'
   data = [
      {
        label: '1',
        value: '1',
        children: [
           {
              label: '1-1',
              value: '1-1',
              children: [
                 label: '1-1-1',
                 value: '1-1-1'
              ]
           }
        ]
      }
   ]
/>

根据上面的伪代码,我们知道给定一个默认值,当我们第一次显示面板时,需要显示3级面板。具体几级面板是根据默认值以及绑定的value决定的。

因此我们可以这样来做,因为click上级面板时需要显示下级面板。所以我们可以将click功能抽离成一个函数,当有默认值并且是第一次展开的时候,我们通过代码手动调用对应的dom的click事件,即可完成展开默认面板的这个功能。

三、逐个击破

我们这里使用原生的html、js、css来完成这个组件的开发,这个过程也请大家跟着我一起写,这样就能体会到jsx语法,某些框架内置的模版语法是有多么的牛逼、多么的高效。

3.1、初始化dom结构

<div class="cascader-component-box">
    <div class="value-box" onclick="initCascaderComponent()"></div>
    <div class="cascader-value-box"></div>
</div>

解释如下:

Cascader级联组件3.png

更具体的dom结构如下:

| - Cascader总容器(class:cascader-component-box)
    | - 用于显示value的容器(class:value-box)
    | - 数据面板的总容器(class:cascader-value-box)
        | - 数据面板1class:cascader-value-panel)
            | - 数据小项1-1class:data-item、selected-data-item)
            | - 数据小项1-2
        | - 数据面板2
        | - 可能存在的其他数据面板...

3.2、初始化样式

.cascader-component-box {
    margin-top: 200px;
    margin-left: 300px;
    width: 150px;
    height: 30px;
    box-sizing: border-box;
    border: 1px solid #cecece;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    border-radius: 5px;
}

// 数据面板的父容器,用于设置公共的shadow
.cascader-value-box {
    border-radius: 5px;
    box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
    border: 1px solid #E4E7ED;
    position: absolute;
    bottom: -132px;
    display: flex;
}
// 每块数据面板的样式
.cascader-value-panel {
    width: 150px;
    height: 130px;
    box-sizing: border-box;
    overflow-y: auto;
    background: rgba(0,0,0,0);
}
// 除了最后一块数据面板,其余的数据面板都加上border-right
.cascader-value-panel:not(:last-child) {
    border-right: solid 1px #E4E7ED;
}
// 每一个数据小项的默认样式
.data-item {
    width: 100%;
    height: 30px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    padding-left: 10px;
}
// 数据小项被选中时的样式
.selected-data-item {
    background-color: #F5F7FA;
    color: #409EFF;
}
// 数据小项hover时的样式
.data-item:hover {
    background-color: #F5F7FA;
}
// 显示value的字体
.value-box {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    font-size: 12px;
}

相信大家理解了3.1的dom结构,上面的这些样式一眼就能看明白。

此时的组件如下:

Cascader级联组件5.png

平平无奇的大白板而已。

3.3、初始化变量

我们这里需要准备的变量如下:

// 级联面板绑定的数据源
let data = [];

// 级联组件目前展开的最大深度
let isShowMaxLengthPanel = 0;

// 级联组件选中的值
let selectedLabel = '';

// 级联组件的默认值
let defaultValue = 'Vue/vue选项/props选项';

// 是否显示级联面板
let isShowValue = false;

到这里大家跟上了吗,我们下一步就是去写如何创建面板。

3.4、创建级联面板

function initDataPanel(){}

我们这个函数主要是创建面板,并且将面板添加到对应的dom里。因此initDataPanel函数的参数如下:

/**
 * @params parentDOM     数据面板的父级DOM
 * @params fatherData    父级数据
 * @params curLevelData  当前层级的数据集合
 * @params curLevel      当前数据在data里的层级
*/

接下来的步骤如下:

  • 先创建当前层级的数据面板。
  • 遍历curLevelData,创建每个数据小项,并且为每个数据小项添加click事件。
  • 将每个数据小项添加到刚创建的数据面板。
  • 将数据面板添加到parentDOM里。

知道了这个函数要干什么,我们就能写出来如下代码:

function initDataPanel(parentDOM, fatherData, curLevelData = [], curLevel){
    // 1、创建数据面板
    let dataPanelDom = document.createElement('div');
    dataPanelDom.classList.add('cascader-value-panel');
    // 2、遍历当前层级里的数据
    for (let index = 0; index < curLevelData.length; index++){
        // 3、创建数据小项
        let curLevelDataItem = curLevelData[index];
        let curLevelDataItemDom = document.createElement('div');
        let curDataItemText = document.createTextNode(curLevelDataItem.label);
        curLevelDataItemDom.classList.add('data-item');
        curLevelDataItemDom.appendChild(curDataItemText);
        dataPanelDom.appendChild(curLevelDataItemDom);
        // 为每个数据小项添加click事件
        curLevelDataItemDom.onclick = function(event){
            // 点击事件(待实现...)
            clickDataItem([...fatherData], curLevelDataItem, parentDOM, dataPanelDom, curLevelDataItemDom, curLevel);
        }
    }
    // 4、将数据面板添加到parentDOM里
    parentDOM.appendChild(dataPanelDom);
    // 5、设置当前数据面板在data里的层级
    isShowMaxLengthPanel = curLevel;
}

现在外面还不能显示数据面板,因为我们还没给valueBox添加click事件。我们现在来实现一下initCascaderComponent函数。

function initCascaderComponent(){
    isShowValue = !isShowValue;
    if (isShowValue){
        let parentDOM = document.querySelector('.cascader-value-box');
        initDataPanel(parentDOM, [null], data, 1);
    }
}

initCascaderComponent函数很简单,就是通过代码手动触发创建面板函数,并且将数据面板的父DOM以及data传进来。

准备数据如下:

let data = [
    {
        label: 'Vue',
        value: 'Vue',
        children: [
            {
                label: 'vue生命周期',
                value: 'vue生命周期'
            },
            {
                label: 'vue选项',
                value: 'vue选项',
                children: [
                    {
                        label: 'data选项',
                        value: 'data选项'
                    },
                    {
                        label: 'props选项',
                        value: 'props选项'
                    }
                ]
            },
            {
                label: 'vue组件',
                value: 'vue组件'
            }
        ]
    },
    {
        label: 'React',
        value: 'React',
        children: [
            {
                label: 'class组件',
                value: 'class组件'
            },
            {
                label: '函数式组件',
                value: '函数式组件'
            }
        ]
    },
    {
        label: 'Webpack',
        value: 'Webpack'
    }
];

现在的效果如下:

我们会看到,目前我们只能创建一级面板,其余都是不符合预期的,比如正确控制面板的显示与隐藏,正确的打开数据面板等等。这是正常现象,因为我们其余逻辑也没写啊。

3.5、实现数据小项的click事件

我们可以将click事件单独抽成一个名为“clickDataItem”的函数,这个函数的主要功能如下:

Cascader级联组件6.png

首先,“支持展开多级菜单”这个功能倒是没啥,再调用一次 initDataPanel呗。

其次,什么是“支持菜单之间的切换”?

Cascader级联组件7.png

就像上图表达的一样,菜单之间的切换分2类:同级切换、跨级切换。

还记得我们初始化过一个变量吗?isShowMaxLengthPanel,这个变量会在数据面板创建后更新,这个值就是当前level层级。

我们要做的就是创建新的数据面板之前,删除掉之前多余的数据面板?哪些面板是多余的呢?我们可以比对isShowMaxLengthPanel与curLevel。

如果不想等,那就获取所有的数据面板,然后删除curLevel到isShowMaxLengthPanel之间的数据面板。当然这个过程,isShowMaxLengthPanel的值可以被document.querySelectorAll('.数据面板').length替代。

如果相等,那就不用管了,正常创建下级面板或者关闭面板。

最后,选中末级数据,如何关闭所有数据面板?我们将所有数据面板的父级DOM的display设置为none即可。

经过上面的分析,clickDataItem源码如下:


/**
     * @description 面板里每条数据的点击事件
     * @params 
     *   fatherData: 当前数据的所有父级数据
     *   curLevelDataItem: 当前数据项的信息
     *   grandpaDOM: 当前数据所在的爷爷级dom
     *   fatherDom: 当前数据所在的父级dom
     *   curLevelDataItemDom: 当前数据对应的dom
     *   curLevel: 当前的层级
*/

function clickDataItem(fatherData, curLevelDataItem, grandpaDOM, fatherDom, curLevelDataItemDom, curLevel){
    // 1、清除当前层里其他数据的选中颜色(首先要获取当前层的其他Dom)
    let otherBrotherDom = Array.from(fatherDom.childNodes);
    for (let index = 0; index < otherBrotherDom.length; index++){
        let curOtherBrotherDom = otherBrotherDom[index];
        if (curOtherBrotherDom.classList.contains('selected-data-item')){
            curOtherBrotherDom.classList.remove('selected-data-item');
        }
    }
    // 2、给当前选中的dom添加背景色
    curLevelDataItemDom.classList.add('selected-data-item');
    // 3、解决数据面板切换的问题
    let hasCreatedDataPanelArr = Array.from(document.querySelectorAll('.cascader-value-panel'));
    if (curLevel <= isShowMaxLengthPanel){
        // 如果此时点击了前面的父面板,那么此时应该删除子面板,并且重新创建子面板
        for (let index = 0; index < hasCreatedDataPanelArr.length; index++){
            if (index > curLevel - 1){
                grandpaDOM.removeChild(hasCreatedDataPanelArr[index]);
            }
        }
    }
    // 4、如果有下级数据,则展开下级面板
    if (curLevelDataItem.children?.length > 0){
        initDataPanel(grandpaDOM, [...fatherData, curLevelDataItem], curLevelDataItem.children, curLevel + 1);
    } else {
    // 5、否则,执行末级数据选中的情况(将所有选中的数据拼接起来)
        let result = '';
        for (let index = 0; index < fatherData.length; index++){
            if (fatherData[index] !== null){
                result = result + fatherData[index].label + '/'
            }
        }
        result = result + curLevelDataItem.label;
        selectedLabel = result;
        isShowValue = false;
        if (document.querySelector('.value-box').childNodes[0]){
            document.querySelector('.value-box').replaceChild(document.createTextNode(selectedLabel), document.querySelector('.value-box').childNodes[0]);
        } else {
            document.querySelector('.value-box').appendChild(document.createTextNode(selectedLabel));
        }
        // 6、返回数据后,将所有的数据面板dom隐藏
        controlOfCascader('none');
    }
}

controlOfCascader函数用于显示与隐藏面板,源码如下:

function controlOfCascader(value){
    document.querySelector('.cascader-value-box').style.display = value;
}

我们还需要再改造下value的显示框,点一次显示cascader,再点一次就关闭cascader。

initCascaderComponent源码修改如下:

function initCascaderComponent(){
    isShowValue = !isShowValue;
    if (!isShowValue){
        controlOfCascader('none')
        return
    }
    if (isShowValue){
        // 说明之前已经初始化了,只需要把相应面板的display设置为block即可
        if (selectedLabel?.length > 0 && document.querySelector('.cascader-value-box').childNodes?.length > 0){
            controlOfCascader('block');
            return
        }
        let parentDOM = document.querySelector('.cascader-value-box');
        initDataPanel(parentDOM, [null], data, 1);
    }
}

现在,这个组件的大致功能算是完毕了,如下:

3.6、支持默认值(最后一公里)

这个功能我们只需要通过代码手动调用click事件就可以了。initDataPanel函数源码修改如下:

function initDataPanel(parentDOM, fatherData, curLevelData = [], curLevel){
    let dataPanelDom = document.createElement('div');
    dataPanelDom.classList.add('cascader-value-panel');
    let hasSelectedDataItemDom = null;
    for (let index = 0; index < curLevelData.length; index++){
        let curLevelDataItem = curLevelData[index];
        let curLevelDataItemDom = document.createElement('div');
        let curDataItemText = document.createTextNode(curLevelDataItem.label);
        curLevelDataItemDom.classList.add('data-item');
        curLevelDataItemDom.appendChild(curDataItemText);
        dataPanelDom.appendChild(curLevelDataItemDom);
        if (curLevel > 1){
            dataPanelDom.style.left = `${(curLevel - 1) * 150}px`;
        }
        curLevelDataItemDom.onclick = function(event){
            // 阻止click事件冒泡
            event.stopPropagation();
            // 点击事件
            clickDataItem([...fatherData], curLevelDataItem, parentDOM, dataPanelDom, curLevelDataItemDom, curLevel);
        }
        
        // ++++++++++++++++++这个是新增的++++++++++++++++++++++++++++++
        if (defaultValue.split('/').includes(curLevelDataItem.label) && defaultValue?.length > 0){
            hasSelectedDataItemDom = curLevelDataItemDom;
        }
    }
    parentDOM.appendChild(dataPanelDom);
    isShowMaxLengthPanel = curLevel;
    
    // ++++++++++++++++++这是新增的+++++++++++++++++++++++++++++++++++
    if (defaultValue?.length > 0){
        hasSelectedDataItemDom?.onclick(new Event('click'));
    }
}

clickDataItem函数源码修改如下:

function clickDataItem(fatherData, curLevelDataItem, grandpaDOM, fatherDom, curLevelDataItemDom, curLevel){
    // ==================== 其余代码不变 ==============
    if (curLevelDataItem.children?.length > 0){
        initDataPanel(grandpaDOM, [...fatherData, curLevelDataItem], curLevelDataItem.children, curLevel + 1);
    } else {
        // +++++++++++++++++这个是新增的+++++++++++++++++++++++++++
        if (defaultValue?.length > 0){
            selectedLabel = defaultValue;
            isShowValue = true;
            defaultValue = ''
            if (document.querySelector('.value-box').childNodes[0]){
                document.querySelector('.value-box').replaceChild(document.createTextNode(selectedLabel), document.querySelector('.value-box').childNodes[0]);
            } else {
                document.querySelector('.value-box').appendChild(document.createTextNode(selectedLabel));
            }
            return
        }
        // 说明这是最后一层,此时应该返回数据
        let result = '';
        for (let index = 0; index < fatherData.length; index++){
            if (fatherData[index] !== null){
                result = result + fatherData[index].label + '/'
            }
        }
        result = result + curLevelDataItem.label;
        selectedLabel = result;
        isShowValue = false;
        if (document.querySelector('.value-box').childNodes[0]){
            document.querySelector('.value-box').replaceChild(document.createTextNode(selectedLabel), document.querySelector('.value-box').childNodes[0]);
        } else {
            document.querySelector('.value-box').appendChild(document.createTextNode(selectedLabel));
        }
        // 返回数据后,将所有的dom隐藏,方便快速恢复
        controlOfCascader('none');
    }
}

重新刷新页面,我们给defaultValue设置一个默认值'Vue/vue选项/props选项'。我们会发现,这个默认值&&默认展开的功能也可以了。

四、最后

好啦,本期内容到这里就结束啦,希望我讲的能够对你有帮助,那么我们下期再见啦,拜拜~~