Bootstrap

使用 Vue3 实现锚点组件

目录

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 最终代码


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>

;