实现一个滚动锚点定位组件

最近做需求,遇到了个锚点定位的需求,实现起来也很简单,在这里记录一下。不想了解实现逻辑的掘友可以直接翻到最底部,查看全部代码,复制后可以直接使用。

最终实现效果:

实现一个滚动锚点定位组件

实现思路

结合vue2中$slots的特点,以及getBoundingClientRect方法获取元素高度,计算滚动距离scrollTop来实现锚点定位。

整体结构

首先将组件的整体框架和使用方式整理出来,然后再一步一步地实现具体功能。

1. 组件的文件路径

实现一个滚动锚点定位组件

2. dom模版及部分JS代码

  1. 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来做判断。

  1. 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. 实现点击定位

实现思路:

  1. mounted中获取到的slot数组,即slotChilds。点击事件会将对应id传递过来,根据id配合slotChilds可以得到需要遍历的数组。

  2. slotChilds中的每一项里有个elm属性,它是真实的dom元素,再通过getBoundingClientRect方法可以得到具体dom元素的高度。

  3. 将每一项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. 实现滚动定位

实现思路:

  1. 获取$slots数组中每个元素的高度,每个元素的高度等于元素自身高度与上一个元素高度之和。定义calcSlotsHeight来实现这个逻辑。
  2. 滚动事件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方法返回的内容在控制台打印的结果:

实现一个滚动锚点定位组件

效果:

实现一个滚动锚点定位组件

代码改进

  1. 增加定时器,简易防抖策略。
  2. 增加开关控制,防止点击和滚动事件互相影响。
  3. 优化代码

修改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,可直接复制使用,也可以根据自己的需求进行改进~

  1. 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>
  1. 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/…

文中如有问题,欢迎掘友们纠正~

原文链接:https://juejin.cn/post/7352079427864657931 作者:娜个小部呀

(0)
上一篇 2024年3月31日 下午4:00
下一篇 2024年3月31日 下午4:11

相关推荐

发表回复

登录后才能评论