如何优雅的实现锚点功能
锚点是什么
相信各位掘友应该都知道,锚点就是类似快捷导航目录
的功能,大多是为了提升用户体验的。
大家都见过的🌰:
- Naive UI的侧标导航

如何实现
- 使用现成的组件库
优点 | 缺点 |
---|---|
使用简单,CV过来改改就好了 | 1.不具有通用性,针对不同的页面内容,需要从新开发 2. 如果你的工程路由模式 是hash 模式,这种通过href 去实现滚动是存在问题的 |
- 自己实现
我们实现的时候要考虑到上面提到的缺点。
简单理一下需求
- 尽可能通用
- 点击页面会滚动到相应的位置
- 滚动页面时,高亮的效果
代码敲起来
考虑到通用性,因此笔者想着实现一个Vue指令
,作用于需要锚点的容器组件上,容器组件里面包含了多个标题
,那可以将标题的 className
当作指令 value
传入,指令内部就可以自动的收集标题了。
新建指令
找到工程目录中开发自定义指令的地方,例如src/directives
,新建文件夹 autoAnchor
,新建文件index.js
function renderAnchor(el, usrClsObj) {
// 根据传入的className找到所有的标题
const panels = [...el.querySelectorAll(usrClsObj.selector)]
}
export default {
inserted(el, binding) {
renderAnchor(el, binding.value)
},
unbind(el, binding) {
}
}
实现一个组件用来显示标题
当前目录新建文件Anchor.vue
,内容如下:
<template>
<div class="anchor-group-wrap">
<div class="link-wrapper">
<div class="link-container" v-show="expand">
<i title="收起" class="yu-icon-arr-right1" @click="expand = false"></i>
<div offset-top="0">
<div class="el-anchor__wrapper" style="max-height: 100vh;">
<div class="el-anchor">
<div class="el-anchor__ink">
<span class="el-anchor__ink__ball" style="top: 0px; display: none;"></span>
</div>
<div
:class="{'el-anchor__link': true, 'el-anchor__link__active': current === title.title}"
v-for="title in selfTitles"
:key="title.titleId"
@click="setActive(title)"
>
<i class="el-anchor__ink__ball"></i>
<span route="#baseInfo" data-scroll-offset="0" data-route="#baseInfo" :title="title.title" class="el-anchor__link__title">{{ title.title }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="anchor-box" v-show="!expand" @click="expand = true">
<span class="anchor-icon yu-icon-menu3"></span>
<span class="anchor-tit">导航</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
titles: Array
},
data() {
return {
current: '', // 当前选中
selfTitles: this.titles,
expand: true, // 可折叠
}
},
beforeDestroy() {
this.observer.unobserve()
},
methods: {
// 选中一个标题时,使用scrollIntoView让标题对应的dom滚动到可视区域
setActive(obj) {
const { title, titleId } = obj
this.current = title
const dom = document.body.querySelector('#' + titleId)
dom.scrollIntoView({ behavior: 'smooth' })
}
}
}
</script>
<style lang="less">
// 样式省略
</style>
这个组件有个prop titles
,逻辑也很简单,就是使用v-for渲染一个title列表,接着就是去构造这个titles
传给Anchor
组件,实例化这个Anchor
组件,挂载。
实例化Anchor,挂载
在renderAnchor中添加逻辑
import Vue from 'vue'
import { genUUID } from '../../utils/index'
import Anchor from './Anchor.vue'
function renderAnchor(el, usrClsObj) {
const AnchorConstruct = Vue.extend(Anchor) // 继承Vue 的构造函数
const panels = [...el.querySelectorAll(usrClsObj.selector)]
// 构造titles
const titles = panels.map(panel => {
// 给每个标题设置 id 的 attribute
const titleId = 'id' + genUUID(8)
panel.setAttribute('id', titleId)
return { titleId, title: panel.childNodes[0].textContent || '' }
})
el.panels = panels // 暂存到el, 后面会用到
const instance = new AnchorConstruct({ propsData: { titles } }) // 实例化Anchor
el._anthorvue = instance
instance.observer = observer
instance.$mount()
el.appendChild(instance.$el) // 挂载
}
到这里不出意外页面已经可以看到效果了,接着就是滚动的问题,在Anchor
中点击标题实现了滚动,那同样的,当滚动内容,标题的选中也应该保持同步。
滚动处理
监听内容的滚动,然后设置对应的标题选中,这里使用IntersectionObserver
。
不了解的掘友可以移步了解下:
IntersectionObserver.IntersectionObserver() - Web API 接口参考 | MDN (mozilla.org)
// instance 就是Anchor实例
function initIntersectionObserver(instance) {
const observer = new IntersectionObserver(
entrys => {
entrys.forEach(entry => {
if (entry.isIntersecting) { // 进入可视区域了
instance.current = entry.target.childNodes[0].textContent // 更改实例的current属性,会触发页面更新
}
})
},
{
rootMargin: '0px',
threshold: 0
}
)
return observer
}
function renderAnchor(el, usrClsObj) {
//...之前写的
const observer = initIntersectionObserver(instance)
el.observer = observer // 暂存到el,后面会用到
panels.forEach(item => {
observer.observe(item) // 观察每个title的dom
})
//...之前写的
}
unbind
在指令的unbind
钩子中,需要让IntersectionObserver不再观察dom的滚动,相当于事件的解绑,防止性能,甚至内存出现问题。
export default {
inserted(el, binding) {
if (binding.value) {
renderAnchor(el, binding.value)
}
},
unbind(el, binding) {
// 遍历解绑
el.panels.forEach(panel => {
el.observer.unobserve(panel)
})
el.panels = null
el._anthorvue = null
el.observer = null
}
}
使用
到这里,其实已经功能已经好了,注册了验证下:
Vue.directive('anchor', autoAnchor)
在分组表单上用下试试
看下效果:

