实现一个 Vue 吸顶锚点组件方法


Posted in Javascript onJuly 10, 2019

前言

近期产品小哥哥给我提了一个新需求,在一个页面的滚动区中添加一组锚点定位按钮,点击按钮将对应的元素显示在页面的可视区中。当按钮组超出页面可视区的时候将其固定在滚动区域的头部,当滚动区滚动时,高亮距离滚动区顶部最近的元素所匹配的锚点按钮。

实现一个 Vue 吸顶锚点组件方法

实现一个 Vue 吸顶锚点组件方法

拆分功能点

现在我们已经明确需求了,接下来我们总结一下这个需求有哪些功能点:

  • 按钮组要有吸顶效果
  • 点击按钮要有锚点定位功能
  • 滚动内容区需要找到对应的按钮并高亮

吸顶组件

要做一个吸顶效果最简单的方式是将 css 的 position 属性设置为 sticky, 这样就实现粘性布局。

.sticky-container {
 position: sticky;
 top: 0px;
}

上面的示例仅仅用了两行 css 的代码就实现了粘性布局,但由于 IE 浏览器完全不支持粘性布局,而我的项目又需要支持一部分的 IE 浏览器,所以就需要手动去实现这样一个功能。

MDN 官方对粘性布局的解释是这样的,粘性布局元素默认是相对定位的,当粘性元素超出父元素的指定值(如 `top` 、`left` 等),例如上面的示例,当元素粘性元素改为固定定位。关于父级元素 MDN 描述的不是很精确,这里的父级元素指的是父级滚动元素,如果没有父级滚动元素则将 `body` 元素作为父级元素。

既然需要自己实现一个吸顶的效果,思考到其他页面可能也会使用的吸顶的功能,所以决定将其单独抽离成一个通用组件。首先我们知道粘性布局是对父级滚动元素定位,所以我们要先找到父级滚动元素,这个功能我们可以通过两种方式实现,一种是向上查找,一种是通过 props 传递一个唯一标识的 css 选择器。

我觉得其他项目可能也会遇到这个功能,所以我定义组件 尽量向着开源靠拢,所以我这里同时支持两种方案。首先我们要实现一个查找父级滚动元素的功能,如何判断一个元素是滚动元素呢?很简单判断其 `overflow` 是否是 `auto` 或者 `scroll`。

// util.js 文件
// 判断一个元素是否是滚动元素
const scrollList = ['auto', 'scroll']

export function hasScrollElement(el, direction = 'vertical') {
 if (!el) return
 const style = window.getComputedStyle(el)
 if (direction === 'vertical') {
 return scrollList.includes(style.overflowY)
 } else if (direction === 'horizontal') {
 return scrollList.includes(style.overflowX)
 }
}

// 获取第一个滚动元素
export function getFirstScrollElement(el, direction = 'vertical') {
 if (!el) return
 if (hasScrollElement(el, direction)) {
 return el
 } else {
 return getFirstScrollElement(el && el.parentElement, direction)
 }
}

这里说下实现吸顶效果所需要的一些基础知识:

  • fixed 定位是相对于浏览器的可视区进行定位,这意味着即使页面滚动,它还是会固定在相同的位置
  • offsetTop 是一个只读的属性,它返回当前元素相对于距离它最近的父级定位元素顶部的距离。
  • scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素值,`scrollTop` 表示这个元素达到父级滚动元素顶部的距离。
<template>
 <div class="cpt-sticky" :class="fixedClass" :style="{ top: top + 'px', zIndex }">
 <slot></slot>
 </div>
</template>

<script>
export default {
 props: {
 top: Number,
 parent: String,
 zIndex: Number
 },

 data() {
 return {
  fixedClass: '',
  scrollElement: null
 }
 },

 mounted() {
 this.initScrollElement()
 },

 destroyed() {
 this.removeScrollEvent()
 },

 methods: {
 handleScroll() {
  const scrollOffsetTop = this.$el.offsetTop - this.top
  if (this.scrollElement.scrollTop >= scrollOffsetTop) {
  this.fixedClass = 'top-fixed'
  } else {
  this.fixedClass = ''
  }
 },

 initScrollElement() {
  const element = document.querySelector(this.parent)
  if (element) {
  this.removeScrollEvent()
  this.scrollElement = element
  this.scrollElement.addEventListener('scroll', this.handleScroll)
  }
 },

 removeScrollEvent() {
  if (this.scrollElement) {
  this.scrollElement.removeEventListener('scroll', this.handleScroll)
  }
 }
 }
}
</script>

