最近开发中二次封装了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的点击,这样就能避免覆盖最外层的遮罩了