自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

原生JS實(shí)現(xiàn)慣性滾動,給鼠標(biāo)滾輪增加阻尼感,縱享絲滑

開發(fā) 前端
本文將教會你如何讓鼠標(biāo)滾輪也能夠絲滑地操作網(wǎng)頁,帶來更舒適的頁面慣性滾動體驗(yàn),同時(shí)講解其中技術(shù)原理與細(xì)節(jié),用最少量的代碼實(shí)現(xiàn) JS 鼠標(biāo)慣性滾動。

前言

當(dāng)我們在移動終端上滑動頁面,手指離開屏幕后,頁面的滾動并不會馬上停止,而是在一段時(shí)間內(nèi)繼續(xù)保持慣性滾動,并且滑動阻尼感和持續(xù)時(shí)間與滑動手勢的幅度成正比。

這種物理學(xué)效果的應(yīng)用在移動端普及后,大部分筆記本觸控板也都支持同樣的效果。

然而鼠標(biāo)滾輪的傳感器通常采用光電或機(jī)械的方式運(yùn)作,由一個(gè)旋轉(zhuǎn)軸和一個(gè)傳感器組成,旋轉(zhuǎn)軸通常無法做出細(xì)微的距離控制,使得距離檢測更像是段落式的,這些信號在傳輸?shù)接?jì)算機(jī)后,并不能實(shí)現(xiàn)絲滑的滾動。

本文將教會你如何讓鼠標(biāo)滾輪也能夠絲滑地操作網(wǎng)頁,帶來更舒適的頁面慣性滾動體驗(yàn),同時(shí)講解其中技術(shù)原理與細(xì)節(jié),用最少量的代碼實(shí)現(xiàn) JS 鼠標(biāo)慣性滾動。

使用插件

要實(shí)現(xiàn)平滑的慣性滾動可以引入 lenis 這個(gè)庫,使用非常簡單:

npm i @studio-freight/lenis
const lenis = new Lenis()

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

requestAnimationFrame(raf)

演示效果可在官方 Demo 中體驗(yàn):https://lenis.studiofreight.com/

當(dāng)然本文不會這么簡單就結(jié)束,接下來我將帶你深入其中原理,動手來造一造這個(gè)輪子,代碼并不復(fù)雜,一起往下看吧。

實(shí)現(xiàn)原理

首先需要利用 DOM 事件禁止鼠標(biāo)滾動,轉(zhuǎn)為 JS 控制。通過滾輪事件中的 deltaY、deltaX 值獲取到最終滾動距離,瀏覽器幀繪制函數(shù) requestAnimationFrame 來逐幀設(shè)置頁面的 scrollTop 達(dá)到模擬滾動的效果,并利用線性插值或緩動函數(shù)等數(shù)學(xué)方法來計(jì)算變化過程中的值,最終達(dá)到平滑地滾動效果。

滾輪事件

滾輪事件(wheel) 取代了已被棄用的非標(biāo)準(zhǔn) mousewheel 事件,代碼如下。

const onWeel = (e) => {
    e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
}
const el = document.documentElement
el.addEventListener('wheel', onWeel); // { passive: false }

幀繪制函數(shù)

window.requestAnimationFrame() 告訴瀏覽器——你希望執(zhí)行一個(gè)動畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動畫。

通過 JS 模擬頁面滾動實(shí)際可以看做是在執(zhí)行一個(gè)連續(xù)的動畫,這時(shí)候肯定就離不開與瀏覽器動畫息息相關(guān)的 requestAnimationFrame 函數(shù)了,我們需要知道它的回調(diào)函數(shù)會傳入一個(gè) DOMHighResTimeStamp 參數(shù),該參數(shù)與 performance.now() 返回值相同,表示開始執(zhí)行回調(diào)函數(shù)的時(shí)間。

const silky = new Silky()

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

通過接收函數(shù)傳入的參數(shù) time,我們可以計(jì)算出每一幀持續(xù)時(shí)間,代碼如下。

class Silky {
  timeRecord = 0 // 回調(diào)時(shí)間記錄

  constructor({ content }) {
    this.content = content || document.documentElement
    const onWeel = (e) => {
      e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
    }
    this.content.addEventListener('wheel', onWeel, { passive: false });
  }
  raf(time) {
    const deltaTime = time - (this.timeRecord || time);
    this.timeRecord = time;
    console.log(deltaTime * 0.001) // 單位轉(zhuǎn)化為秒,該值后面計(jì)算時(shí)會用到
  }
}