<style lang="scss">
.cpt-sticky {
 .top-fixed {
 position: fixed;
 width: 100%;
 background: #fff;
 }
}
</style>

就像上面的示例代码一样,短短几十行就实现了一个吸顶组件,不过它实现了吸顶的功能,但是还有一些缺陷。

  1. 在慢速滚动页面,吸顶组件在固定与非固定的时候有明显的卡顿现象。
  2. 由于我的需求有一些是需要做锚点定位功能,但是直接用锚点定位会改变路由所以改为了滚动定位(后面会细说)。但是由于吸顶组件在 `fixed` 之后会脱离文档流,导致定位的元素会有一部分(吸顶组件高度 )被卡在吸顶组件下方。就像下面这张图的效果,右边的锚点定位2区域的标题被隐藏了。

实现一个 Vue 吸顶锚点组件方法

这些问题也很好解决,使用一个和吸顶组件相同大小的占位元素,当吸顶组件脱离文档流之后,占位元素插入吸顶组件原来的 DOM 位置中,然后顺便带上一些小优化。由于占位元素需要和组件高度一致,所以必须要保证 `slot` 插槽中的 DOM 元素已经被加载完成,另外放在 slot 元素中可能发生变更,所以我在吸顶状态变更之前获取其高度。

<template>
 <div class="cpt-sticky">
 <div class="sticky-container" :class="fixedClass" :style="{ top: top + 'px', zIndex }">
  <slot></slot>
 </div>
 <div v-if="showPlaceholder" class="sticky-placeholder" :style="{ height: offsetHeight + 'px' }"></div>
 </div>
</template>

<script>
import { getFirstScrollElement } from 'util.js'

export default {
 props: {
 top: {
  type: Number,
  default: 0
 },
 zIndex: {
  type: Number,
  default: 0
  },
 parent: {
  type: String,
  default: ''
 }
 },

 data() {
 return {
  isMounted: false,
  fixedClass: '',
  offsetHeight: 0,
  scrollElement: null,
  showPlaceholder: false
 }
 },

 mounted() {
 this.isMounted = true
 this.initScrollElement()
 },

 watch: {
 parent: {
  immediate: true,
  handler: 'getScrollElement'
 },

 fixedClass(v) {
  if (v && !this.offsetHeight) {
  this.offsetHeight = this.$el.offsetHeight
  }
  this.showPlaceholder = !!v
 }
 },

 destroyed() {
 this.removeScrollEvent()
 },

 methods: {
 handleScroll(e) {
  const scrollOffsetTop = this.$el.offsetTop - this.top
  if (this.scrollElement.scrollTop >= scrollOffsetTop) {
  this.fixedClass = 'top-fixed'
  } else {
  this.fixedClass = ''
  }
 },

 initScrollElement() {
  if (!this.isMounted) return
  const parent = this.parent
  let element = null
  if (parent) {
  element = document.querySelector(parent)
  if (element === this.scrollElement) return
  } else if (this.$el) {
  element = getFirstScrollElement(this.$el)
  }
  if (element) {
  this.removeScrollEvent()
  this.scrollElement = element
  this.scrollElement.addEventListener('scroll', this.handleScroll)
  }
 },

 removeScrollEvent() {
  if (this.scrollElement) {
  this.scrollElement.removeEventListener('scroll', this.handleScroll)
  }
 }
 }
}
</script>

<style lang="scss">
.cpt-sticky {
 .top-fixed {
 position: fixed;
 width: 100%;
 background: #fff;
 }
}
</style>

锚点定位

网页中经常会有用到锚点定位的场景,例如百度知道的目录,我目前知道有三种方式可以实现这种功能。

  1. 使用 a 标签定位
  2. 使用 js 定位

