Vue3 项目中实现“电梯”效果,快速实现锚点导航功能

Vue3 项目中实现“电梯”效果,快速实现锚点导航功能

2024-07-30
JavaScript前端vue3
AI 智能概括

本文记录了 Vue3 项目中实现锚点导航的方案。普通页面可以直接使用 CSS 的 scroll-behavior,但在 Hash 路由项目里,浏览器默认锚点行为容易和路由冲突,因此改用 JavaScript 控制滚动。点击导航时用 scrollIntoView 定位到对应内容区,内容区进入视口时用 IntersectionObserver 更新左侧选中状态。文章给出 LineTime 组件代码,并说明右侧内容需要提供稳定的唯一 id

Powered by AI

项目里有一个常见需求:左侧是时间线或目录,右侧是具体内容。点击左侧某一项,右侧滚动到对应区域;用户手动滚动右侧内容时,左侧也要同步高亮当前区域。

这种交互有点像商场里的“电梯导航”,本质上就是锚点定位和滚动监听的双向联动。

最终效果大概是这样:

GIF

什么时候可以只用 CSS

如果项目没有使用 Hash 路由,最简单的方案其实是原生锚点配合 CSS:

CSS
html {
  scroll-behavior: smooth;
}

相关文档可以看 scroll-behavior

但我这个项目使用的是 Vue Hash 路由。URL 里的 # 本身已经被路由使用了,再用原生锚点容易和路由行为混在一起,所以这里没有走纯 CSS 方案,而是用 JavaScript 主动控制滚动。

需要实现的两个能力

这个组件主要做两件事:

  • 点击左侧导航,右侧内容平滑滚动到指定位置。
  • 右侧内容滚动到视口中时,左侧导航自动更新选中状态。

对应到技术实现:

scrollIntoView 负责“我点了哪个,就滚到哪个”:

scrollIntoView

IntersectionObserver 负责“现在滚到哪个,就高亮哪个”。

组件设计

我把左侧时间线封装成了一个 LineTime 组件。右侧内容由业务数据渲染,每一块内容都需要有一个唯一 id,左侧数据里保存对应的 elId

整体关系是:

  • 左侧点击某一项时,根据 elId 找到右侧 DOM。
  • 调用 scrollIntoView({ behavior: "smooth" }) 平滑滚动。
  • 页面加载后,把右侧每个内容块交给 IntersectionObserver 监听。
  • 当某个内容块进入视口时,更新 activeIndex,并通知父组件。

项目里的组件封装如下:

组件封装

LineTime 组件代码

HTML
<!--
 @Author: Laucher
 @Date: 2024/7/29
 @Description: 時間綫組件
-->
<template>
  <n-timeline :icon-size="20">
    <n-timeline-item
      v-for="(item, index) in behaviorRecordList"
      :key="item.id"
      :style="{
                    '--n-line-color': getColorByLevel(index),
                    '--n-line-color-thin': darkenColor(getColorByLevel(index),100),
                    '--n-line-color-op': hexToRgba(getColorByLevel(index),0.2)
                  }"
    >
      <template #icon>
        <div
          class="circle border-[3px]"
          :style="`background: var(--n-line-color); border: 3px solid var(--n-line-color-thin)`"
        ></div>
      </template>
      <template #default>
        <div
          class="px-[8px] py-[8px] -mt-[10px] rounded cursor-pointer "
          :class="{
                          'circle-active': activeIndex == index + 1,
                         }"
          v-for="(v) in item.detail_json"
          :key="v.id"
          @click="changeHandle(index)"
        >
          <div
            v-if=" v.id === 'FeedFecalCollectionTour' && v.options?.partOptions.time"
            class="text-[#333] text-[14px] 2xl:text-[16px] mb-[10px]"
          >
            {{ v.options?.partOptions.time }}
          </div>
          <div v-else class="text-[#333] text-[14px] 2xl:text-[16px] mb-[10px]">
            {{ v.options?.partOptions.times[0] }} - {{
            v.options?.partOptions.times[1] }}
          </div>
          <div class="text-[#666666]">
            <span class="text-[#666666]">
              {{ v.options?.partOptions.title.label }}:
            </span>
            <span :style="`color: var(--n-line-color)`">
              {{ v.options?.partOptions.title.value }}
            </span>
          </div>
        </div>
      </template>
    </n-timeline-item>
  </n-timeline>
</template>
 
<script setup lang="ts">
  import { useColorByLevel } from "@/hooks/useColors";
  import { onMounted, ref, toRefs } from "vue";
 
  const { getColorByLevel, darkenColor, hexToRgba } = useColorByLevel();
 
  const activeIndex = ref(1);
 
  const props = withDefaults(
    defineProps<{
      behaviorRecordList: any;
    }>(),
    {
      behaviorRecordList: [],
    }
  );
 
  const { behaviorRecordList } = toRefs(props);
 
  const emit = defineEmits(["activeIndex"]);
 
  function changeHandle(index) {
    activeIndex.value = index + 1;
    // 让对应的元素滚动到视图区
    document
      .getElementById(behaviorRecordList.value[index].elId)
      ?.scrollIntoView({
        behavior: "smooth",
      });
    emit("activeIndex", activeIndex.value);
  }
 
  function changeActive(els) {
    // 我们不需要做任何事情。
    if (els[0].intersectionRatio <= 0) return;
    if (els[0].isIntersecting > 0) {
      let index = behaviorRecordList.value.findIndex(
        (item) => item.elId == els[0].target.id
      );
      if (index != -1) {
        activeIndex.value = index + 1;
        emit("activeIndex", activeIndex.value);
      }
    }
  }
 
  onMounted(() => {
    let observer = new IntersectionObserver(changeActive, {
      root: null,
      threshold: 1,
    });
 
    behaviorRecordList.value.map((item) => {
      let el = document.getElementById(item.elId);
      el && observer.observe(el);
    });
  });
</script>
 
<style scoped lang="less">
  .circle {
    width: 18px;
    height: 18px;
    border-radius: 50%; /* 设置为圆形 */
  }
 
  .circle-active {
    position: relative;
    background-color: var(--n-line-color-op);
 
    &::before {
      content: "";
      position: absolute;
      width: 0;
      height: 0;
      border-top: 8px solid transparent;
      border-bottom: 8px solid transparent;
      border-right: 8px solid var(--n-line-color-op); /* 三角形的颜色 */
      left: -8px;
      top: 10px;
    }
  }
</style>

右侧内容需要稳定 id

这个方案能不能稳定工作,关键在右侧内容区。

因为左侧点击时会通过 document.getElementById(item.elId) 找到对应 DOM,所以右侧每一块内容都必须设置一个稳定、唯一的 id。这个 id 要和左侧数据里的 elId 对得上。

右侧内容 id

如果右侧内容是接口返回后动态渲染的,建议在整理数据时就把 elId 一起生成好,避免模板里临时拼接导致两边不一致。

注意点

这个实现已经能满足大多数“电梯导航”场景,不过实际项目里还有几个细节可以按需调整:

  • 如果页面顶部有固定导航栏,可以给右侧内容块加 scroll-margin-top,避免滚动后被遮住。
  • IntersectionObserverthreshold 会影响高亮触发时机,内容块高度差异很大时可以适当调低。
  • 如果组件卸载频繁,建议在 onBeforeUnmount 里断开 observer,避免不必要的监听残留。

整体思路并不复杂:点击时主动滚动,滚动时反向更新导航。把这两件事分清楚,锚点导航就很好维护。

发布于 2024-07-30