my-anchor/src/Anchor.vue

<template>
  <MyAffix v-bind="affix" @viewUpdate="handleViewUpdate">
    <div class="my-anchor__wrapper">
      <div class="my-anchor">
        <div class="my-anchor__ink">
          <span class="my-anchor__ball" :class="ballClass" :style="ballStyle"></span>
        </div>
        <MyAnchorLink v-for="(item, index) in data"
                      v-bind="item"
                      :key="index"
                      :active="activeLink"
                      @click="handleClick">
          <template v-if="$scopedSlots.title" v-slot:title="props">
            <slot name="title" v-bind="props">{{props.title}}</slot>
          </template>
        </MyAnchorLink>
      </div>
    </div>
  </MyAffix>
</template>

<script>
  /**
   * 锚菜单组件
   * @author chenhuachun
   *
   * @module $ui/components/my-anchor
   *
   */
  import {MyAffix} from '$ui'
  import MyAnchorLink from './Link'
  import {scrollTop} from '$ui/utils/dom'

  /**
   * 插槽
   * @member slots
   * @property {string} title 作用域插槽,参数:菜单数据项对象
   */
  export default {
    name: 'MyAnchor',
    components: {
      MyAffix,
      MyAnchorLink
    },
    /**
     * 属性参数
     * @member props
     * @property {Object} [affix] MyAffix组件配置项 {offsetTop, offsetBottom, target}
     * @property {Array} [data] 菜单数据数组,数据项 {title, icon, href, anchor}
     * @property {string} [data.title] 菜单标题文本
     * @property {string|Object} [data.icon] 标题前面的图标
     * @property {string} [data.href] 菜单链接地址
     * @property {string} [data.anchor] 菜单锚位置id,href 与 anchor 二选一
     *
     */
    props: {
      affix: Object,
      data: Array
    },
    provide() {
      return {
        myAnchor: this
      }
    },
    data() {
      return {
        activeLink: null,
        top: 0,
        links: []
      }
    },
    computed: {
      ballClass() {
        return {
          'is-visible': this.top > 0
        }
      },
      ballStyle() {
        return {
          top: `${this.top}px`
        }
      }
    },
    watch: {
      activeLink(link) {
        if (!link) return
        const title = link.$refs.title
        if (title) {
          this.top = title.$el.offsetTop + (title.$el.clientHeight / 2) - 4.5
        }
      }
    },
    methods: {
      getContainer() {
        if (!this.affix || !this.affix.target) {
          return window
        }
        return this.affix.target()
      },
      scrollTo(anchor) {
        if (!anchor) return
        const el = document.querySelector(anchor)
        if (!el) return

        const wrapper = this.getContainer()
        if (!wrapper) return
        scrollTop(wrapper, wrapper.scrollY || wrapper.scrollTop, el.offsetTop)
      },
      handleClick(vm) {
        this.activeLink = vm
        const anchor = vm.anchor
        if (anchor) {
          this.scrollTo(anchor)
        }
        /**
         * 点击菜单项时触发
         * @event click
         * @param {VueComponent} vm 菜单项实例
         */
        this.$emit('click', vm)
      },
      setCurrentAnchor(container) {
        const viewOffsetTop = container.offsetTop || document.documentElement.scrollTop
        const viewScrollTop = container.scrollTop || document.documentElement.scrollTop
        let matchScrollTop = 0
        this.links.forEach(link => {
          const el = document.querySelector(link.anchor)
          if (!el) return
          const realOffsetTop = el.offsetTop - viewOffsetTop
          // 确保从上到下找
          if (el && realOffsetTop >= matchScrollTop && viewScrollTop >= realOffsetTop) {
            matchScrollTop = realOffsetTop
            this.activeLink = link
          }
        })
      },
      handleViewUpdate() {
        const wrapper = this.getContainer()
        if (!wrapper) return
        this.setCurrentAnchor(wrapper)
      }
    }
  }
</script>