乐趣区

vue-sticky组件详解

sticky 简介

sticky 的本意是粘的,粘性的,使用其进行的布局被称为粘性布局。
sticky 是 position 属性新推出的值,属于 CSS3 的新特性,常用与实现吸附效果。
设置了 sticky 布局的元素,在视图窗口时,与静态布局的表现一致。
但当该元素的位置移出设置的视图范围时,其定位效果将变成 fixed,并根据设置的 left、top 等作为其定位参数。
具体效果如下,当页面滚动至下方,原本静态布局的「演职员表」将变为 fixed 布局,固定在页面顶部。

sticky 兼容性
下图可见,除了 IE 以外,目前绝大部分浏览器都是支持 sticky 布局。

需求背景

但是实际情况并不如上图展示的那么美好,在 360 安全浏览器上,并不支持 sticky 布局,即使使用极速模式(使用 chrome 内核运行)也不支持。
另外,笔者在网上找过相关的 vue-sticky 组件。但是使用起来并不是那么顺手,而且看其源码也是一头雾水,用着不踏实。
所以自己写了一个,希望通过本文能将组件分享出去,也希望将本组件的原理讲清楚。让其他同学在使用的时候能更踏实一些。遇到坑也知道该怎么去填。希望能帮到大家。

面向人群

急于使用 vue-sticky 组件的同学。直接下载文件,拷贝代码即可运行。
喜欢看源码,希望了解组件背后原理的同学。其实本 sticky 组件原理很简单,看完本文,相信你一定能把背后原理看懂。刚接触前端的同学也可以通过本文章养成看源码的习惯。打破对源码的恐惧,相信自己,其实看源码并没有想象中的那么困难

组件完整源码如下
<!–sticky 组件 –>
<template>
<!– 盒子容器 –>
<section ref=”$box” class=”c-sticky-box” :style=”boxStyle”>
<!– 内容容器 –>
<div ref=”$content” class=”content” :style=”contentStyle”>
<slot></slot>
</div>
</section>
</template>

<script>

export default {
props: {
top: {
type: [String],
default: ‘unset’,
},
left: {
type: [String],
default: ‘unset’,
},
},

data() {
return {
boxStyle: {
position: ‘static’,
top: 0,
left: 0,
width: ‘auto’, // 占位,为了形成数据绑定
height: ‘auto’,
},
contentStyle: {
position: ‘static’,
top: 0,
left: 0,
width: ‘auto’,
height: ‘auto’,
},
isFixedX: false, // 是否已经设置为 fixed 布局,用于优化性能,防止多次设置
isFixedY: false, // 是否已经设置为 fixed 布局,用于优化性能,防止多次设置
isSupport: this.cssSupport(‘position’, ‘sticky’),
// isSupport: false,
}
},

mounted() {
if (!this.isSupport) {// 不支持 sticky
this.getContentSize() // 获取内容宽高
this.scrollHandler() // 主动触发一次位置设置操作
window.addEventListener(‘resize’, this.onResize)
window.addEventListener(‘scroll’, this.scrollHandler, true)
} else {
this.boxStyle = {
position: ‘sticky’,
top: this.top,
left: this.left,
}
}
},

beforeDestroy() {
if (!this.isSupport) {
window.removeEventListener(‘resize’, this.onResize)
window.removeEventListener(‘scroll’, this.scrollHandler, true)
}
},

methods: {
// 判断是否支持某样式的函数
cssSupport(attr, value) {
let element = document.createElement(‘div’)
if (attr in element.style) {
element.style[attr] = value
return element.style[attr] === value
} else {
return false
}
},

// 获取 dom 数据
getContentSize() {
// 获取内容容器宽高信息
const style = window.getComputedStyle(this.$refs.$content)

// 设置盒子容器的宽高,为了后续占位
this.boxStyle.width = style.width
this.boxStyle.height = style.height
},

// 页面缩放重置大小时,重新计算其位置
onResize() {
const {$box} = this.$refs
const {contentStyle} = this
const boxTop = $box.getBoundingClientRect().top
const boxLeft = $box.getBoundingClientRect().left

if (contentStyle.position === ‘fixed’) {
contentStyle.top = this.top === ‘unset’ ? `${boxTop}px` : this.top
contentStyle.left = this.left === ‘unset’ ? `${boxLeft}px` : this.left
}
},

scrollHandler() {
const {$content, $box} = this.$refs
const {contentStyle} = this
const boxTop = $box.getBoundingClientRect().top
const boxLeft = $box.getBoundingClientRect().left
const contentTop = $content.getBoundingClientRect().top
const contentLeft = $content.getBoundingClientRect().left

if (this.top !== ‘unset’) {
if (boxTop > parseInt(this.top) && this.isFixedY) {
this.isFixedY = false
contentStyle.position = ‘static’
} else if (boxTop < parseInt(this.top) && !this.isFixedY) {
this.isFixedY = true
contentStyle.position = ‘fixed’
this.onResize()
}

// 当位置距左位置不对时,重新设置 fixed 对象 left 的值,防止左右滚动位置不对问题
if (contentLeft !== boxLeft && this.left === ‘unset’) {
this.onResize()
}
}

if (this.left !== ‘unset’) {
if (boxLeft > parseInt(this.left) && this.isFixedX) {
this.isFixedX = false
contentStyle.position = ‘static’
} else if (boxLeft < parseInt(this.left) && !this.isFixedX) {
this.isFixedX = true
contentStyle.position = ‘fixed’
this.onResize()
}

// 当位置距左位置不对时,重新设置 fixed 对象 left 的值,防止左右滚动位置不对问题
if (contentTop !== boxTop && this.top === ‘unset’) {
this.onResize()
}
}
},
},

}
</script>
技术难点
sticky 效果需要解决这么几个问题

