本文首发于公众号:品味前端,作者:茶无味de一天,转载请注明出处。

前言

当我们在移动终端上滑动页面,手指离开屏幕后,页面的滚动并不会马上停止,而是在一段时间内继续保持惯性滚动,并且滑动阻尼感和持续时间与滑动手势的幅度成正比。

这种物理学效果的应用在移动端普及后,大部分笔记本触控板也都支持同样的效果。

然而鼠标滚轮的传感器通常采用光电或机械的方式运作,由一个旋转轴和一个传感器组成,旋转轴通常无法做出细微的距离控制,使得距离检测更像是段落式的,这些信号在传输到计算机后,并不能实现丝滑的滚动。

可以通过以下例子感受两种滚动的差异:

https://code.juejin.cn/pen/7272919488994279484

本文将教会你如何让鼠标滚轮也能够丝滑地操作网页,带来更舒适的页面惯性滚动体验,同时讲解其中技术原理与细节,用最少量的代码实现 JS 鼠标惯性滚动。

使用插件

要实现平滑的惯性滚动可以引入 lenis 这个库,使用非常简单:

1
npm i @studio-freight/lenis
1
2
3
4
5
6
7
8
const lenis = new Lenis()

function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}

requestAnimationFrame(raf)

演示效果可在官方 Demo 中体验:https://lenis.studiofreight.com/

当然本文不会这么简单就结束,接下来我将带你深入其中原理,动手来造一造这个轮子,代码并不复杂,一起往下看吧。

实现原理

首先需要利用 DOM 事件禁止鼠标滚动,转为 JS 控制。通过滚轮事件中的 deltaYdeltaX 值获取到最终滚动距离,浏览器帧绘制函数 requestAnimationFrame 来逐帧设置页面的 scrollTop 达到模拟滚动的效果,并利用线性插值缓动函数等数学方法来计算变化过程中的值,最终达到平滑地滚动效果。

滚轮事件

滚轮事件(wheel) 取代了已被弃用的非标准 mousewheel 事件,代码如下。

1
2
3
4
5
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
}
const el = document.documentElement
el.addEventListener('wheel', onWeel); // { passive: false }

帧绘制函数

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

通过 JS 模拟页面滚动实际可以看做是在执行一个连续的动画,这时候肯定就离不开与浏览器动画息息相关的 requestAnimationFrame 函数了,我们需要知道它的回调函数会传入一个 DOMHighResTimeStamp 参数,该参数与 performance.now() 返回值相同,表示开始执行回调函数的时间。

1
2
3
4
5
6
7
const silky = new Silky()

function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);

通过接收函数传入的参数 time,我们可以计算出每一帧持续时间,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Silky {
timeRecord = 0 // 回调时间记录

constructor({ content }) {
this.content = content || document.documentElement
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
}
this.content.addEventListener('wheel', onWeel, { passive: false });
}
raf(time) {
const deltaTime = time - (this.timeRecord || time);
this.timeRecord = time;
console.log(deltaTime * 0.001) // 单位转化为秒,该值后面计算时会用到
}
}

监听事件的第三个参数需设置为非被动模式,保证 preventDefault 可触发。

虚拟滚动

添加如下一些参数,并在类中定义 onVirtualScroll 方法,用于设置动画更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Silky {
timeRecord = 0 // 回调时间记录
targetScroll = 0 // 当前滚动位置
animatedScroll = 0 // 动画滚动位置
from = 0 // 记录起始位置
to = 0 // 记录目标位置
........
onVirtualScroll(target) {
this.to = target;
this.onUpdate = (value) => {
this.animatedScroll = value; // 记录动画距离
this.content.scrollTop = this.animatedScroll; // 设置滚动
this.targetScroll = value; // 记录滚动后的距离
}
}
}

在滚动事件中调用 onVirtualScroll

1
2
3
4
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
this.onVirtualScroll(this.targetScroll + e.deltaY);
}