使用 a 标签定位

先说说 a 标签定位,这是一种最常用的定位方式。它有两种实现方式,一种是通过 herf 属性链接的指定元素的 id。另一种是添加一个 a 标签,再将 href 属性链接到这个 a 标签的 name 属性。

<a href="#view1">按钮1</a>
<a href="#view2">按钮1</a>
...
<div id="view1">视图1</div>
<div><a name="view2">视图2</a></div>

这种定位方式很简单,它支持任意标签定位。不过它也存在一些问题,例如如果滚动区内有固定或绝对定位,会出现遮罩问题,还有瞬间滚动到顶部,交互不是很好,当然这些都可以通过 css 解决。但最主要问题是,a 标签定位会改变路由的 hash,如果有相应的路由的话会进行路由跳转。

实现一个 Vue 吸顶锚点组件方法实现一个 Vue 吸顶锚点组件方法

通过 js 模拟锚点定位

通过 js 去操作元素的 `scrollTop` 等属性,使其滚动到父级滚动元素指定的位置,就能实现定位效果。这里简单提一下 `scrollIntoView()` 这个方法,根据MDN 的描述,`Element.scrollIntoView()` 方法让当前的元素滚动到浏览器窗口的可视区域内。`scrollIntoView()` 还支持动画的选项,通过 `behavior` 设置,不过遗憾的是它遇到固定定位也会出现遮盖的问题,所以最终选择手动去撸码,不过 `scrollIntoView()` 倒是很适合做回到顶部这种功能。

首先我们需要让按钮和滚动区内容元素建立对应关系,在按钮的值中放入对应的内容区元素的 css 选择器,根据点击按钮的值找到对应的元素。所以计算规则是这个元素距离滚动区的高度加上这个元素上边距的高度(我在内容区加了外边距,我希望显示它),减去滚动区距离可视区的高度(我的页面没有定位,所以 offsetTop 对应可视区),再减去按钮组件的高度,就可以得出需要滚动的位置。

<template>
 <div class="cpt-anchor">
 <el-radio-group
  v-model="selector"
  size="mini"
  @change="handleMenuChange">
  <el-radio-button
  v-for="menu in menus"
  :key="menu.value"
  :label="menu.value">
  {{ menu.label }}
  </el-radio-button>
 </el-radio-group>
 </div>
</template>

<script>
// 添加缓动函数
import { tween } from 'shifty'
// 类似 lodash.get 但处理了 null 类型
import { get as _get } from 'noshjs'
import { getFirstScrollElement } from 'util.js'

export default {
 props: {
 // 滚动区距离可视区顶部的高度
 top: {
  type: Number,
  default: 0
 },
 menus: {
  type: Array,
  default: []
 }
 },

 data() {
 return {
  selector: ''
 }
 },

 watch: {
  menus: {
  immediate: true,
  handler(list) {
  this.selector = _get(list, [0, 'value'], '')
  }
 }
 },

 methods: {
 handleMenuChange(selector) {
  const scrollElement = document.querySelector(select)
  const rootScrollElement = getFirstScrollElement(scrollElement)
  if (scrollElement && rootScrollElement) {
  const offsetTop = scrollElement.offsetTop + scrollElement.clientTop
  const offsetHeight = _get(this.$el, ['parentElement', 'offsetHeight'], 0)
  const top = offsetTop - this.top - offsetHeight

  // 做一个缓动处理
  tween({
   from: { x: rootScrollElement.scrollTop },
   to: { x: top },
   duration: 500,
   easing: 'easeOutQuint',
   step: ({ x }) => {
   rootScrollElement.scrollTop = x
   }
  }).then(({ x }) => {
   rootScrollElement.scrollTop = x
  })
  }
 }
 }
}
</script>

锚点与视图联动

接下来我们来看看最后一个功能,当用户滚动内容区时,高亮距离按钮组件最近的那个元素所对应的按钮。这个功能我可以看成是目录导航,当我们查看不同内容时高亮对应的目录。

