最近整理项目中用到的组件时,开发了导航定位的组件。当我们页面模块过多时,通过导航可以快速的定位到指定模块,提高用户体验。该组件基于vue3开发,使用IntersectionObserver,scrollIntoView等API,大概效果如下:
功能简介
该组件主要实现以下两个功能:
- 点击右侧导航,导航关联的模块内容切换到视图中。
- 滑动左侧模块内容,模块关联的导航自动切换到选中态。
代码实现
点击右侧导航时,调用导航关联模块的scrollIntoView方法,将模块切换到视图中。当滑动模块内容时,自动显示导航选中态,有两种方案实现:
- 使用IntersectionObserver API监听模块,当模块从底部滑入到视图中时,设置模块关联的导航选中态。
- 使用常规的scroll事件监听,判断目标元素滚动距离,与该元素的offsetTop值进行比对,从而确定导航选中态。
方案一
使用IntersectionObserver API监听目标元素的可视状态。代码如下:
<template>
<div class="m-box-list">
<div ref="mainRef" class="main">
<!-- 主体内容插槽 -->
<slot></slot>
</div>
<div class="side">
<div class="nav">
<!-- 导航内容插槽 -->
<slot name="nav">
<tab v-model="currNav" :list="navList" @on-change="onNavChange" />
</slot>
</div>
</div>
</div>
</template>
<script>
import { defineComponent, ref, reactive, onMounted, nextTick } from "vue";
import { dom, tools } from "@htfed/utils";
import Tab from "../HtBusTab";
// 导航盒子,可以通过导航定位到指定盒子。
export default defineComponent({
name: "BoxList",
components: {
Tab,
},
props: {
// 配置数据
data: {
type: Object,
required: false,
default: () => ({}),
},
},
setup(props) {
const currNav = ref(0);
const navList = reactive([]);
const mainRef = ref(null);
let boxList = reactive([]);
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
currNav.value = Number(entry.target?.dataset?.index);
}
});
/**
* 获取盒子元素列表
*/
const onGetBoxList = () => {
boxList = Array.from(mainRef.value?.getElementsByClassName("ht-box"));
};
/**
* 设置导航数据
*/
const onSetNavList = () => {
boxList.forEach((i, index) => {
navList[index] = {
label: i.dataset?.title || `模块${index + 1}`,
value: index,
};
// 给盒子元素属性添加dataset值,为盒子的索引值
i.dataset.index = index;
// 观察该盒子元素
observer.observe(i);
});
};
/**
* 导航切换
*/
const onNavChange = (params) => {
boxList[Number(params.value)]?.scrollIntoView();
};
onMounted(() => {
nextTick(() => {
onGetBoxList();
onSetNavList();
});
});
return {
currNav,
navList,
mainRef,
boxList,
onGetBoxList,
onSetNavList,
onNavChange,
};
},
});
</script>
<style lang="less" scoped>
.m-box-list {
display: flex;
.main {
flex: 1;
}
.side {
width: 200px;
padding: 0 15px;
box-sizing: border-box;
.nav {
position: sticky;
top: 0;
right: 0;
}
}
}
</style>
IntersectionObserver 简介
官方文档:developer.mozilla.org/zh-CN/docs/…
Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。
当我们想实现懒加载,滚动加载,页面埋点等功能时可使用该API。
IntersectionObserver 用法
创建一个 IntersectionObserver 对象,并传入相应参数和回调用函数,该回调函数将会在目标 (target) 元素和根 (root) 元素的交集大小超过阈值 (threshold) 规定的大小时候被执行。阈值为 0 时表示目标元素退出 root 元素或出现在 root 元素中时,回调函数执行。阈值为 1.0 意味着目标元素完全出现在 root 选项指定的元素中可见时,回调函数将会被执行。阈值默认值为0。
// 实例化
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
}, {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
});
// 执行
observer.observe(Node);
IntersectionObserver options
- root 指定根 (root) 元素,用于检查目标的可见性。必须是目标元素的父级元素。如果未指定或者为null,则默认为浏览器视窗。
- rootMargin 根 (root) 元素的外边距。该属性值是用作 root 元素和 target 发生交集时候的计算交集的区域范围,使用该属性可以控制 root 元素每一边的收缩或者扩张。默认值为 0。
- threshold 可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 IntersectionObserver 注册的回调函数将会被执行。默认值是 0 (意味着只要有一个 target 像素出现在 root 元素中,回调函数将会被执行)。该值为 1.0 含义是当 target 完全出现在 root 元素中时候 回调才会被执行。
IntersectionObserver callback params
- entry.boundingClientRect 目标元素的矩形区域的信息。
- entry.intersectionRatio 目标元素的可见比例,即intersectionRect占boundingClientRect的比例。
- entry.intersectionRect 目标元素与视口(或根元素)的交叉区域的信息。
- entry.isIntersecting 目标元素是否可见。
- entry.rootBounds 根元素的矩形区域信息,即为getBoundingClientRect方法返回的值。
- entry.target 被观察的目标元素,是一个 DOM 节点对象。
- entry.time 当可视状态变化时,状态发送改变的时间戳。
方案二
监听scroll事件实现,代码如下:
<template>
<div class="m-box-list">
<div ref="mainRef" class="main">
<!-- 主体内容插槽 -->
<slot></slot>
</div>
<div class="side">
<div class="nav">
<!-- 导航内容插槽 -->
<slot name="nav">
<tab v-model="currNav" :list="navList" @on-change="onNavChange" />
</slot>
</div>
</div>
</div>
</template>
<script>
import {
defineComponent,
ref,
reactive,
onMounted,
onUnmounted,
nextTick,
} from "vue";
import { dom, tools } from "@htfed/utils";
import Tab from "../HtBusTab";
// 导航盒子,可以通过导航定位到指定盒子。
export default defineComponent({
name: "BoxList",
components: {
Tab,
},
props: {
// 配置数据
data: {
type: Object,
required: false,
default: () => ({}),
},
},
setup(props) {
const scrolling = ref(false);
const currNav = ref(0);
const navList = reactive([]);
const scrollContainer = props.data.scrollContainer || window;
const mainRef = ref(null);
let boxList = reactive([]);
/**
* 滚动事件
*/
const onScroll = () => {
if (scrolling.value) return;
const scrollTop = dom.getScrollTop(props.data.scrollContainer);
// 找出当前视图中的盒子,如果盒子内容是动态的,则需要实时获取offsetTop值
const index = boxList?.findLastIndex((i) => {
const offsetTop = props.data.isDynamic
? dom.getOffsetTop(i, props.data.scrollContainer)
: i.dataset.offsetTop;
return scrollTop + (props.data.offsetTop || 0) >= offsetTop;
});
if (index > -1 && index !== currNav.value) {
currNav.value = index;
}
};
/**
* 截流的滚动事件
*/
const onThrottleScroll = tools.throttle(onScroll, 500);
/**
* 获取盒子元素列表
*/
const onGetBoxList = () => {
boxList = Array.from(mainRef.value?.getElementsByClassName("ht-box"));
};
/**
* 设置导航数据
*/
const onSetNavList = () => {
boxList.forEach((i, index) => {
navList[index] = {
label: i.dataset?.title || `模块${index + 1}`,
value: index,
};
// 给盒子元素属性添加dataset值,为盒子的索引值
i.dataset.index = index;
if (!props.data.isDynamic)
i.dataset.offsetTop = dom.getOffsetTop(i, props.data.scrollContainer);
});
};
/**
* 导航切换
*/
const onNavChange = (params) => {
scrolling.value = true;
boxList[Number(params.value)]?.scrollIntoView();
setTimeout(() => {
scrolling.value = false;
}, 300);
};
onMounted(() => {
scrollContainer.addEventListener("scroll", onThrottleScroll);
nextTick(() => {
onGetBoxList();
onSetNavList();
});
});
onUnmounted(() => {
scrollContainer.removeEventListener("scroll", onThrottleScroll);
});
return {
currNav,
navList,
mainRef,
boxList,
onScroll,
onThrottleScroll,
onGetBoxList,
onSetNavList,
onNavChange,
};
},
});
</script>
<style lang="less" scoped>
.m-box-list {
display: flex;
.main {
flex: 1;
}
.side {
width: 200px;
padding: 0 15px;
box-sizing: border-box;
.nav {
position: sticky;
top: 0;
right: 0;
}
}
}
</style>
监听根元素的滚动事件,计算滚动距离是否大于目标元素的offsetTop值,来判断当前应该定位哪个导航。
方案差异
- 方案一使用IntersectionObserver监听,浏览器空余线程处理,性能上更占优势。
- 方案二可根据滚动距离灵活定制导航的显示逻辑。因频繁触发滚动事件,性能上劣势。
如果导航的定位切换是在目标元素进入到页面时定位,或者目标元素出现在屏幕固定的百分比时,可使用方案一。因为项目中用到的导航切换是上一个模块滚出时进行导航的定位,所以采用了方案二。
两个方案最大的区别是:
- 目标元素进入视图区时导航定位采用方案一。
- 目标元素退出视图区时导航定位采用方案二。
参考文档
- Intersection Observer API developer.mozilla.org/zh-CN/docs/…
- IntersectionObserver介绍 www.jianshu.com/p/7c06669ed…