定义一个 advance 方法在每一帧计算并执行 onUpdate 更新视图,不过我们现在还未进行缓动计算,所以只需要把目标位置赋值即可。

1
2
3
4
5
6
7
8
raf(time) {
......
this.advance()
}
advance() {
const value = this.to
this.onUpdate?.(value);
}

此时页面就可以像往常一样滚动了,并且是不依赖系统默认事件的,由 JS 代理滚动效果,接下来我们继续往方法里处理如何平滑过渡。

线性插值实现阻尼感

线性插值是一种简单的插值方法,它使用线性函数来计算过渡过程中的值。简单来说,它是一种通过直线来连接两个点,在两个点之间按比例计算中间的数值。线性插值可以用于各种场景,比如在图形学中计算两个点之间的中间点,或者在动画中实现平滑的过渡效果,代码实现:

1
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 对两个值进行线性插值 (0 <= amt <= 1)

我们将该方法用于每一帧计算当中,默认差值强度为 0.1

1
2
3
4
advance() {
const value = lerp(this.targetScroll, this.to, this.lerp);
this.onUpdate?.(value);
}

这样就实现了一个平滑的惯性滚动效果,但实际上由于帧率是可变的(受屏幕刷新率影响),每帧之间的插值距离也会有所不同,要进一步优化阻尼效果还需要在线性插值的基础上增加阻尼系数时间步长,目前大部分显示器在 60 FPS 左右就能让人眼的感受流畅不卡顿了,修改代码如下:

1
2
3
4
5
6
const damp = (x, y, lambda, dt) => lerp(x, y, 1 - Math.exp(-lambda * dt)) // 阻尼效果

advance(deltaTime) {
const value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
this.onUpdate?.(value);
}

deltaTime 在前面讲 requestAnimationFrame 已经计算过了,只需要在调用时传入 advance 当中,单位需转化为秒。

修改后可能你并不会感觉到有明显的差异,如果在高刷新率的显示器上两者的流畅度差异就会很明显了。关于 damp 函数的具体原理较为复杂,lenis 的作者参考了一篇2016年的文章来实现的,链接我放在了文末。

缓动函数

除了使用线性插值来实现平滑滚动,还可以使用常见的缓动函数来计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const clamp = (min, input, max) => Math.max(min, Math.min(input, max)) // 获取一个中间值

class Silky {
........
currentTime = 0 // 记录当前时间
duration = 0 // 滚动动画的持续时间
........
onVirtualScroll(target) {
this.currentTime = 0;
this.from = this.animatedScroll;
.........
}
advance(deltaTime) {
let value = 0
if (this.lerp) {
value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
} else {
this.currentTime += deltaTime
const linearProgress = clamp(0, this.currentTime / this.duration, 1)
const easedProgress = this.easing ? this.easing(linearProgress) : 1
value = this.from + (this.to - this.from) * easedProgress
}
this.onUpdate?.(value);
}
}

上面代码中 linearProgress 表示一个从 0 到 1 的线性进度值,通过代入缓动函数计算得出 easedProgress 缓动进度,最后将缓动进度乘以起始值和目标值之间的差,加上起始值而得到当前帧应该推进的值。

不同的缓动函数会有不同的效果,可以传入不同的 easing 函数来改变。

1
2
3
4
// 缓入缓出函数(ease-in-out)慢快慢
let easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
// 指数反向缓动函数(easeOut)先快后慢
let easing = (t) => 1 - Math.pow(1 - t, 2)

例子

以上代码核心的部分就都已经实现了,除 lenis 官方的演示 Demo 外,本文也举两个应用惯性滚动的例子看看实际效果如何。

视频滚动

在该例子中我使用了 scrolly-video 这个库,它能将视频每一帧解析绘制到 Canvas 上,然后基于滚动控制进度,实现效果如下:

普通滚动 平滑滚动

