likes
comments
collection
share

如何优雅的实现锚点功能

作者站长头像
站长
· 阅读数 82

锚点是什么

相信各位掘友应该都知道,锚点就是类似快捷导航目录的功能,大多是为了提升用户体验的。

大家都见过的🌰:

如何优雅的实现锚点功能

  • 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
评论
请登录