最近做需求,遇到了个锚点定位的需求,实现起来也很简单,在这里记录一下。不想了解实现逻辑的掘友可以直接翻到最底部,查看全部代码,复制后可以直接使用。
最终实现效果:
实现思路
结合vue2中$slots
的特点,以及getBoundingClientRect
方法获取元素高度,计算滚动距离scrollTop
来实现锚点定位。
整体结构
首先将组件的整体框架和使用方式整理出来,然后再一步一步地实现具体功能。
1. 组件的文件路径
2. dom模版及部分JS代码
- anchor/index.vue
// anchor/index.vue
<template>
<div class="anchor-detail">
<div class="anchor-key">
<span
class="key"
v-for="item in list"
:key="item.id"
:class="{ active: Number(current) === item.id }"
@click="changeAnchor(item)"
>{{ item.name }}</span>
</div>
<div class="anchor-content" ref="anchorContent" @scroll="onScroll">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'anchor-index',
props: {
list: {
type: Array,
default: () => []
}
},
data() {
return {
current: 1,
};
},
mounted() {
this.$nextTick(() => {
this.slotChilds = this._self.$slots.default;
});
},
methods: {
changeAnchor(item) {},
onScroll(e) {},
}
};
</script>
参数: anchor-index
组件接收一个list
参数,数组中的每一项是一个对象。
slot数量: 在组件初始化时,可以用vue提供的$slots
,来获取slot
的个数,因此,需要在mounted
钩子函数中获取当前组件的$slots
并存储起来。$slots
的具体内容在控制台打印结果如下:
事件: methods
方法中分别定义:点击事件changeAnchor
和滚动事件onScroll
注意:
dom元素上的
id
我这里定义的是数字,所以在dom上判断高亮的代码用的Number(current) === item.id
来做判断。
- anchor/anchor-item.vue
// anchor/anchor-item.vue
<template>
<div class="anchor-item">
<div class="label">{{ label }}</div>
<div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'anchor-item',
props: {
label: {
type: String,
default: ''
}
},
data() {
return {};
}
};
</script>
anchor-item
组件的内部逻辑最简单,只接收一个参数label
,用来设置组件的标题,组件内容用slot
标签来代替。
3. 使用方式
关于使用方式,这里就不贴代码了,直接放个截图吧,简单直接:
截图中的anchorList
代码如下:
anchorList: [
{ id: 1, name: '账户信息' },
{ id: 2, name: '基础信息' },
{ id: 3, name: '商品信息' },
{ id: 4, name: '费用信息' },
{ id: 5, name: '进度信息' }
]
整体框架搭建出来后,开始实现具体的功能代码。
实现锚点定位代码逻辑
1. 实现点击定位
实现思路:
-
在
mounted
中获取到的slot
数组,即slotChilds
。点击事件会将对应id
传递过来,根据id
配合slotChilds
可以得到需要遍历的数组。 -
slotChilds
中的每一项里有个elm
属性,它是真实的dom元素,再通过getBoundingClientRect
方法可以得到具体dom元素的高度。 -
将每一项dom元素高度相加得到的和就是要滚动的
scrollTop
。
为了方便理解, 举个例子: 如果点击项id是3,那么只需要将小于3的前几项的dom元素高度累加起来就是scrollTop
的值。
对于获取元素的高度,可选择的方式有很多种,我在这里用了getBoundingClientRect
。想要了解更多关于getBoundingClientRect
内容的掘友,请移步# Element.getBoundingClientRect()
点击事件changeAnchor
代码逻辑如下:
changeAnchor(item) {
// 赋值id,用来设置样式高亮
this.current = item.id;
// 向外暴露选中的id
this.$emit('changeAnchor', item.id);
// 根据id判断,小于id的就是需要计算的dom。
const list = this.slotChilds;
const doms = list.filter(v => v.key < item.id);
let scrollTop = 0;
if (doms && doms.length) {
let sum = 0;
doms.forEach(v => {
const rect = v.elm.getBoundingClientRect();
// 计算dom的高度+16, 这个16是每个模块间的间距
sum += rect.height + 16;
});
scrollTop = sum;
} else {
scrollTop = 0;
}
this.$refs.anchorContent.scrollTop = scrollTop;
},
效果:
2. 平滑滚动
上面的代码基本已经实现了点击后锚点定位的功能了,不过看上去有点生硬,接下来再加一行css代码,让体验变得更好一些。
scroll-behavior: smooth; // 滚动框实现平稳的滚动
效果:
3. 实现滚动定位
实现思路:
- 获取
$slots
数组中每个元素的高度,每个元素的高度等于元素自身高度与上一个元素高度之和。定义calcSlotsHeight
来实现这个逻辑。 - 滚动事件
onScroll
内部,scrollTop
与每个元素的高度对比,用来计算当前的锚点对应哪一个模块。
// 计算slot数组内每个元素的高度,返回一个对象
calcSlotsHeight() {
const obj = {};
let pre = 0;
for (let i = 0, len = this.slotChilds.length; i < len; i++) {
const { elm, key } = this.slotChilds[i];
const dom = elm.getBoundingClientRect();
pre += dom.height;
if (!(key in obj)) {
obj[key] = 0;
}
obj[key] = pre;
}
return obj;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
// TODO 性能有一定影响,后面可用防抖方式优化此处。
const data = this.calcSlotsHeight();
const values = Object.values(data);
const keys = Object.keys(data);
let index = 0;
for (let i = 0, len = values.length; i < len; i++) {
const v = values[i];
if (scrollTop < v || (i === len - 1 && scrollTop > v)) {
index = i;
break;
}
}
this.current = keys[index];
this.$emit('changeAnchor', this.current);
},
calcSlotsHeight
方法返回的内容在控制台打印的结果:
效果:
代码改进
- 增加定时器,简易防抖策略。
- 增加开关控制,防止点击和滚动事件互相影响。
- 优化代码
修改onScroll
方法,增加定时器.
changeAnchor(item) {
this.current = item.id;
this.$emit('changeAnchor', item.id);
const list = this.slotChilds;
const doms = list.filter(v => v.key < item.id);
let scrollTop = 0;
if (doms && doms.length) {
let sum = 0;
doms.forEach(v => {
const rect = v.elm.getBoundingClientRect();
sum += rect.height + 16;
});
scrollTop = sum;
} else {
scrollTop = 0;
}
this.$refs.anchorContent.scrollTop = scrollTop;
this.isClick = true; // 点击触发,置为true;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
if (this.timer !== 0) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
clearTimeout(this.timer);
this.onScroll2(scrollTop);
this.isClick = false; // 开关控制,滚动结束后,置为false;
}, 25)
},
onScrollTwo(scrollTop) {
// TODO 性能有一定影响,后面可用防抖方式优化此处。
const data = this.calcSlotsHeight();
const values = Object.values(data);
const keys = Object.keys(data);
const l = values.length - 1;
// 简化for循环代码
let index = values.findIndex((v, i) => scrollTop < v || (i === l && scrollTop >= v));
if (index === -1) index = 0;
// 非点击时触发
if (!this.isClick) {
this.$emit('changeAnchor', this.current = keys[index]);
}
},
全部代码
下面是全部代码,附加了css,可直接复制使用,也可以根据自己的需求进行改进~
- anchor/index.vue
<template>
<div class="anchor-detail">
<div class="anchor-key">
<span
class="key"
@click="changeAnchor(item)"
v-for="item in list"
:key="item.id"
:class="{ active: Number(current) === item.id }"
>{{ item.name }}</span>
</div>
<div class="anchor-content" ref="anchorContent" @scroll="onScroll">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'anchor-index',
props: {
list: {
type: Array,
default: () => []
}
},
data() {
return {
current: 1,
isClick: false,
};
},
mounted() {
this.timer = 0;
this.$nextTick(() => {
this.slotChilds = this._self.$slots.default;
});
},
methods: {
calcSlotsHeight() {
const obj = {};
let pre = 0;
for (let i = 0, len = this.slotChilds.length; i < len; i++) {
const { elm, key } = this.slotChilds[i];
const dom = elm.getBoundingClientRect();
pre += dom.height;
if (!(key in obj)) {
obj[key] = 0;
}
obj[key] = pre;
}
return obj;
},
changeAnchor(item) {
this.current = item.id;
this.$emit('changeAnchor', item.id);
const list = this.slotChilds;
const doms = list.filter(v => v.key < item.id);
let scrollTop = 0;
if (doms && doms.length) {
let sum = 0;
doms.forEach(v => {
const rect = v.elm.getBoundingClientRect();
sum += rect.height + 16;
});
scrollTop = sum;
} else {
scrollTop = 0;
}
this.$refs.anchorContent.scrollTop = scrollTop;
this.isClick = true;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
if (this.timer !== 0) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
clearTimeout(this.timer);
this.onScrollTwo(scrollTop);
this.isClick = false;
}, 25)
},
onScrollTwo(scrollTop) {
// TODO 性能有一定影响,后面可用防抖方式优化此处。
const data = this.calcSlotsHeight();
const values = Object.values(data);
const keys = Object.keys(data);
const l = values.length - 1;
let index = values.findIndex((v, i) => scrollTop < v || (i === l && scrollTop >= v));
if (index === -1) index = 0;
if (!this.isClick) {
this.$emit('changeAnchor', this.current = keys[index]);
}
},
}
};
</script>
<style lang="scss" scoped>
.anchor-detail {
height: 100%;
width: 100%;
display: flex;
justify-content: flex-start;
flex-direction: row;
}
.anchor-key {
width: 184px;
height: 100%;
padding: 16px;
box-sizing: border-box;
text-align: left;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 4px;
.key {
color: #222222;
font-size: 16px;
font-weight: 500;
font-family: 'PingFang SC';
line-height: 24px;
margin-bottom: 16px;
cursor: pointer;
&.active {
color: #00b388;
}
}
}
.anchor-content {
flex: 1;
overflow: auto;
scroll-behavior: smooth;
}
</style>
- anchor/anchor-item.vue
<template>
<div class="anchor-item">
<div class="label">{{ label }}</div>
<div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'anchor-item',
props: {
label: {
type: String,
default: ''
}
},
data() {
return {};
}
};
</script>
<style lang="scss" scoped>
.anchor-item {
padding: 16px;
box-sizing: border-box;
font-weight: 400;
font-family: 'PingFang SC';
background-color: #fff;
margin: 0 16px 16px;
border-radius: 4px;
.label {
color: #262626;
font-size: 20px;
margin-bottom: 15px;
line-height: 28px;
text-align: left;
}
}
</style>
参考文档
developer.mozilla.org/en-US/docs/…
文中如有问题,欢迎掘友们纠正~