目录
2.4.1 从表单实例中,获取锚点列表 getAnchorList()
1. 需求介绍
如图所示,锚点组件实现了以下功能:
- 锚点组件显示表单所有的标题
- 锚点组件存在 “返回顶部” 钮
- 当表单滚动时,锚点组件对应目录自动高亮
- 点击锚点组件列表项时,表单滚顶到指定章节处
- 当前激活的锚点索引应该高亮
2. 实现过程
2.1 表单结构介绍
此项目表单需要每个模块可以折叠,所以采用 ElementPlus 中的折叠面板,如下所示:
- 使用 div 包裹所有表单内容,并定义 ref="useAnchorFormRef" 用于获取表单实例
- 使用 el-collapse-item 包裹每一项表单内容
- 使用 .details-container__submenu 包裹标题,该类名后面会作为锚点内部寻找标题的依据
<div ref="useAnchorFormRef" class="details-container--scroll">
<el-collapse v-model="activeNames" @change="handleCollapseChange">
<!-- 任务审核意见 -->
<el-collapse-item
v-if="type === TaskViewPageTypeEnum.check"
:title="TaskViewCollapseNameEnum.taskReviewComments"
:name="TaskViewCollapseNameEnum.taskReviewComments"
>
<!-- 标题 -->
<template #title>
<div class="details-container__submenu">
{{ TaskViewCollapseNameEnum.taskReviewComments }}
</div>
</template>
<!-- 多行文本 -->
<el-input v-model="reviewComments" type="textarea" :disabled="true" :rows="4"></el-input>
</el-collapse-item>
</el-collapse>
</div>
2.2 确定锚点组件接收的参数及使用方法
2.2.1 form-dom:需要被锚点组件控制的表单实例
为了让表单页面中的逻辑尽量精简,只关心表单业务本身;与业务无关的逻辑(关于表单滚动监听的事件),都考虑在锚点组件中实现,因此锚点组件需要接收表单组件实例;
2.2.2 active-anchor:默认激活的锚点
有些表单,要求一进来就定位到指定的模块,激活指定的锚点
2.2.3 title-class:表单标题特有的类名
用于判断元素的 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',
},
},
2.2.4 将 锚点组件 挂载到 body 上
锚点组件涉及到了定位,如果直接挂载到元素内部,会被父元素的 position 影响到,而导致定位位置不可控因素变多,因此使用 teleport 将他挂载到 body 上,确保位置固定
由于锚点列表依据于表单数据,因此需要在表单实例加载完成后,才能渲染锚点组件
2.2.5 锚点组件使用示例
<!-- 锚点组件 -->
<teleport :disabled="false" to="body">
<!-- form-dom:需要被锚点组件控制的表单实例 -->
<!-- active-anchor:默认激活的锚点,设置此项后,进入表单会自动定位锚点,并滚动到相应位置 -->
<!-- v-if="useAnchorFormRef" 此判断必须存在,防止传入 表单实例DOM 为空的问题 -->
<anchor-point v-if="useAnchorFormRef" :form-dom="useAnchorFormRef" :active-anchor="0"></anchor-point>
</teleport>
2.3 实现锚点组件基本结构
如下所示,除了需要展示锚点列表,还需要展示 返回顶部 的按钮
<div class="anchor__container">
<template v-if="anchorList.length">
<div
v-for="node in anchorList"
:key="node.index"
class="anchor__item"
:label="node.label"
:class="{ active: currentAnchor === node.index }"
@click="handleAnchorClick(node)"
>
{{ node.label }}
</div>
</template>
<div class="anchor__return-top" @click="handleReturnTop">
返回顶部
</div>
</div>
2.4 锚点组件 onMounted() 时,要执行的操作
2.4.1 从表单实例中,获取锚点列表 getAnchorList()
先定义三个变量:
- 锚点列表
- 当前激活的锚点索引
- 表单实例中,标题的 DOM 实例列表
响应式变量如下所示:
// 响应式变量
const state = reactive({
// 锚点列表
anchorList: [] as any[],
// 当前激活的锚点索引
currentAnchor: 0,
// 表单实例中,章节 DOM 列表(锚点列表的内容就是通过这个变量填充的)
titleListInForm: [] as any[],
});
接下来要执行这些操作:
- 清空锚点列表
- 根据 props.title-class 以及 querySelectorAll() 获取全部表单标题 DOM 实例,为了让 DOM 实例列表变成数组,使用 Array.form 处理他们
- 遍历标题 DOM 列表,填充锚点列表;需要注意:要实现点击锚点,滚动到表单指定区域,就要在每一项锚点数据中,填充上当前锚点需要让表单滚动多大距离,也就是此处的 top;
/**
* 从表单实例中,获取章节列表,并填充锚点列表
*/
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);
};
2.4.2 激活默认锚点,滚动到指定位置
实现思路:
- 如果不是默认激活第一项,则要手动激活锚点项,并滚动到指定位置
- 遍历锚点列表,寻找和当前表单所处位置(第几个)一致的锚点索引,将该锚点对应的标题组件存到临时变量中
- 如果找到了对应的标题 DOM,则使用 el.scrollIntoView() 方法,平滑的滚动到对应位置
注意:此处应该使用定时器,否则会导致滚动不生效
// 如果默认激活的锚点,不是第一个,则要先进行一次滚动
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);
}
}
2.4.3 添加滚动事件监听
这里需要注意:props 传进来的 表单 DOM 实例,可以直接使用,不要添加 .value
挂载时,需要添加滚动事件监听,卸载时,要记得取消滚动事件监听
onMounted(() => {
// 给表单添加滚动监听
props.formDom.addEventListener('scroll', handleDebounceScroll);
});
onUnmounted(() => {
// 移除表单滚动监听
props.formDom.removeEventListener('scroll', handleDebounceScroll);
});
2.4.4 给滚动事件添加防抖
只要页面发生变化,就会触发滚动事件;因此,一定要添加防抖事件,避免影响性能
/**
* 防抖 在事件被触发一定时间后再执行回调,如果在这段事件内又被触发,则重新计时
* 使用场景:
* 1、搜索框中,用户在不断输入值时,用防抖来节约请求资源
* 2、点击按钮时,用户误点击多次,用防抖来让其只触发一次
* 3、window 触发 resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次
* @param fn 回调
* @param duration 时间间隔的阈值(单位:ms) 默认1000ms
*/
export function useDebounce<F extends(...args: unknown[]) => unknown> (fn: F, duration = 1000):
() => void {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debounce = (...args: Parameters<F>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = undefined;
}, duration);
};
return debounce;
}
/**
* 对滚动事件进行防抖处理,节约性能
*/
const handleDebounceScroll = useDebounce(handleScroll, 200);
2.5 滚动事件实现逻辑
2.5.1 阻止事件向上传播
/**
* 处理滚动事件
*/
const handleScroll = (e: any) => {
// console.log('处理滚动事件', e);
e.stopPropagation();
// 根据表单已经滚动的高度,判断激活哪个锚点
activeFixedAnchor();
};
2.5.2 根据表单已经滚动的高度,判断激活哪个锚点
遍历锚点列表,如果符合以下条件,则修改激活的锚点项
- 如果 表单滚动的高度 等于 表单标题的 offsetTop
- 如果 表单滚动的高度 介于 当前标题节点的 offsetTop 和 下一个标题节点的 offsetTop 之间(也就是当前标题看不到了,但下一个标题还没滚动到头部)
注意:由第二条可知,我们要对比下一个节点和当前节点的 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.6 添加锚点项点击事件
参考 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',
});
}
};
2.7 实现返回顶部按钮功能
修改表单的 scrollTop 即可
/**
* 返回顶部
*/
const handleReturnTop = () => {
// eslint-disable-next-line no-param-reassign, vue/no-mutating-props
props.formDom.scrollTop = 0;
};
2.8 最终代码
<template>
<div class="anchor__container">
<template v-if="anchorList.length">
<div
v-for="node in anchorList"
:key="node.index"
class="anchor__item"
:label="node.label"
:class="{ active: currentAnchor === node.index }"
@click="handleAnchorClick(node)"
>
{{ node.label }}
</div>
</template>
<div class="anchor__return-top" @click="handleReturnTop">
返回顶部
</div>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, defineComponent, onMounted, onUnmounted } from 'vue';
// hooks 防抖
import { useDebounce } from '../hooks/common/use-debounce';
export default defineComponent({
name: 'AnchorPoint',
props: {
// 使用锚点的表单实例
formDom: {
type: Object,
default: () => ({}),
required: true,
},
// 默认激活哪个锚点(项目中有要求一进入某个表单时,就定位到指定锚点的需求,默认激活第一个节点)
activeAnchor: {
type: Number,
default: 0,
},
// 章节特有的类名(用于判断元素的 offsetTop,5.0 里使用 .details-container__submenu 作为章节类名,也可以自己定义)
titleClass: {
type: String,
default: '.details-container__submenu',
},
},
setup(props, { emit }) {
// 响应式变量
const state = reactive({
// 锚点列表
anchorList: [] as any[],
// 当前激活的锚点索引
currentAnchor: 0,
// 表单实例中,章节 DOM 列表(锚点列表的内容就是通过这个变量填充的)
titleListInForm: [] as any[],
});
/**
* 返回顶部
*/
const handleReturnTop = () => {
// eslint-disable-next-line no-param-reassign, vue/no-mutating-props
props.formDom.scrollTop = 0;
};
/**
* 从表单实例中,获取章节列表,并填充锚点列表
*/
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);
};
/**
* 点击锚点列表项
*/
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',
});
}
};
/**
* 根据表单已经滚动的高度,判断激活哪个锚点
*/
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;
}
}
}
};
/**
* 处理滚动事件
*/
const handleScroll = (e: any) => {
// console.log('处理滚动事件', e);
e.stopPropagation();
// 根据表单已经滚动的高度,判断激活哪个锚点
activeFixedAnchor();
};
/**
* 对滚动事件进行防抖处理,节约性能
*/
const handleDebounceScroll = useDebounce(handleScroll, 200);
onMounted(() => {
// console.log('锚点组件内,获取滚动表单实例 ===', props.formDom);
// 从表单实例中,获取章节列表,并填充锚点列表
getAnchorList();
// 如果默认激活的锚点,不是第一个,则要先进行一次滚动
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.formDom.addEventListener('scroll', handleDebounceScroll);
});
onUnmounted(() => {
// 移除表单滚动监听
props.formDom.removeEventListener('scroll', handleDebounceScroll);
});
return {
...toRefs(state),
handleReturnTop,
handleAnchorClick,
};
},
});
</script>
<style lang="scss" scoped>
.anchor__container {
position: fixed;
top: 50%;
right: 46px;
overflow: auto;
box-sizing: border-box;
width: 300px;
height: 180px;
padding: 12px;
background: rgba(255, 0, 0, 0.4);
transform: translate(0, -50%);
}
.anchor__item {
overflow: hidden;
box-sizing: border-box;
width: 100%;
margin: 4px 0;
padding: 4px;
background: rgba(255, 255, 0, 0.2);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.anchor__return-top {
position: absolute;
bottom: 0;
padding: 4px 0;
background: rgba(0, 0, 255, 0.2);
color: blue;
cursor: pointer;
}
.active {
color: yellow;
}
</style>