監(jiān)聽事件的第三個(gè)參數(shù)需設(shè)置為非被動模式,保證 preventDefault 可觸發(fā)。

虛擬滾動

添加如下一些參數(shù),并在類中定義 onVirtualScroll 方法,用于設(shè)置動畫更新。

class Silky {
  timeRecord = 0 // 回調(diào)時(shí)間記錄
  targetScroll = 0 // 當(dāng)前滾動位置
  animatedScroll = 0 // 動畫滾動位置
  from = 0 // 記錄起始位置
  to = 0 // 記錄目標(biāo)位置
  ........
  onVirtualScroll(target) {
    this.to = target;
    this.onUpdate = (value) => {
      this.animatedScroll = value; // 記錄動畫距離
      this.content.scrollTop = this.animatedScroll; // 設(shè)置滾動
      this.targetScroll = value; // 記錄滾動后的距離
    }
  }
}

在滾動事件中調(diào)用 onVirtualScroll:

const onWeel = (e) => {
    e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
    this.onVirtualScroll(this.targetScroll + e.deltaY);
}

定義一個(gè) advance 方法在每一幀計(jì)算并執(zhí)行 onUpdate 更新視圖,不過我們現(xiàn)在還未進(jìn)行緩動計(jì)算,所以只需要把目標(biāo)位置賦值即可。

raf(time) {
  ......
  this.advance()
}
advance() {
  const value = this.to
  this.onUpdate?.(value);
}

此時(shí)頁面就可以像往常一樣滾動了,并且是不依賴系統(tǒng)默認(rèn)事件的,由 JS 代理滾動效果,接下來我們繼續(xù)往方法里處理如何平滑過渡。

線性插值實(shí)現(xiàn)阻尼感

線性插值是一種簡單的插值方法,它使用線性函數(shù)來計(jì)算過渡過程中的值。簡單來說,它是一種通過直線來連接兩個(gè)點(diǎn),在兩個(gè)點(diǎn)之間按比例計(jì)算中間的數(shù)值。線性插值可以用于各種場景,比如在圖形學(xué)中計(jì)算兩個(gè)點(diǎn)之間的中間點(diǎn),或者在動畫中實(shí)現(xiàn)平滑的過渡效果,代碼實(shí)現(xiàn):

const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 對兩個(gè)值進(jìn)行線性插值 (0 <= amt <= 1)

我們將該方法用于每一幀計(jì)算當(dāng)中,默認(rèn)差值強(qiáng)度為 0.1:

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

這樣就實(shí)現(xiàn)了一個(gè)平滑的慣性滾動效果,但實(shí)際上由于幀率是可變的(受屏幕刷新率影響),每幀之間的插值距離也會有所不同,要進(jìn)一步優(yōu)化阻尼效果還需要在線性插值的基礎(chǔ)上增加阻尼系數(shù)和時(shí)間步長,目前大部分顯示器在 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 已經(jīng)計(jì)算過了,只需要在調(diào)用時(shí)傳入 advance 當(dāng)中,單位需轉(zhuǎn)化為秒。

修改后可能你并不會感覺到有明顯的差異,如果在高刷新率的顯示器上兩者的流暢度差異就會很明顯了。關(guān)于 damp 函數(shù)的具體原理較為復(fù)雜,lenis 的作者參考了一篇2016年的文章來實(shí)現(xiàn)的,鏈接我放在了文末。

緩動函數(shù)

除了使用線性插值來實(shí)現(xiàn)平滑滾動,還可以使用常見的緩動函數(shù)來計(jì)算。

const clamp = (min, input, max) => Math.max(min, Math.min(input, max)) // 獲取一個(gè)中間值

