原生JS實現慣性滾動,給鼠標滾輪增加阻尼感,縱享絲滑
前言
當我們在移動終端上滑動頁面,手指離開屏幕后,頁面的滾動并不會馬上停止,而是在一段時間內繼續保持慣性滾動,并且滑動阻尼感和持續時間與滑動手勢的幅度成正比。
這種物理學效果的應用在移動端普及后,大部分筆記本觸控板也都支持同樣的效果。
然而鼠標滾輪的傳感器通常采用光電或機械的方式運作,由一個旋轉軸和一個傳感器組成,旋轉軸通常無法做出細微的距離控制,使得距離檢測更像是段落式的,這些信號在傳輸到計算機后,并不能實現絲滑的滾動。
本文將教會你如何讓鼠標滾輪也能夠絲滑地操作網頁,帶來更舒適的頁面慣性滾動體驗,同時講解其中技術原理與細節,用最少量的代碼實現 JS 鼠標慣性滾動。
使用插件
要實現平滑的慣性滾動可以引入 lenis 這個庫,使用非常簡單:
npm i @studio-freight/lenis
const lenis = new Lenis()
function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
演示效果可在官方 Demo 中體驗:https://lenis.studiofreight.com/
當然本文不會這么簡單就結束,接下來我將帶你深入其中原理,動手來造一造這個輪子,代碼并不復雜,一起往下看吧。
實現原理
首先需要利用 DOM 事件禁止鼠標滾動,轉為 JS 控制。通過滾輪事件中的 deltaY、deltaX 值獲取到最終滾動距離,瀏覽器幀繪制函數 requestAnimationFrame 來逐幀設置頁面的 scrollTop 達到模擬滾動的效果,并利用線性插值或緩動函數等數學方法來計算變化過程中的值,最終達到平滑地滾動效果。
滾輪事件
滾輪事件(wheel) 取代了已被棄用的非標準 mousewheel 事件,代碼如下。
const onWeel = (e) => {
e.preventDefault(); // 阻止默認事件,停止滾動
}
const el = document.documentElement
el.addEventListener('wheel', onWeel); // { passive: false }
幀繪制函數
window.requestAnimationFrame() 告訴瀏覽器——你希望執行一個動畫,并且要求瀏覽器在下次重繪之前調用指定的回調函數更新動畫。
通過 JS 模擬頁面滾動實際可以看做是在執行一個連續的動畫,這時候肯定就離不開與瀏覽器動畫息息相關的 requestAnimationFrame 函數了,我們需要知道它的回調函數會傳入一個 DOMHighResTimeStamp 參數,該參數與 performance.now() 返回值相同,表示開始執行回調函數的時間。
const silky = new Silky()
function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
通過接收函數傳入的參數 time,我們可以計算出每一幀持續時間,代碼如下。
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 方法,用于設置動畫更新。
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:
const onWeel = (e) => {
e.preventDefault(); // 阻止默認事件,停止滾動
this.onVirtualScroll(this.targetScroll + e.deltaY);
}
定義一個 advance 方法在每一幀計算并執行 onUpdate 更新視圖,不過我們現在還未進行緩動計算,所以只需要把目標位置賦值即可。
raf(time) {
......
this.advance()
}
advance() {
const value = this.to
this.onUpdate?.(value);
}
此時頁面就可以像往常一樣滾動了,并且是不依賴系統默認事件的,由 JS 代理滾動效果,接下來我們繼續往方法里處理如何平滑過渡。
線性插值實現阻尼感
線性插值是一種簡單的插值方法,它使用線性函數來計算過渡過程中的值。簡單來說,它是一種通過直線來連接兩個點,在兩個點之間按比例計算中間的數值。線性插值可以用于各種場景,比如在圖形學中計算兩個點之間的中間點,或者在動畫中實現平滑的過渡效果,代碼實現:
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 對兩個值進行線性插值 (0 <= amt <= 1)
我們將該方法用于每一幀計算當中,默認差值強度為 0.1:
advance() {
const value = lerp(this.targetScroll, this.to, this.lerp);
this.onUpdate?.(value);
}
這樣就實現了一個平滑的慣性滾動效果,但實際上由于幀率是可變的(受屏幕刷新率影響),每幀之間的插值距離也會有所不同,要進一步優化阻尼效果還需要在線性插值的基礎上增加阻尼系數和時間步長,目前大部分顯示器在 60 FPS 左右就能讓人眼的感受流暢不卡頓了,修改代碼如下:
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年的文章來實現的,鏈接我放在了文末。
緩動函數
除了使用線性插值來實現平滑滾動,還可以使用常見的緩動函數來計算。
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 函數來改變。
// 緩入緩出函數(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 行左右:
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
}
}
基本使用:
const silky = new Silky()
function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
實例化接收參數說明:
當然這只是最基礎的例子,缺少一些邊界處理等,如在實際生產項目中使用,推薦安裝前面提到的 lenis 這個庫,它擁有更完善的功能,基礎使用方法和本例是一樣的。
碼上掘金中查看完整代碼及演示:
https://code.juejin.cn/pen/7272935569129209910
參考資料
lenis 開源地址: https://github.com/studio-freight/lenis
使用 LERP 進行幀速率獨立阻尼 FRAME RATE INDEPENDENT DAMPING USING LERP:「鏈接」