目录
1. 需求介绍
2. 实现过程
2.1 表单结构介绍
2.2 确定锚点组件接收的参数及使用方法
2.2.1 form-dom:需要被锚点组件控制的表单实例
2.2.2 active-anchor:默认激活的锚点
2.2.3 title-class:表单标题特有的类名
2.2.4 将 锚点组件 挂载到 body 上
2.2.5 锚点组件使用示例
2.3 实现锚点组件基本结构
2.4 锚点组件 onMounted() 时,要执行的操作
2.4.1 从表单实例中,获取锚点列表 getAnchorList()
2.4.2 激活默认锚点,滚动到指定位置
2.4.3 添加滚动事件监听
2.4.4 给滚动事件添加防抖
2.5 滚动事件实现逻辑
2.5.1 阻止事件向上传播
2.5.2 根据表单已经滚动的高度,判断激活哪个锚点
2.6 添加锚点项点击事件
2.7 实现返回顶部按钮功能
2.8 最终代码
如图所示,锚点组件实现了以下功能:
此项目表单需要每个模块可以折叠,所以采用 ElementPlus 中的折叠面板,如下所示:
{{ TaskViewCollapseNameEnum.taskReviewComments }}
为了让表单页面中的逻辑尽量精简,只关心表单业务本身;与业务无关的逻辑(关于表单滚动监听的事件),都考虑在锚点组件中实现,因此锚点组件需要接收表单组件实例;
有些表单,要求一进来就定位到指定的模块,激活指定的锚点
用于判断元素的 offsetTop,此处使用 .details-container__submenu 作为标题类名,可以自己定义;简单来说,我需要获取每个标题距离可视区域顶部的范围,通过类名,获取表单标题 DOM实例,进而获取 DOM 实例的 scrollTop 属性实现
综上所述,最终接收的 props 长这个样子:
props: {// 使用锚点的表单实例formDom: {type: Object,default: () => ({}),required: true,},// 默认激活哪个锚点activeAnchor: {type: Number,default: 0,},// 章节特有的类名titleClass: {type: String,default: '.details-container__submenu',},
},
锚点组件涉及到了定位,如果直接挂载到元素内部,会被父元素的 position 影响到,而导致定位位置不可控因素变多,因此使用 teleport 将他挂载到 body 上,确保位置固定
由于锚点列表依据于表单数据,因此需要在表单实例加载完成后,才能渲染锚点组件
如下所示,除了需要展示锚点列表,还需要展示 返回顶部 的按钮
{{ node.label }} 返回顶部
先定义三个变量:
响应式变量如下所示:
// 响应式变量
const state = reactive({// 锚点列表anchorList: [] as any[],// 当前激活的锚点索引currentAnchor: 0,// 表单实例中,章节 DOM 列表(锚点列表的内容就是通过这个变量填充的)titleListInForm: [] as any[],
});
接下来要执行这些操作:
/*** 从表单实例中,获取章节列表,并填充锚点列表*/
const getAnchorList = () => {// 清空锚点列表state.anchorList = [];// 获取表单实例中的章节 DOM 列表state.titleListInForm = Array.from(props.formDom.querySelectorAll(props.titleClass));// console.log('获取表单实例中的章节 DOM 列表 titleListInForm ===', titleListInForm);// 遍历章节 DOM 列表,填充锚点列表state.titleListInForm.forEach((item: any, index) => {// console.log('当前遍历的 章节 DOM item ===', item);state.anchorList.push({index, // 章节索引label: item.innerHTML || '--', // 章节内容top: item.offsetTop,titleDOM: item, // 章节完整 DOM 信息});});// console.log('填充锚点列表 state.anchorList ===', state.anchorList);
};
实现思路:
注意:此处应该使用定时器,否则会导致滚动不生效
// 如果默认激活的锚点,不是第一个,则要先进行一次滚动
if (props.activeAnchor !== 0) {state.currentAnchor = props.activeAnchor;// 即将滚动到的目标章节 DOMlet showTitleDomStart: any;state.anchorList.forEach((item: any) => {const indexTemp = item.index;if (props.activeAnchor === indexTemp) {showTitleDomStart = item.titleDOM;console.log('默认滚动到的 章节DOM', item.titleDOM);}});// 如果找到了符合条件的章节 DOMif (showTitleDomStart) {setTimeout(() => {// 平滑滚动showTitleDomStart.scrollIntoView({behavior: 'smooth',block: 'start',});}, 500);}
}
这里需要注意:props 传进来的 表单 DOM 实例,可以直接使用,不要添加 .value
挂载时,需要添加滚动事件监听,卸载时,要记得取消滚动事件监听
onMounted(() => {// 给表单添加滚动监听props.formDom.addEventListener('scroll', handleDebounceScroll);
});onUnmounted(() => {// 移除表单滚动监听props.formDom.removeEventListener('scroll', handleDebounceScroll);
});
只要页面发生变化,就会触发滚动事件;因此,一定要添加防抖事件,避免影响性能
/*** 防抖 在事件被触发一定时间后再执行回调,如果在这段事件内又被触发,则重新计时* 使用场景:* 1、搜索框中,用户在不断输入值时,用防抖来节约请求资源* 2、点击按钮时,用户误点击多次,用防抖来让其只触发一次* 3、window 触发 resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次* @param fn 回调* @param duration 时间间隔的阈值(单位:ms) 默认1000ms*/
export function useDebounce unknown> (fn: F, duration = 1000):
() => void {let timeoutId: ReturnType | undefined;const debounce = (...args: Parameters) => {if (timeoutId) {clearTimeout(timeoutId);}timeoutId = setTimeout(() => {fn(...args);timeoutId = undefined;}, duration);};return debounce;
}/*** 对滚动事件进行防抖处理,节约性能*/
const handleDebounceScroll = useDebounce(handleScroll, 200);
/*** 处理滚动事件*/
const handleScroll = (e: any) => {// console.log('处理滚动事件', e);e.stopPropagation();// 根据表单已经滚动的高度,判断激活哪个锚点activeFixedAnchor();
};
遍历锚点列表,如果符合以下条件,则修改激活的锚点项
注意:由第二条可知,我们要对比下一个节点和当前节点的 offsetTop,所以最后一个节点不可以用上述方法判断是否激活
如何判断最后一个节点呢?
如果当前表单滚动的高度 大于 最后一个标题节点的 offsetTop,则直接激活
注意:这个判断方法存在 bug,如果最后的表单内容没有那么厂,就永远不会激活最后一个节点,但是目前没找到好的解决方案
/*** 根据表单已经滚动的高度,判断激活哪个锚点*/
const activeFixedAnchor = () => {// 这里需要注意一个问题,表单实例的 scrollTop 是相对于编辑页面头部的下方开始的,而标题的 offsetTop 是相对于 微应用容器 计算的,因此要加上 65const formScrollTop = props.formDom.scrollTop + 65; // 表单的 scrollTop,默认为 0for (let k = 0; k < state.anchorList.length; k++) {if (// 如果 scrollTop 正好和标题节点的 offsetTop 相等formScrollTop === state.anchorList[k].top// 由于需要和下一个标题节点作比较,所以当前标题节点不能是最后一个|| (k < state.anchorList.length - 1// scrollTop 介于当前判断的标题节点和下一个标题节点之间&& formScrollTop > state.anchorList[k].top&& formScrollTop < state.anchorList[k + 1].top)) {// console.log('表单的 scrollTop,激活标题的 offsetTop,激活id ===', formScrollTop, state.anchorList[k].top, k);state.currentAnchor = k;break;// 如果是最后一个标题节点,只要 scrollTop 大于节点的 offsetTop 即可} else if (k === state.anchorList.length - 1) {if (formScrollTop > state.anchorList[k - 1].top) {state.currentAnchor = k;break;}}}
};
参考 2.4.2 逻辑,基本一致
/*** 点击锚点列表项*/
const handleAnchorClick = (anchorInfo: any) => {// console.log('当前点击的锚点列表项 ===', anchorInfo);// 修改当前选中的锚点state.currentAnchor = anchorInfo.index;// 即将滚动到的目标章节 DOMlet showTitleDom: any;state.titleListInForm.forEach((item: any, index) => {const labelTemp = item.innerHTML;if (anchorInfo.label === labelTemp) {showTitleDom = item;}});// 如果找到了符合条件的章节 DOMif (showTitleDom) {// 平滑滚动showTitleDom.scrollIntoView({behavior: 'smooth',block: 'start',});}
};
修改表单的 scrollTop 即可
/*** 返回顶部*/
const handleReturnTop = () => {// eslint-disable-next-line no-param-reassign, vue/no-mutating-propsprops.formDom.scrollTop = 0;
};
{{ node.label }} 返回顶部