发现一个BUG
,就是点击一个title的时候,会高亮别的title,那就改一下
Anchor.vue
<script>
export default {
//...
data() {
return {
//...
click: false
}
},
methods: {
setActive(obj) {
// ...
this.click = true
// ...
this.$nextTick(() => {
this.click = false
})
}
}
}
</script>
initIntersectionObserver
function initIntersectionObserver(instance) {
const observer = new IntersectionObserver(
entrys => {
entrys.forEach(entry => {
if (entry.isIntersecting) {
instance.click === false && (instance.current = entry.target.childNodes[0].textContent)
}
})
},
{
rootMargin: '0px',
threshold: 0
}
)
return observer
}
这个问题就解决了,当笔者洋洋得意时,同事找到我,你这个一点都不好用。
一看,他是这么用的:
搞了两个Anchor实例
出来,其实一个页面应该只有一个的,那再来改下:
思路就是,将第一个容器后面的的titles
都添加到第一个Anchor实例
中,将容器的className
也传入,方便我们获取第一个容器Dom(因为我们已经将Anchor实例暂存到第一个容器Dom)
同一个页面共用一个Anchor实例
function renderAnchor(el, usrClsObj) {
const AnchorConstruct = Vue.extend(Anchor)
const parentNode = el.parentNode.querySelector(usrClsObj.pSelector) // 第一个容器Dom
const panels = [...el.querySelectorAll(usrClsObj.selector)]
const titles = panels.map(panel => {
const titleId = 'id' + genUUID(8)
panel.setAttribute('id', titleId)
return { titleId, title: panel.childNodes[0].textContent || '' }
})
el.panels = panels
if (parentNode._anthorvue) { // 第一个容器暂存了Anchor实例,就调用addTitles添加就好了
parentNode._anthorvue.addTitles(panels, titles)
el.observer = parentNode.observer
} else {
const instance = new AnchorConstruct({ propsData: { titles } })
const observer = initIntersectionObserver(instance)
el.observer = observer
el._anthorvue = instance
instance.observer = observer
instance.$mount()
panels.forEach(item => {
observer.observe(item)
})
el.appendChild(instance.$el)
}
}
Anchor.vue
<template>
// ...
</template>
<script>
export default {
props: {
titles: Array
},
data() {
return {
current: '',
selfTitles: this.titles,
expand: true,
click: false
}
},
beforeDestroy() {
this.observer.unobserve()
},
methods: {
setActive(obj) {
const { title, titleId } = obj
this.current = title
this.click = true
const dom = document.body.querySelector('#' + titleId)
dom.scrollIntoView({ behavior: 'smooth' })
this.$nextTick(() => {
this.click = false
})
},
addTitles(panels, titles) { // 添加并观察dom滚动
this.selfTitles.push(...titles)
const observer = this.observer
if (observer) {
panels.forEach(item => {
observer.observe(item)
})
}
}
}
}
</script>
<style lang="less">
</style>
最终

🌹🌹🌹感谢掘友的观看🌹🌹🌹
🌹🌹🌹如果觉得看完对您有点帮助,点个赞支持下🌹🌹🌹
🌹🌹🌹如果笔者写的不对的,还望不吝赐教噢🌹🌹🌹
转载自:https://juejin.cn/post/7125421657094619167