目录
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 中的折叠面板,如下所示:
为了让表单页面中的逻辑尽量精简,只关心表单业务本身;与业务无关的逻辑(关于表单滚动监听的事件),都考虑在锚点组件中实现,因此锚点组件需要接收表单组件实例;
有些表单,要求一进来就定位到指定的模块,激活指定的锚点
用于判断元素的 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;
// 即将滚动到的目标章节 DOM
let showTitleDomStart: any;
state.anchorList.forEach((item: any) => {
const indexTemp = item.index;
if (props.activeAnchor === indexTemp) {
showTitleDomStart = item.titleDOM;
console.log('默认滚动到的 章节DOM', item.titleDOM);
}
});
// 如果找到了符合条件的章节 DOM
if (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 是相对于 微应用容器 计算的,因此要加上 65
const formScrollTop = props.formDom.scrollTop + 65; // 表单的 scrollTop,默认为 0
for (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;
// 即将滚动到的目标章节 DOM
let showTitleDom: any;
state.titleListInForm.forEach((item: any, index) => {
const labelTemp = item.innerHTML;
if (anchorInfo.label === labelTemp) {
showTitleDom = item;
}
});
// 如果找到了符合条件的章节 DOM
if (showTitleDom) {
// 平滑滚动
showTitleDom.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
};
修改表单的 scrollTop 即可
/**
* 返回顶部
*/
const handleReturnTop = () => {
// eslint-disable-next-line no-param-reassign, vue/no-mutating-props
props.formDom.scrollTop = 0;
};
{{ node.label }}
返回顶部