占位问题,sticky 实现原理,无非是在特定超出视图时,将内容的布局设为 fixed。但将内容设置为 fixed 布局时,内容将脱离文档流,原本占据的空间将被释放掉,这将导致页面空了一块后其他内容发生位移。
页面 resize 后位置问题。当使用 fixed 定位时,其定位将根据页面进行。若页面大小发现变化,原显示的位置可能与页面变化后的不一致。这时需要重新设置。
横向滚动条问题。本质上和 resize 是同一个问题,需要监听 scroll 事件,当页面发送无相关方向的位移时,需要重新计算其位置,例如前面的 sticky 效果示例中设置了「演职员表」的 top 值,当其 fixed 后,滚动 X 轴,需要重新设置 fixed 的 left 参数。让元素始终位于页面相同位置

实现思路

组件有两层容器

一个是内容 slot 的容器 $content
一个是内容容器 $content 的 sticky 盒子容器 $box
即包围关系为 $sticky-box($content(slot))

<section ref=”$box” class=”c-sticky-box” :style=”boxStyle”>
<div ref=”$content” class=”content” :style=”contentStyle”>
<slot></slot>
</div>
</section>

监听 vue 的 mounted 事件

这时内容 slot 已经被渲染出来
获取 slot 容器 $content 的宽高,设置到 $box 容器上
设置 $box 容器宽高是为了当后续 $content 容器 Fixed 后,$box 容器仍在页面中占据空间。

const style = window.getComputedStyle(this.$refs.$content)
this.boxStyle.width = style.width
this.boxStyle.height = style.height

监听 scroll 事件

在事件中获取容器 $content 在页面中的位置,并将其与预设值进行大小比较,判断 $content 是否应该 fixed
怎么便捷地获取 $content 在页面中的位置呢?直接使用 Element.getBoundingClientRect()函数,该函数将返回 {left,top} 分别表示 dom 元素距离窗口的距离。详细可参看 MDN 文档

const {$content, $box} = this.$refs
const {contentStyle} = this
const boxTop = $box.getBoundingClientRect().top
const boxLeft = $box.getBoundingClientRect().left
const contentTop = $content.getBoundingClientRect().top
const contentLeft = $content.getBoundingClientRect().left