这个功能如何实现呢,我们来分析一下,当查看不同内容时会滚动屏幕,所以我们要给按钮的父级滚动元素绑定 `scroll` 事件。判断当前滚动区距离按钮最近的元素,我们需要在这个元素上添加与按钮中的值对应的 css 选择器。当内容区发生滚动时根据按钮获取内容区中所有的元素,然后将滚动区元素的 `scrollTop` 减去按钮元素的高度,即得出按钮下方的滚动高度,然后再遍历这些元素头部和尾部是否包含了这个滚动高度,然后找到这个元素对应的按钮。

上面的结论已经可以完成,但存在一些问题,先说第一个问题导致按钮导航失效,只导航到下一个按钮边结束。这个问题不一定会所有人都遇到,之所以我会遇到这个问题,是因为我用了 `Element` 的 `Radio` 组件,要高亮的时候变更了 v-model 的值导致。而点击按钮时会触发滚动,就会和联动高亮的事件冲突了,所以用一个 `isScroll` 变量标记当前是否是锚点定位状态,定位状态不触发滚动操作。

<template>
 <div class="cpt-anchor">
 <el-radio-group
  v-model="selector"
  size="mini"
  @change="handleMenuChange">
  <el-radio-button
  v-for="menu in menus"
  :key="menu.value"
  :label="menu.value">
  {{ menu.label }}
  </el-radio-button>
 </el-radio-group>
 </div>
</template>

<script>
import { tween } from 'shifty'
import { get as _get } from 'noshjs'
import { getFirstScrollElement } from 'util.js'

import TabMenus from 'components/tab-menus.vue'

export default {
 props: {
 top: {
  type: Number,
  default: 0
 },
 menus: {
  type: Array,
  default: []
 },
 parent: {
  type: String,
  default: ''
 }
 },

 data() {
 return {
  menu: '',
  isScroll: true,
  isMounted: false,
  scrollTop: 0,
  anchorChange: false,
  rootScrollElement: ''
 }
 },

 mounted() {
 this.isMounted = true
 this.getScrollElement()
 },

 watch: {
 parent: {
  immediate: true,
  handler: 'getScrollElement'
 },

 menus: {
  immediate: true,
  handler(list) {
  this.menu = _get(list, [0, 'prop'], '')
  }
 },

 scrollTop(v) {
  if (this.anchorChange) {
  // 切换按钮会滚动视图,$nextTick 之后按钮值改变了,但滚动可能还没有结束,所以需要打个标记。
  this.isScroll = true
  }
 }
 },

 methods: {
 handleMenuChange(select) {
  this.isScroll = false
  this.anchorChange = false
  // 滚动高度等于元素距离可视区头部高度减去元素自身高度与元素上边框高度以及滚动区距离可视区头部的高度。
  const scrollElement = document.querySelector(select)
  if (scrollElement && this.rootScrollElement) {
  const offsetTop = scrollElement.offsetTop + scrollElement.clientTop
  const offsetHeight = _get(
   this.$el,
   ['parentElement', 'offsetHeight'],
   0
  )
  const top = offsetTop - this.top - offsetHeight

  // 做一个缓动处理
  tween({
   from: { x: this.rootScrollElement.scrollTop },
   to: { x: top },
   duration: 500,
   easing: 'easeOutQuint',
   step: ({ x }) => {
   this.rootScrollElement.scrollTop = x
   }
  }).then(({ x }) => {
   this.rootScrollElement.scrollTop = x
  })

  this.$nextTick(() => {
   this.anchorChange = true
  })
  }
 },

 getScrollElement() {
  if (!this.isMounted) return
  // 如果没有传入 parent 默认取第一个父级滚动元素
  const parent = this.parent
  let element = null
  if (parent) {
  element = document.querySelector(parent)
  // mount 之后 rootScrollElement 可能已经存在了,如果和上次一样就不做任何操作。
  if (element === this.rootScrollElement) return
  } else if (this.$el) {
  element = getFirstScrollElement(this.$el.parentElement)
  }
  if (element) {
  this.removeScrollEvent()
  this.rootScrollElement = element
  this.rootScrollElement.addEventListener('scroll', this.handleScroll)
  }
 },

 removeScrollEvent() {
  if (this.rootScrollElement) {
  this.rootScrollElement.removeEventListener('scroll', this.handleScroll)
  }
 },

 handleScroll(event) {
  const scrollTop = this.rootScrollElement.scrollTop
  this.scrollTop = scrollTop
  if (!this.isScroll) return
  const { data, top } = this
  const offsetHeight = _get(this.$el, ['parentElement', 'offsetHeight'], 0)
  const scrollList = []
  data.forEach(item => {
  const element = document.querySelector(item.prop)
  if (element) {
   const top = element.offsetTop
   const rect = {
   top: top + element.clientTop - top - offsetHeight,
   bottom: top + element.offsetHeight - top - offsetHeight
   }
   scrollList.push(rect)
  }
  })
  // 遍历按钮元素的 top 和 bottom,查看当前滚动在那个元素的区间内。
  scrollList.some((it, index) => {
  if (index && scrollTop >= it.top && top < it.bottom) {
   const menu = _get(data, [index, 'prop'], '')
   if (menu) this.menu = menu
   return true
  } else {
   // 当小于最小高度时,就等于最小高度
   if (scrollTop >= 0 && scrollTop < it.bottom) {
   const menu = _get(data, [index, 'prop'], '')
   if (menu) this.menu = menu
   return true
   }
  }
  })
 }
 }
}
</script>

