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

一、组件分析
一个最基本的级联组件,无论是单选还是多选,它都应该包括上面这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>
解释如下:
更具体的dom结构如下:
| - Cascader总容器(class:cascader-component-box)
| - 用于显示value的容器(class:value-box)
| - 数据面板的总容器(class:cascader-value-box)
| - 数据面板1(class:cascader-value-panel)
| - 数据小项1-1(class: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结构,上面的这些样式一眼就能看明白。
此时的组件如下:
平平无奇的大白板而已。
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”的函数,这个函数的主要功能如下:
首先,“支持展开多级菜单”这个功能倒是没啥,再调用一次 initDataPanel呗。
其次,什么是“支持菜单之间的切换”?
就像上图表达的一样,菜单之间的切换分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选项'。我们会发现,这个默认值&&默认展开的功能也可以了。
四、最后
好啦,本期内容到这里就结束啦,希望我讲的能够对你有帮助,那么我们下期再见啦,拜拜~~