class Silky {
  ........
  currentTime = 0 // 記錄當(dāng)前時(shí)間
  duration = 0 // 滾動動畫的持續(xù)時(shí)間
  ........
  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 表示一個(gè)從 0 到 1 的線性進(jìn)度值,通過代入緩動函數(shù)計(jì)算得出 easedProgress 緩動進(jìn)度,最后將緩動進(jìn)度乘以起始值和目標(biāo)值之間的差,加上起始值而得到當(dāng)前幀應(yīng)該推進(jìn)的值。

不同的緩動函數(shù)會有不同的效果,可以傳入不同的 easing 函數(shù)來改變。

// 緩入緩出函數(shù)(ease-in-out)慢快慢
let easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
// 指數(shù)反向緩動函數(shù)(easeOut)先快后慢
let easing = (t) => 1 - Math.pow(1 - t, 2)

例子

以上代碼核心的部分就都已經(jīng)實(shí)現(xiàn)了,除 lenis 官方的演示 Demo 外,本文也舉兩個(gè)應(yīng)用慣性滾動的例子看看實(shí)際效果如何。

視頻滾動

在該例子中我使用了 scrolly-video 這個(gè)庫,它能將視頻每一幀解析繪制到 Canvas 上,然后基于滾動控制進(jìn)度,實(shí)現(xiàn)效果如下:

Gif 圖幀率有限,可以前往在線體驗(yàn)效果,視頻加載需要一點(diǎn)時(shí)間。

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

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

年終總結(jié)

去年我做了一個(gè)掘金 2022 年終總結(jié)網(wǎng)頁,采用的是滾動控制動畫的交互,但效果在鼠標(biāo)操作時(shí)體驗(yàn)并不好,之前的卡頓感強(qiáng)烈,動畫細(xì)節(jié)也容易丟失:

現(xiàn)在加上這個(gè)慣性滾動,體驗(yàn)明顯就好很多了,在線查看演示:https://code.juejin.cn/pen/7178839138609659959

完整代碼

下面貼出文章的完整代碼,整個(gè) demo 的代碼差不多 50 行左右:

const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 對兩個(gè)值進(jìn)行線性插值 (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)) // 獲取一個(gè)中間值

class Silky {
  timeRecord = 0 // 回調(diào)時(shí)間記錄
  targetScroll = 0 // 當(dāng)前滾動位置
  animatedScroll = 0 // 動畫滾動位置
  from = 0 // 記錄起始位置
  to = 0 // 記錄目標(biāo)位置
  lerp // 插值強(qiáng)度 0~1
  currentTime = 0 // 記錄當(dāng)前時(shí)間
  duration = 0 // 滾動動畫的持續(xù)時(shí)間

  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(); // 阻止默認(rèn)事件,停止?jié)L動
      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; // 設(shè)置滾動
      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);

實(shí)例化接收參數(shù)說明:

當(dāng)然這只是最基礎(chǔ)的例子,缺少一些邊界處理等,如在實(shí)際生產(chǎn)項(xiàng)目中使用,推薦安裝前面提到的 lenis 這個(gè)庫,它擁有更完善的功能,基礎(chǔ)使用方法和本例是一樣的。

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

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

參考資料

lenis 開源地址: https://github.com/studio-freight/lenis

使用 LERP 進(jìn)行幀速率獨(dú)立阻尼 FRAME RATE INDEPENDENT DAMPING USING LERP:「鏈接」

責(zé)任編輯:武曉燕 來源: 今日頭條
相關(guān)推薦

2024-03-26 10:30:37

Mybatis擴(kuò)展庫API

2022-12-20 09:09:27

ViteWebpack

2021-01-18 18:42:33

工具調(diào)優(yōu)開發(fā)

2022-03-18 13:59:46

緩存RedisCaffeine

2022-11-03 07:49:52

JS原生元素

2020-06-15 18:00:36

transformbannerJavascript

2021-05-10 20:58:11

數(shù)據(jù)庫擴(kuò)容用戶

2022-08-16 08:37:09

視頻插幀深度學(xué)習(xí)

2009-08-22 20:25:05

Ubuntu安裝VMw

2009-08-17 10:26:39

鼠標(biāo)手勢

2025-03-03 12:00:00

JavaScriptfor 循環(huán)語言

2024-05-21 10:28:51

API設(shè)計(jì)架構(gòu)

2023-03-03 17:00:00

部署Linux內(nèi)核

2009-09-02 19:11:42

C#鼠標(biāo)滾輪

2022-12-19 14:53:07

模型訓(xùn)練

2021-07-14 13:46:28

KubeVela阿里云容器

2020-07-22 15:15:28

Vue前端代碼

2025-03-10 08:44:17

點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號