<style lang="scss">
.cpt-anchor {
 padding-top: 4px;
 .cpt-tab-menus {
 margin: 0;
 .el-radio-button {
  margin-left: 10px;
  .el-radio-button__inner {
  border: none;
  border-radius: 5px 5px 0 0;
  border-bottom: 2px solid #e4e7ed;
  background-color: #f6f6f8;
  font-size: 16px;

  &:hover {
   border-bottom: 2px solid #409eff;
  }
  }

  &.is-active {
  .el-radio-button__inner {
   color: #fff;
   border: none;
   border-radius: 5px 5px 0 0;
   background-color: #409eff;
   border-bottom: 2px solid #409eff;
   box-shadow: none;
  }
  }
 }
 }
}
</style>

吸顶锚点组件

最后将上面两个组件合并到一起就是我们所需要的吸顶锚点组件了。

<template>
 <div class="cpt-sticky-anchor">
 <sticky :top="top" :z-index="zIndex">
  <sticky-menu :top="top" :data="menus" :parent="parent"></sticky-menu>
 </sticky>
 // 滚动区内容存放位置
 <slot></slot>
 </div>
</template>

<script>
import Sticky from './sticky.vue'
import StickyMenu from './menu.vue'

export default {
 // 这里简写了,因为上面已经有了。
 props: {
 top,
 menus,
 parent,
 zIndex,
 offsetHeight
 },

 components: {
 Sticky,
 StickyMenu
 }
}
</script>

使用示例

<template>
 <div class="page-demo">
 ... 其他内容
 <sticky-anchor menus="menus" parent=".page-demo">
  <ul>
  <li class="button-1">视图一</li>
   <li class="button-2">视图二</li>
  </ul>
  </sticky-anchor>
 </div>
</template>

<script>
import StickyAnchor from 'components/sticky-anchor.vue'

export default {
 data() {
 return {
  menus: [
  { label: '按钮一', value: '.button-1' },
  { label: '按钮二', value: '.button-2' }
  ]
 }
 },

 components: {
 StickyAnchor
 }
}
</script>

总结

到这里整个功能已经全部实现了,我们来总结一下。

