实现一个 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的val()方法
Jun 27 Javascript
div拖拽插件——JQ.MoveBox.js(自制JQ插件)
May 17 Javascript
JavaScript prototype 使用介绍
Aug 29 Javascript
javascript不同类型数据之间的运算的转换方法
Feb 13 Javascript
通过正则表达式实现表单验证是否为中文
Feb 18 Javascript
使用jquery解析XML示例代码
Sep 05 Javascript
javascript实现瀑布流自适应遇到的问题及解决方案
Jan 28 Javascript
JS对字符串编码的几种方式使用指南
May 14 Javascript
深入理解vue $refs的基本用法
Jul 13 Javascript
JavaScript实现的搜索及高亮显示功能示例
Aug 14 Javascript
vue 引入公共css文件的简单方法(推荐)
Jan 20 Javascript
cnpm加速Angular项目创建的方法
Sep 07 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
解析linux下安装memcacheq(mcq)全过程笔记
2013/06/27 PHP
2个Codeigniter文件批量上传控制器写法例子
2014/07/25 PHP
php出现内存位置访问无效错误问题解决方法
2014/08/16 PHP
php基于websocket搭建简易聊天室实践
2016/10/24 PHP
PHP更安全的密码加密机制Bcrypt详解
2017/06/18 PHP
Laravel6.0.4中将添加计划任务事件的方法步骤
2019/10/15 PHP
JavaScript Event学习第八章 事件的顺序
2010/02/07 Javascript
JS链式调用的实现方法
2013/03/07 Javascript
js判断页面中是否有指定控件的简单实例
2014/03/04 Javascript
实现checkbox全选、反选、取消JavaScript小脚本异常
2014/04/10 Javascript
JQuery的Ajax中Post方法传递中文出现乱码的解决方法
2014/10/21 Javascript
Javascript基础教程之switch语句
2015/01/18 Javascript
jquery自适应布局的简单实例
2016/05/28 Javascript
AngularJS ng-repeat数组有重复值的解决方法
2016/10/23 Javascript
详解nodejs 文本操作模块-fs模块(四)
2016/12/22 NodeJs
JavaScript对JSON数据进行排序和搜索
2017/07/24 Javascript
React全家桶环境搭建过程详解
2018/05/18 Javascript
JavaScript设计模式之装饰者模式定义与应用示例
2018/07/25 Javascript
Vue面试题及Vue知识点整理
2018/10/07 Javascript
JavaScript数组特性与实践应用深入详解
2018/12/30 Javascript
vue 集成 vis-network 实现网络拓扑图的方法
2019/08/07 Javascript
原生js实现无缝轮播图
2020/01/11 Javascript
Python自动连接ssh的方法
2015/03/07 Python
python opencv实现旋转矩形框裁减功能
2018/07/25 Python
python实现贪吃蛇小游戏
2020/03/21 Python
Python3.6实现带有简单界面的有道翻译小程序
2019/04/16 Python
Oroton中国官网:澳洲知名奢侈配饰品牌
2017/03/26 全球购物
将时尚融入珠宝:Adornmonde
2019/10/17 全球购物
校园十佳歌手策划书
2014/01/22 职场文书
3分钟英语演讲稿
2014/04/29 职场文书
学生旷课检讨书500字
2014/10/28 职场文书
优秀大学生事迹材料
2014/12/24 职场文书
银行求职信范文怎么写
2015/03/20 职场文书
Flask使用SQLAlchemy实现持久化数据
2021/07/16 Python
警用民用对讲机找不同
2022/02/18 无线电
python中filter,map,reduce的作用
2022/06/10 Python