比较 boxTop 与预设值 top 的大小,当 boxTop 比预设值值要小时,即内容即将移出规定的视图范围。这时将内容容器 $content 设置为 fixed。并设置其 top 值(即预设的 top 值,吸顶距离),left 值与盒子位置相同,故设置为盒子距离的 left 值
当 boxTop 比预设值值要大时,即内容重新返回的视图范围。则将内容容器 $content 重新设置会静态布局,让其重新回到盒子布局内部。由于静态布局不受 left 和 top 的影响,所以不需要设置 left 和 top

if (boxTop > parseInt(this.top) && this.isFixedY) {
contentStyle.position = ‘static’
} else if (boxTop < parseInt(this.topI) && !this.isFixedY) {
contentStyle.position = ‘fixed’
contentStyle.top = this.top
contentStyle.left = `${boxLeft}px`
}
在 scroll 事件中,除了 Y 轴方向上的滚动,还可能发生 X 轴方向的滚动。这些需要重新设置 fixed 元素的 left 值,让其与盒子容器的 left 值一致
// 当位置距左位置不对时,重新设置 fixed 对象 left 的值,防止左右滚动位置不对问题
if (contentLeft !== boxLeft && this.left === ‘unset’) {
const {$box} = this.$refs
const {contentStyle} = this
const boxTop = $box.getBoundingClientRect().top
const boxLeft = $box.getBoundingClientRect().left
if (contentStyle.position === ‘fixed’) {
contentStyle.top = this.top
contentStyle.left = `${boxLeft}px`
}
}

最后,是监听页面的 resize 事件,防止页面大小变化时,fixed 相对页面的变化。同样的,重新设置 left 值
// 当位置距左位置不对时,重新设置 fixed 对象 left 的值,防止左右滚动位置不对问题
const {$box} = this.$refs
const {contentStyle} = this
const boxTop = $box.getBoundingClientRect().top
const boxLeft = $box.getBoundingClientRect().left

if (contentStyle.position === ‘fixed’) {
contentStyle.top = this.top === ‘unset’ ? `${boxTop}px` : this.top
contentStyle.left = this.left === ‘unset’ ? `${boxLeft}px` : this.left
}

需要注意的地方

目前仅支持 top 与 left 值的单独使用,暂不支持同时设置
目前仅支持 px 单位,暂不支持 rem 及百分比单位

设置内容样式时需要注意,设置定位相关属性需要设置在 box 容器上,例如设置 ’displCy: inline-block;’,’verticCl-Clign: top;’,’margin’

设置外观样式,如背景,边框等,则设置在 slot 内容中
即内容 content-box 以外的设置在 box 容器中,content-box 以内的样式,则设置在 slot 内容中

盒子容器不需要设置 position 属性,即使有也会被冲刷掉。因为程序将内部重新设置 position 的值
同样的,在样式中设置盒子容器的 left 和 top 值也是无效的,会被程序内部重新设置。只能通过 dom 属性值传递到组件中进行设置

后续优化
目前本组件仅实现了基本功能,后续还将继续优化以下功能

slot 内容中,如果有图片,如果获取设置宽高,(监听所有图片的 load 事件,重新设置容器的高宽)
目前仅在 mounted 中获取 slot 的宽高,这仅仅是 dom 元素被渲染,但是 dom 内容是否加载完毕并不知道的,如 img 标签,后续在 slot 中,监听所有 img 标签的 load 事件,load 中,重新设置组件容器的大小

slot 内容有变化时,设置容器

同样的,当 slot 内容变化后,重新设置 $content 的宽高
具体如何实现,暂时还没有头绪

移动端适配
目前只测试了在 PC 中的效果,暂未在移动端做测试。不排除移动端使用存在坑

单位适配
目前只支持 PX 单位,未支持 rem,百分百等单位

left 和 top 值的混合使用,目前只支持单个属性的使用,暂不支持同时设置

项目源码及示例
第一稿写完了,撒花花

退出移动版