吸顶效果用两种解决方案,如果浏览支持 sticky 布局,使用 css 更加方便。 使用 a 标签做锚点定位更加简单,但遇到定位布局需要特殊处理,但会改变路由 hash。 做锚点与滚动联动时需要注意按钮点击事件与滚动事件冲突。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
Jquery下的26个实用小技巧(jQuery tips, tricks &amp; solutions)
Mar 01 Javascript
浅析javascript闭包 实例分析
Dec 25 Javascript
jQuery解析json数据实例分析
Nov 24 Javascript
JS操作XML实例总结(加载与解析XML文件、字符串)
Dec 08 Javascript
JavaScript小技巧整理
Dec 30 Javascript
动态加载js、css的实例代码
May 26 Javascript
jQuery双向列表选择器DIV模拟版
Nov 01 Javascript
jquery 删除节点 添加节点 找兄弟节点的简单实现
Dec 07 Javascript
微信小程序 轮播图swiper详解及实例(源码下载)
Jan 11 Javascript
实例详解Vue项目使用eslint + prettier规范代码风格
Aug 20 Javascript
Vue基本使用之对象提供的属性功能
Apr 30 Javascript
vuex存取值和映射函数使用说明
Jul 24 Javascript
vue webpack重写cookie路径的方法
Jul 10 #Javascript
vue登录页面cookie的使用及页面跳转代码
Jul 10 #Javascript
Laravel admin实现消息提醒、播放音频功能
Jul 10 #Javascript
微信小程序把百度地图坐标转换成腾讯地图坐标过程详解
Jul 10 #Javascript
JavaScript实现的弹出遮罩层特效经典示例【基于jQuery】
Jul 10 #jQuery
JavaScript实现的滚动公告特效【基于jQuery】
Jul 10 #jQuery
JavaScript前端页面搜索功能案例【基于jQuery】
Jul 10 #jQuery
You might like
非常不错的MySQL优化的8条经验
2008/03/24 PHP
php 静态变量与自定义常量的使用方法
2010/01/26 PHP
PHP利用APC模块实现文件上传进度条的方法
2015/01/26 PHP
PHP随机生成唯一HASH值自定义函数
2015/04/20 PHP
php需登录的文件上传管理系统
2020/03/21 PHP
写自已的js类库需要的核心代码
2012/07/16 Javascript
JavaScript数组对象赋值用法实例
2015/08/04 Javascript
jQuery实现Meizu魅族官方网站的导航菜单效果
2015/09/14 Javascript
Nodejs Express4.x开发框架随手笔记
2015/11/23 NodeJs
Nodejs爬虫进阶教程之异步并发控制
2016/02/15 NodeJs
深入剖析javascript中的exec与match方法
2016/05/18 Javascript
jQuery实现鼠标选中文字后弹出提示窗口效果【附demo源码】
2016/09/05 Javascript
VUE页面中加载外部HTML的示例代码
2017/09/20 Javascript
关于react中组件通信的几种方式详解
2017/12/10 Javascript
vue watch自动检测数据变化实时渲染的方法
2018/01/16 Javascript
chosen实现省市区三级联动
2018/08/16 Javascript
jQuery+Datatables实现表格批量删除功能【推荐】
2018/10/24 jQuery
新手入门带你学习JavaScript引擎运行原理
2019/06/24 Javascript
jQuery属性选择器用法实例分析
2019/06/28 jQuery
js根据后缀判断文件文件类型的代码
2020/05/09 Javascript
vue实现简易图片左右旋转,上一张,下一张组件案例
2020/07/31 Javascript
vue created钩子函数与mounted钩子函数的用法区别
2020/11/05 Javascript
小结Python用fork来创建子进程注意事项
2014/07/03 Python
python实现一次创建多级目录的方法
2015/05/15 Python
Python 循环语句之 while,for语句详解
2018/04/23 Python
python实现大文本文件分割
2019/07/22 Python
Django 权限认证(根据不同的用户,设置不同的显示和访问权限)
2019/07/24 Python
Python使用PyQt5/PySide2编写一个极简的音乐播放器功能
2020/02/07 Python
Python3实现监控新型冠状病毒肺炎疫情的示例代码
2020/02/13 Python
使用gunicorn部署django项目的问题
2020/12/30 Python
大学学年自我鉴定
2013/10/28 职场文书
青年文明号服务承诺
2014/03/31 职场文书
施工单位安全责任书
2014/07/24 职场文书
普通党员自我剖析材料
2014/10/07 职场文书
2016春季运动会通讯稿
2015/07/18 职场文书
缅怀先烈主题班会
2015/08/14 职场文书