Gif 图帧率有限,可以前往在线体验效果,视频加载需要一点时间。

在线查看:https://code.juejin.cn/pen/7272280679629946939

scrolly-video 插件:https://www.npmjs.com/package/scrolly-video

年终总结

去年我做了一个掘金 2022 年终总结网页,采用的是滚动控制动画的交互,但效果在鼠标操作时体验并不好,之前的卡顿感强烈,动画细节也容易丢失:

现在加上这个惯性滚动,体验明显就好很多了,在线查看演示:https://code.juejin.cn/pen/7178839138609659959

完整代码

下面贴出文章的完整代码,整个 demo 的代码差不多 50 行左右:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 对两个值进行线性插值 (0 <= amt <= 1)
const damp = (x, y, lambda, dt) => lerp(x, y, 1 - Math.exp(-lambda * dt)) // 阻尼效果
const clamp = (min, input, max) => Math.max(min, Math.min(input, max)) // 获取一个中间值

class Silky {
timeRecord = 0 // 回调时间记录
targetScroll = 0 // 当前滚动位置
animatedScroll = 0 // 动画滚动位置
from = 0 // 记录起始位置
to = 0 // 记录目标位置
lerp // 插值强度 0~1
currentTime = 0 // 记录当前时间
duration = 0 // 滚动动画的持续时间

constructor({ content, lerp, duration, easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) } = {}) {
this.lerp = isNaN(lerp) ? 0.1 : lerp
this.content = content || document.documentElement
this.duration = duration || 1;
this.easing = easing;
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
this.onVirtualScroll(this.targetScroll + e.deltaY);
}
this.content.addEventListener('wheel', onWeel, { passive: false });
}
raf(time) {
if (!this.isRunning) return;
const deltaTime = time - (this.timeRecord || time);
this.timeRecord = time;
this.advance(deltaTime * 0.001)
}
onVirtualScroll(target) {
this.isRunning = true
this.to = target;
this.currentTime = 0;
this.from = this.animatedScroll;
this.onUpdate = (value) => {
this.animatedScroll = value; // 记录动画距离
this.content.scrollTop = this.animatedScroll; // 设置滚动
this.targetScroll = value; // 记录滚动后的距离
}
}
advance(deltaTime) {
let completed = false
let value = 0
if (this.lerp) {
value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
if (Math.round(this.value) === Math.round(this.to)) {
completed = true
}
} else {
this.currentTime += deltaTime
const linearProgress = clamp(0, this.currentTime / this.duration, 1)
completed = linearProgress >= 1
const easedProgress = completed ? 1 : this.easing(linearProgress)
value = this.from + (this.to - this.from) * easedProgress
}
this.onUpdate?.(value);
if (completed) this.isRunning = false
}
}

基本使用:

1
2
3
4
5
6
7
const silky = new Silky()

function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);

实例化接收参数说明:

选项 默认 描述
content document.documentElement 包含将滚动的内容的元素
lerp 0.1 线性插值强度(0 到 1 之间)
duration 1 滚动动画的持续时间(单位秒)如果定义了 lerp 则无用
easing (ease-in-out) 滚动动画的缓动函数,如果定义了 lerp 则无用

当然这只是最基础的例子,缺少一些边界处理等,如在实际生产项目中使用,推荐安装前面提到的 lenis 这个库,它拥有更完善的功能,基础使用方法和本例是一样的。

码上掘金中查看完整代码及演示:

https://code.juejin.cn/pen/7272935569129209910

参考资料

lenis 开源地址

使用 LERP 进行帧速率独立阻尼:FRAME RATE INDEPENDENT DAMPING USING LERP

以上就是文章的全部内容了,感谢看到这里!如果觉得写得还不错,对你有所帮助或启发,别忘了点赞收藏关注“一键三连”哦~ 我是茶无味(公众号: 品味前端),一名平凡的前端 Developer,希望与你共同成长~