Element UI中Cascader组件踩坑总结

1,419 阅读5分钟

最近开发中二次封装了Element UI的Cascader组件,遇到了不少坑,在这里记录一下

1.配置了懒加载,但是一级节点是常量数据,二级节点才是通过接口返回的
/**
 * 获取第一级节点,
 * @return {Promise} 获取节点的promise
 */
getFirstLevel() {
    return new Promise((resolve) => {
        //initNodes就是一级节点常量
        resolve(initNodes);
    });
},
​
/**
 * 处理节点懒加载,handleLazyLoad函数对应配置中的lazyLoad函数
 * @param {Object} node 当前节点
 * @param {Function} resolve 加载成功后的回调
 * @return {Function} 加载成功后的回调
 */
handleLazyLoad(node, resolve) {
    const { level } = node;
    // 一级节点为常量
    if (level === 0) {
        this.getFirstLevel().then((res) => {
            return resolve(res);
        });
    } else if (level === 1) {
        //这里为通过接口请求到的数据
    } 
}

上面可以看到常量节点的获取必须为异步获取,直接resolve是不行的,我们来看看Element UI在这一部分的源码

lazyLoad(node, onFullfiled) {
    const { config } = this;
​
    // 如果没有传入node,初始化根节点和store
    if (!node) {
        node = node || { root: true, level: 0 };
        this.store = new Store([], config);
        this.menus = [this.store.getNodes()];
    }
​
    // 设置当前节点为加载中状态
    node.loading = true;
​
    // 定义resolve函数,用于处理加载完成后的逻辑,我们定义的handleLazyLoad函数调用的resolve就是这个函数
    const resolve = dataList => {
        const parent = node.root ? null : node;
        // 如果有数据,将数据添加到store中
        dataList && dataList.length && this.store.appendNodes(dataList, parent);
        // 更新节点状态
        node.loading = false;
        node.loaded = true;
​
        //this.checkedValue是一个数组,用于表示选择的值的路径
        //类似下面的结构:[一级节点,二级节点]
        if (Array.isArray(this.checkedValue)) {
            //从第一项开始,依次对选择的值路径上每个节点进行处理
            const nodeValue = this.checkedValue[this.loadCount++];
            const valueKey = this.config.value;
            const leafKey = this.config.leaf;
​
            // 是否能从我们resolve回来的数据里面找到当前选中的值
            if (Array.isArray(dataList) && dataList.filter(item => item[valueKey] === nodeValue).length > 0) {
                //过滤出匹配的节点
                const checkedNode = this.store.getNodeByValue(nodeValue);
​
                // 如果选中的节点不是叶子节点,继续加载子节点
                if (!checkedNode.data[leafKey]) {
                    this.lazyLoad(checkedNode, () => {
                        this.handleExpand(checkedNode);
                    });
                }
​
                // 如果路径上所有值都已加载完成,计算要显示的文本
                if (this.loadCount === this.checkedValue.length) {
                    this.$parent.computePresentText();
                }
            }
        }
​
        // 调用完成回调
        onFullfiled && onFullfiled(dataList);
    };
​
    // 调用配置中的lazyLoad函数来获取数据
    config.lazyLoad(node, resolve);
}

通过断点调试可以发现,如果直接进行resolve,会导致在调用resolve函数的时候,this.checkedValue还没有被赋值给我们要求的值,导致在Array.isArray(this.checkedValue)这一步判断就会被直接跳过,那下面的this.$parent.computePresentText()就没有执行,自然就显示不出来值了。

2.外部给配置了懒加载的组件进行赋值后,有概率不会回显

我们还是看源码

watch: {
    options: {
        handler: function () {
            //options有变化才会进行初始化工作
            this.initStore();
        },
            //立即执行一次
            immediate: true,
                deep: true,
    }
}
initStore() {
    const { config, options } = this;
    if (config.lazy && isEmpty(options)) {
        //这里相当于懒加载函数通常只会在最开始的时候执行一次
        this.lazyLoad();
    } else {
        //其他处理
    }
},

从源码中可以看出,在我们不改变options的情况下,懒加载函数只会在最开始执行一次,这样导致如果我们赋的值如果需要接口等耗时任务才能获取,这里再给Cascader组件赋值,是不会再执行懒加载函数的。

第一种解决方案是改变key在拿到需要赋的值之后重新渲染组件,渲染两次即懒加载函数也会执行两次,这样会导致重复请求,需要配合store解决

第二种解决方案是利用v-if不会渲染的特性,在拿到值之后才进行渲染,这种方式对于我的项目不是很有效,因为项目中级联组件是一个全局组件,每次切换页面,组件都会消失一阵,用户体验会很差

