前言
借鉴 vant-sticky 源码,实现业务需求的某个功能时,第一眼看以为看懂了,拿来用的时候,才发现一知半解。看第二遍时,对不起,是我肤浅了。这里侧重分析实现原理,其他部分不拓展。一起研读源码,交流心得吧 ~
会分析这三个的源码实现,因为项目用的 Vue2,故参考 Vant2 的 v2.12.54 版本,
而该版本未实现 Vant3 的吸底距离功能,故不做分析。
如果只关注实现原理,不关注每个部分实现细节的话,可以跳到 onScroll 滚动事件部分。
项目启动和调试
clone 项目:
git clone https://github.com/youzan/vant.git
切换版本:
git checkout v2.12.54
安装和启动项目:
npm run bootstrap
npm run dev
调试过程中,可以打印计算值,帮助理解
源码分析
找到 vant-sticky 目录后,开始我们的源码分析吧
html 部分
render() {
const { fixed } = this;
const style = {
height: fixed ? `${this.height}px` : null,
};
return (
<div style={style}> // 1 注意这里是 style
// bem({ fixed })
// fixed 为 true 生成的是 'vant-sticky--fixed‘
// 否则生成的是 'vant-sticky‘
<div class={bem({ fixed })} style={this.style}> // 2 这里是 this.style
{this.slots()}
</div>
</div>
);
}
标记说明:
1 为包裹元素 用于占位,因为内部元素 class=‘vant-sticky–fixed’ 是用 fixed 实现的,会脱离文档流。
2 class 和 style 都是根据 fixed 去决定是否展示。而 style 是计算属性,动态变化的。
这里学习到的是:
- 元素使用 fixed 时,为了不影响滚动效果,布局错乱,可以包裹一个父元素去保持占位。
- 由同个变量去控制一个元素的样式变化,而静态的样式放到 class 里,动态的放到 style 里。
css 部分
@import '../style/var';
.van-sticky {
&--fixed {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: @sticky-z-index;
}
}
@import ‘…/style/var’ 定义了 less 变量,@sticky-z-index: 99;
computed: {
// 将 px vw vh rem 单位传值转换为 px
offsetTopPx() {
return unitToPx(this.offsetTop);
},
// style 动态响应 dom 元素
style() {
// 由 this.fixed 控制 style 展现与否
if (!this.fixed) {
// 返回空,就不设置 style 了
return;
}
const style = {};
if (isDef(this.zIndex)) {
// 修改层级,vant 默认在 vant-sticky--fixed 里变量定义为 99,这里通过传参修改
style.zIndex = this.zIndex;
}
if (this.offsetTopPx && this.fixed) {
// offsetTopPx 赋值给 top,来设置偏移量
style.top = `${this.offsetTopPx}px`;
}
if (this.transform) {
style.transform = `translate3d(0, ${this.transform}px, 0)`;
}
return style;
},
},
props 和 data 部分
简单看下传值和变量定义部分
props: {
zIndex: [Number, String], // 吸顶时的 z-index
container: null, // 容器对应的 HTML 节点,类型 Element
offsetTop: { // 吸顶时与顶部的距离,支持 px vw vh rem 单位,默认 px
type: [Number, String],
default: 0,
},
},
data() {
return {
fixed: false,
height: 0, // 元素本身高度
transform: 0, // 偏移量,只在有容器,且展示吸底效果时,有用到
};
},
初始的生命周期部分
created 生命周期
created() {
// compatibility: https://caniuse.com/#feat=intersectionobserver
// vant2 使用 SSR 写的,故有 isServer 是否在服务器运行的判断
// window.IntersectionObserver ie11 不支持
if (!isServer && window.IntersectionObserver) {
this.observer = new IntersectionObserver(
// entries是一个数组,每个成员都是一个 IntersectionObserverEntry 对象
// 有几个被观察的成员就有几个对象
(entries) => {
// 每次元素进入可视区 或 离开可视区时 触发
if (entries[0].intersectionRatio > 0) {
this.onScroll();
}
},
// root 属性指定目标元素所在的容器节点(即根元素)
{ root: document.body }
);
}
},
Window.IntersectionObserver 自动观察元素是否可见(本质是目标元素与视口产生一个交叉区,只有线程空闲下来,才会执行观察器), 详见 阮一峰的 IntersectionObserver API 使用教程
后续会用到,虽然即便把 IntersectionObserver 相关部分全都注释掉,也不影响使用。
IntersectionObserver 食用说明:
// 用法
this.observer = new IntersectionObserver(callback, option)
// 开始观察
this.observer.observe(this.$el);
// 停止观察
this.observer.unobserve(this.$el);
// 关闭观察器
this.observer.disconnect();
接下来先了解下 BindEventMixin 函数,用于混入生命周期函数 mounted、activated、deactivated、beforeDestroy 以绑定和取消监听传入的 handler 事件
import { on, off } from '../utils/dom/event';
let uid = 0;
// 入参 handler 是个函数
export function BindEventMixin(handler) {
const key = `binded_${uid++}`; // 记录绑定
function bind() {
if (!this[key]) { // 没有绑定
handler.call(this, on, true); // 把 on(即 addEventListener)传给 handler,第三个参数是告知 handler 当前状态是否绑定
this[key] = true; // 标记绑定
}
}
function unbind() {
if (this[key]) { // 绑定了,则取消监听事件
handler.call(this, off, false); // 把 off (即 removeEventListener )传给 handler
this[key] = false; // 标记w未绑定
}
}
// 通过 mixins,混入生命周期函数,以绑定和取消监听事件
return {
mounted: bind,
activated: bind,
deactivated: unbind,
beforeDestroy: unbind,
};
}
mixins :用于混入生命周期钩子
mixins: [
BindEventMixin(function (bind, isBind) {
if (!this.scroller) {
this.scroller = getScroller(this.$el); // getScroller 从当前元素一直向上找到带有滚动属性的元素
}
// this.observer 是 IntersectionObserver 的实例
if (this.observer) {
// 当 mounted|activated 时,isBind 为 true,开始观察
// 当取消监听时,isBind 为 false,停止观察
const method = isBind ? 'observe' : 'unobserve';
this.observer[method](this.$el);
}
// bind 即为 on( addEventListener)
bind(this.scroller, 'scroll', this.onScroll, true);
this.onScroll();
}),
],
这里能借鉴的是,我们也可以通过 mixins 的方式去自动的绑定和取消监听事件。前提是,需要一开始载入便监听的且符合这些生命周期。同时 watch 某个数据变化,去手动的监听和取消监听就不太适用了。当然,也可以依据情况改造下函数。
onScroll 滚动事件部分
概念
我们先弄明白后续计算会用到的几个概念
scrollTop 是滚动的距离,红箭头是 window.scrollTop 滚动的距离
getBoundingClientRect() 提供了元素的大小及其相对于视口的位置
红箭头是 el.getBoundingClientRect().top 的距离
可以发现,在向上滚动的过程中,window.scrollTop 不断增加,el.getBoundingClientRect().top 不断减少。而增加的部分刚好等于减少的部分。
如果元素的顶部超出视口,那么 el.getBoundingClientRect().top 为负值,window.scrollTop 还是不断增加。
可以得出,在滚动的过程中, el.getBoundingClientRect().top + window.scrollTop 的值始终是不变的。 而一开始 window.scrollTop 为 0,因此两者相加的值其实始终为一开始 el.getBoundingClientRect().top 的值,也就是元素顶部到视口顶部的距离。
offsetHeight 是一个元素本身的高度 + padding+border+滚动条,不包括伪元素
因此 el.getBoundingClientRect().top + window.scrollTop + el.offsetHeight 的含义是元素的初始位置的底部到视口顶部的距离
有了上面的理论基础后,接下来是重中之重的 onScroll 滚动事件部分,先从 1、2 情况讲起
实现原理:
关键代码:scrollTop + offsetTopPx > topToPageTop
当页面滚动距离 + 偏移量 > 目标元素一开始距离顶部的距离,目标元素设置 fixed 属性,吸顶。其中偏移量,是通过设置 props 的 offsetTop 属性去偏移。
反过来,意味着滚回去了,那么移除 fixed 属性
methods: {
onScroll() {
// 判断当前元素,及祖先元素是否隐藏了,隐藏了就不需要滚动了
if (isHidden(this.$el)) {
return;
}
// 当前元素的高度,可用于占位,一直不变的
this.height = this.$el.offsetHeight;
const { container, offsetTopPx } = this;
// window 滚动的距离 window.scrollTop
const scrollTop = getScrollTop(window);
// getElementTop() 返回 el.getBoundingClientRect().top + window.scrollTop
// 上面分析过,保持不变,是一开始元素的顶部与视口顶部的距离
const topToPageTop = getElementTop(this.$el);
const emitScrollEvent = () => {
this.$emit('scroll', {
scrollTop,
isFixed: this.fixed,
});
};
// 先注释掉该部分后面讲解,目前的部分足够实现 1 2 效果
// if (container) {
// ...
// }
// 当滚动距离达到指定上限:页面滚动的距离 + 偏移 > 一开始元素的顶部与视口顶部的距离(这里面是有包括 offsetTop 的高度的)
// 这里也可以反过来想, 一开始元素的顶部与视口顶部的距离 - 偏移 为最多可以滚动的距离
// offsetTopPx 偏移,来自设置的 props 的 offsetTop
if (scrollTop + offsetTopPx > topToPageTop) {
this.fixed = true; // 设置 fixed 属性,目标元素视口吸顶
this.transform = 0; // 重置因吸底容器效果而产生的偏移 transform,后面会提到。
} else {
// 当滚回顶部时,取消 fixed
this.fixed = false;
}
emitScrollEvent();
},
}
接下来,分析 3 指定容器的情况
和 1 2 情况不同的是,当不断向下滚动,元素会被容器带走,在这之前视口顶部到容器底部的距离,小于目标元素高度 + 偏移量时,会吸底容器,如下图。
而其他部分逻辑的代码实现和 1 2 情况相同。
有个问题是,如果在容器和元素之间再放个元素,是否也有吸底效果呢?
代码如下
<div ref="container" style="height: 150px; background-color: #fff">
<van-button type="warning">假容器</van-button>
<van-sticky :container="container" :offset-top="20">
<van-button type="warning" style="margin-left: 215px">指定容器</van-button>
</van-sticky>
类似这种
超出容器边界了,不是我们想要的效果。看起来,这一版并不支持上述情况。因此,我们接下来的源码分析,默认目标元素一开始的位置是在容器边缘
实现原理:
scrollTop + offsetTopPx + this.height > bottomToPageTop
当页面滚动距离 + 偏移 + 目标元素高度,超出了容器一开始的底部到视口顶部的距离
如果超出部分小于元素高度,则展示吸底效果。设置 fixed 吸顶,在通过 transfom 向上移动超出的距离,以达到吸底容器的效果。
如果完全超出元素高度,则消除所有静态、动态样式,回到原样。
下面部分代码,便是上述特殊吸底情况的分析。
if (container) {
// 借鉴上面的分析,排除不支持的情况后
// topToPageTop 为一开始目标元素顶部到视口顶部的距离(涵盖偏移量)
// container.offsetHeight 容器自身的高度
// bottomToPageTop 为容器一开始从底部到视口顶部的距离
const bottomToPageTop = topToPageTop + container.offsetHeight;
// 页面滚动的距离 + 偏移 + 目标元素的高度 > 容器一开始从底部到顶部的距离
// 其中 this.height = this.$el.offsetHeight;
// 意味着,如果保持 fixed 的状态,目标元素会超出容器底部,这时候应该让它吸底
if (scrollTop + offsetTopPx + this.height > bottomToPageTop) {
// 目标元素超出底部的距离 = 目标元素高度 + 页面滚动距离 - 容器一开始的底部到顶部的距离
// distanceToBottom 为什么不减去偏移呢?因为此时视觉上已经超出容器底部了,不需要管偏移,而是要吸附容器底部了
const distanceToBottom = this.height + scrollTop - bottomToPageTop;
// 超出距离 < 元素高度
// 没有全部超出,元素吸底展示
if (distanceToBottom < this.height) {
// 给个 fixed 吸顶,通过调整 transform 往上移动使得视觉上元素到了容器的底部
this.fixed = true;
// 需往上移动的距离为,超出的距离 + offsetTop 值的大小(抵消掉 top 值,因为原先的 style.top 值还在)
this.transform = -(distanceToBottom + offsetTopPx);
} else {
// 完全超出,解除 fixed
// 意味着 class='van-sticky--fixed' 删除,动态的 style 返回 {}
this.fixed = false;
}
emitScrollEvent();
return;
}
在理解了上述原理后,为我们的业务增效吧。动手之前多思考,生搬硬套不可取。好用的方法是,先把逻辑写下来,理清楚,再一步步去攻克和实现。