上面两种方式都有一些缺陷,之前也试过用placeholder代替掉显示文字的区域,这样默认展开同样也需要自己实现,效果也不能满意,如果有其他方案也欢迎大家一同讨论。

3.实现点击文字所在行即可进行选择

Element UI中的任意一级单选目前是类似Radio组件圆点的形式,点击圆点才能选中对于用户不是很友好,我们需要去掉圆点,点击文字所在行即可选择对应项。

最开始是这样做的

<template>
    <el-cascader
        :props="props"
    ></el-cascader>
</template>
<script>
    export default {
        data() {
            return {
                props: {
                    // 是否可以直接选中父节点
                    checkStrictly: true,
                    // 配置展开方式
                    expandTrigger: 'hover',
                },
            };
        }
    };
</script>
<style lang="scss">
    // 扩大点击区域
    .el-cascader-panel .el-radio {
        width: 100%;
        height: 100%;
        z-index: 10;
        position: absolute;
        top: 10px;
        right: -10px;
    }
    // 隐藏单选框
    .el-cascader-panel .el-radio__input {
        visibility: hidden;
    }
    .el-cascader-panel .el-cascader-node__postfix {
        top: 10px;
    }
</style>

这样做相当于是把radio部分扩大了,但是这样做会覆盖掉一些组件原来的处理,还是看源码

renderNodeList(h) {
    //...其他节点处理
    //注意这里返回了hoverZone
    return [...nodes, isHoverMenu ? <svg ref='hoverZone' class='el-cascader-menu__hover-zone'></svg> : null];
},
handleMouseMove(e) {
// 获取当前激活的节点和悬停计时器
const { activeNode, hoverTimer } = this;
// 获取悬停区域的DOM元素
const { hoverZone } = this.$refs;
​
// 如果没有激活的节点或悬停区域不存在,则直接返回
if (!activeNode || !hoverZone) return;
​
// 如果鼠标当前在激活节点内
if (activeNode.contains(e.target)) {
    // 清除悬停计时器,防止悬停区域被清除
    clearTimeout(hoverTimer);
​
    // 获取组件元素的边界信息
    const { left } = this.$el.getBoundingClientRect();
    // 计算鼠标相对于组件元素的X坐标
    const startX = e.clientX - left;
    // 获取组件元素的宽度和高度
    const { offsetWidth, offsetHeight } = this.$el;
    // 获取激活节点的顶部和底部位置
    const top = activeNode.offsetTop;
    const bottom = top + activeNode.offsetHeight;
​
    // 更新悬停区域的SVG路径,绘制两条从鼠标位置到组件边缘的透明路径
    hoverZone.innerHTML = `
      <path style="pointer-events: auto;" fill="transparent" d="M${startX} ${top} L${offsetWidth} 0 V${top} Z" />
      <path style="pointer-events: auto;" fill="transparent" d="M${startX} ${bottom} L${offsetWidth} ${offsetHeight} V${bottom} Z" />
    `;
} else if (!hoverTimer) {
    // 如果悬停计时器不存在,则设置一个计时器,在指定时间后清除悬停区域
    this.hoverTimer = setTimeout(this.clearHoverZone, this.panel.config.hoverThreshold);
}
}

组件内部为了避免hover触发展开时,鼠标如果想从一级菜单移入二级菜单,途中会经过部分一级菜单,这样经过的一级菜单也有可能被hover展开,所以用svg绘制了临时的一个三角形悬停区域用来遮住鼠标移动的路径。

如果按照我们上面的写法,扩大的radio反而遮住了这个悬停区域,导致原有的功能无法触发,所以换了下面这种写法

<template>
<el-cascader>
    <div
         slot-scope="{ data }"
         @click="clickNode"
         class="span-click"
         >
        {{ data.label }}
    </div>
</el-cascader>
</template>
<script>
​
    export default {
        data() {
            return {
                props: {
                    // 是否可以直接选中父节点
                    checkStrictly: true,
                    // 配置展开方式
                    expandTrigger: 'hover',
                },
            };
        },
        methods: {
        /**
         * 通过点击文字选中的处理函数
         * @param {Object} e 事件对象
         */
            clickNode(e) {
                // 模拟点击对应的radio
                e.target.parentElement.parentElement.firstElementChild.click();
            },
        }
    };
</script><style lang="scss">
    .span-click {
        width: 100%;
    }
    // 隐藏单选框
    .el-cascader-panel .el-radio__input {
        display: none;
    }
    .el-cascader-panel .el-cascader-node__postfix {
        top: 10px;
    }
</style>

这种方法利用data区域的插槽,模拟进行radio的点击,这样就能避免覆盖最外层的遮罩了