Scroll,你玩明白了嘛?
1、引言
最近在實現列表的滾動交互時,算是被複雜的業務場景整得懷疑人生了。今天主要聊一下關於 scroll 的應用:
-
CSS 平滑滾動
-
JS 滾動方法
-
區分人爲滾動和腳本滾動
2、CSS 平滑滾動
2.1 一行樣式改善體驗
在一些滾動交互比較頻繁的場景,我們可以通過在可滾動容器上增加一行樣式來改善用戶體驗。
scroll-behavior: smooth;
比如說,在文檔網站裏,我們常使用 #
來去定位到對應的瀏覽位置。
像上面這個例子,我們首先通過 #
去錨定對應內容,實現了一個 tab 切換的效果:
<div>
<a href="#A">A</a>
<a href="#B">B</a>
<a href="#C">C</a>
</div>
<div class>
<div id="A" class>
A
</div>
<div id="B" class>
B
</div>
<div id="C" class>
C
</div>
</div>
同時,爲了實現平滑滾動,我們在滾動容器上設置瞭如下的 CSS:
.scroll-ctn {
display: block;
width: 100%;
height: 300px;
overflow-y: scroll;
scroll-behavior: smooth;
border: 1px solid grey;
}
在 scroll-behavior: smooth
的作用下,容器內的默認滾動呈現了平滑滾動的效果。
2.2 兼容性
IE 和 移動端 ios 上兼容性較差,必要時需要依賴 polyfill。
2.3 注意
1、在可滾動的容器上設置了 scroll-behavior: smooth
之後,其優先級是高於 JS 方法的。也就是說,在 JS 中指定 behavior: auto
,想要恢復立即滾動到目標位置的效果,將不會生效。
2、在可滾動的容器上設置了 scroll-behavior: smooth
之後,還能夠影響到瀏覽器 Ctrl+F 的表現,使其也呈現平滑滾動的效果。
3、JS 滾動方法
3.1 基本方法
我們熟知的原生 scroll 方法,大概有這些:
-
scrollTo:滾動到目標位置
-
scrollBy:相對當前位置滾動
-
scrollIntoView:讓元素滾動到視野內
-
scrollIntoViewIfNeeded:讓元素滾動到視野內(如果不在視野內)
以大家用得比較多的 scrollTo
爲例,它有兩種調用方式:
// 第一種形式
const x = 0, y = 200;
element.scrollTo(x, y);
// 第二種形式
const options = {
top: 200,
left: 0,
behavior: 'smooth'
};
element.scrollTo(options);
而滾動的行爲,即方法參數中的 behavior
分爲兩種:
-
auto:立即滾動
-
smooth:平滑滾動
除了上述的 3 個 api,我們還可以通過簡單粗暴的 scrollTop
、 scrollLeft
去設置滾動位置:
// 設置 container 上滾動距離 200
container.scrollTop = 200;
// 設置 container 左滾動距離 200
container.scrollLeft = 200;
值得一提的是, scrollTop
、 scrollLeft
的兼容性很好。而且相較於其他的方法,一般不會出什麼幺蛾子(後文會講到)。
3.2 應用
自己以往需要用到滾動的場景有:
-
組件初始化,定位到目標位置
-
點擊當前頁靠底部的某個元素,觸發滾動翻頁
-
......
舉個例子,現在我希望在列表組件加載完成後,列表能夠自動滾動到第三個元素。
根據上面提到的我們可以用很多種方式去實現,假設我們已經爲列表容器增加了 scroll-behavior: smooth
的樣式,然後在 useEffect hook 中去調用滾動方法:
import React, { useEffect, useRef } from "react";
import "./styles.css";
export default function App() {
const listRef = useRef({ cnt: undefined, items: [] });
const listItems = ["A", "B", "C", "D"];
useEffect(() => {
// 定位到第三個
const { cnt, items } = listRef.current;
// 第一種
// cnt.scrollTop = items[2].offsetTop;
// 第二種
// cnt.scrollTo(0, items[2].offsetTop);
// 第三種
// cnt.scrollTo({ top: items[2].offsetTop, left: 0, behavior: "smooth" });
// 第四種
items[2].scrollIntoView();
// items[2].scrollIntoViewIfNeeded();Ï
}, []);
return (
<div class>
<ul class ref={(ref) => (listRef.current.cnt = ref)}>
{listItems.map((item, index) => {
return (
<li
class
ref={(ref) => (listRef.current.items[index] = ref)}
key={item}
>
{item}
</li>
);
})}
</ul>
</div>
);
}
上述代碼中,提到了四種方式:
-
容器的 scrollTop 賦值
-
容器的 scrollTo 方法,傳入橫縱滾動位置
-
容器的 scrollTo 方法,傳入滾動配置
-
元素的 scrollIntoView / scrollIntoViewIfNeeded 方法
雖然最後效果都是一樣的,但這幾種方法實際上還是有些許差異的。
3.3 scrollIntoView 的奇怪現象
3.3.1 頁面整體偏移
最近在過一些歷史用例的時候,遇到了這種情況:
現象大概就是,當我通過按鈕,滾動定位到聊天區域的某條消息時,頁面整體發生了偏移(向上移動)。再看一眼代碼,發現使用的是 scrollIntoView:
因爲是第一次遇到,所以上萬能的 stack overflow 上逛了一圈,看到了類似的問題:scrollIntoView 導致頁面整體移動 。
這個問題常常發生在哪些情況下呢?
1、頁面有 iframe 的情況下,比如說這個例子。
表現是當 iframe 內的內容發生滾動時,主頁面也發生了滾動。這顯然和 MDN 上的描述不一致:
Element 接口的 scrollIntoView () 方法會滾動元素的父容器,使被調用 scrollIntoView () 的元素對用戶可見。
2、直接使用 scrollIntoView()
的默認參數
先說說 scrollIntoView()
支持什麼參數:
element.scrollIntoView(alignToTop); // Boolean 型參數
element.scrollIntoView(scrollIntoViewOptions); // Object 型參數
(1)alignToTop
-
如果爲
true
,元素的頂端將和其所在滾動區的可視區域的頂端對齊。相應的scrollIntoViewOptions: {block: "start", inline: "nearest"}
。這是這個參數的默認值。 -
如果爲
false
,元素的底端將和其所在滾動區的可視區域的底端對齊。相應的scrollIntoViewOptions: {block: "end", inline: "nearest"}
。
(2)scrollIntoViewOptions
包含下列屬性:
-
behavior
可選定義動畫過渡效果,
"auto"
或"smooth"
之一。默認爲"auto"
。 -
block
可選定義垂直方向的對齊,
"start"
,"center"
,"end"
, 或"nearest"
之一。默認爲"start"
。 -
inline
可選定義水平方向的對齊,
"start"
,"center"
,"end"
, 或"nearest"
之一。默認爲"nearest"
。
回到我們的問題,爲什麼使用默認參數,即 element.scrollIntoView()
,會引發頁面偏移的問題呢?
關鍵在於 block: "start"
,從上面的參數說明我們瞭解到,默認不傳參數的情況下,取的是 block: start
,它表示 “元素頂端與所在滾動區的可視區域頂端對齊”。但從現象上看,影響的不只是 “所在滾動區” 或者 “父容器”,祖先 DOM 元素也被影響了。
由於尋覓不到 scrollIntoView
的源碼,暫時只能定位到是 start
這個默認值在做妖。既然原生的方法有問題,我們需要採取一些別的方式來代替。
3.3.2 解決方式
1、更換參數
既然是 block: start
有問題,那咱們換一個效果就好了,這裏建議使用 nearest
。
element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
可能也有好奇的朋友想問,這些對齊的選項具體代表了什麼含義?在 MDN 裏面好像都沒有做特別的解釋。這裏引用 stackoverflow 上的一個高贊解答,可以幫助你更好的理解。
使用
{block: "start"}
,元素在其祖先的頂部對齊。使用
{block: "center"}
,元素在其祖先的中間對齊。使用
{block: "end"}
,元素在其祖先的底部對齊。使用
{block: "nearest"}
:
如果您當前位於其祖先的下方,則元素在其祖先的頂部對齊。
如果您當前位於其祖先之上,則元素在其祖先的底部對齊。
如果它已經在視圖中,保持原樣。
2、scrollTop/scrollLeft
上文也提到 scrollTop/scrollLeft 賦值是兼容性最好的滾動方式,我們可以利用它來代替默認的 scrollIntoView () 的表現。
比如說置頂某個元素,可以定義可滾動容器的 scrollTop 爲該元素的 offsetTop:
container.scrollTop = element.offsetTop;
值得一提的是,結合 CSS 的 scroll-behavior,這種賦值方式也可以實現平滑滾動效果。
4、如何區分人爲滾動和腳本滾動
4.1 背景
最近遇到這麼一個需求,做一個實時高亮當前播放內容的字幕文稿。核心的交互是:
1、當用戶沒有人爲滾動文稿時,會保持自動翻頁的功能
2、當用戶人爲滾動文稿時,後續將不會自動翻頁,並出現 “回到當前播放位置” 的按鈕
3、假如點擊了 “回到當前播放位置” 的按鈕,會回到目標位置,並恢復自動翻頁的功能。
像上面的演示中,用戶觸發了人爲滾動,之後點擊 “回到當前播放位置”,觸發了腳本滾動。
4.2 人爲滾動
怎麼定義 “人爲滾動” 呢?我們所瞭解的人爲滾動,包含:
-
鼠標滾動
-
鍵盤方向鍵滾動
-
縮進鍵滾動
-
翻頁鍵滾動
-
......
假如說,我們通過 onWheel、onKeyDown 等事件,去監聽人爲滾動,定是不能盡善盡美的。那麼我們換個思路,能否去對 “腳本滾動” 下功夫?
4.3 腳本滾動
怎麼定義 “腳本滾動”?我們將由代碼觸發的滾動,定義爲 “腳本滾動”。
我們需要用一種方式描述 “腳本滾動”,來和 “人爲滾動” 做區分。由於它們是非此即彼的關係,那實際上我們只需要在 onScroll
這個事件上,通過一個 flag 去區分即可。
流程圖如下:
而這其中唯一需要關注的點在於,需要通過什麼方式知道,腳本滾動結束了?
scrollTo 等原生方式,顯然沒有給我們提供回調方法,來告訴我們滾動在什麼時候結束。所以我們還是需要依賴 onScroll 去監聽當前的滾動位置,來得知滾動什麼時候達到目標位置。
所以上面的流程還要再加一步:
接下來看看代碼要怎麼組織。
4.4 代碼實現
首先看一下我們想要實現的 demo:
接下來先實現基本的頁面結構。
1、定義一個長列表,並通過 useRef
記錄:
-
滾動容器的
ref
-
腳本滾動的判斷變量
isScriptScroll
-
當前的滾動位置
scrollTop
2、接着,爲滾動容器綁定一個 onScroll
方法,在其中分別編寫人爲滾動和腳本滾動的邏輯,並使用節流來避免頻繁觸發。
在人爲滾動和腳本滾動的邏輯中,我們通過更新 wording 這個狀態,來區分當前處於人爲滾動還是腳本滾動。
3、用一個 button 來觸發腳本滾動,調用 listScroll
方法,傳入容器 ref
,想要滾動到的 scrollTop
以及滾動結束後的 callback
方法。
如下:
import throttle from "lodash.throttle";
import React, { useRef, useState } from "react";
import { listScroll } from "./utils";
import "./styles.css";
const scrollItems = new Array(1000).fill(0).map((item, index) => {
return index + 1;
});
export default function App() {
const [wording, setWording] = useState("等待中");
const cacheRef = useRef({
isScriptScroll: false,
cnt: null,
scrollTop: 0
});
const onScroll = throttle(() => {
if (cacheRef.current.isScriptScroll) {
setWording("腳本滾動中");
} else {
cacheRef.current.scrollTop = cacheRef.current.cnt.scrollTop;
setWording("人爲滾動中");
}
}, 200);
const scriptScroll = () => {
cacheRef.current.scrollTop += 600;
cacheRef.current.isScriptScroll = true;
listScroll(cacheRef.current.cnt, cacheRef.current.scrollTop, () => {
setWording("腳本滾動結束");
cacheRef.current.isScriptScroll = false;
});
};
return (
<div class>
<button
class
onClick={() => {
scriptScroll();
}}
>
觸發一次腳本滾動
</button>
<p class>當前狀態:{wording}</p>
<ul
class
onScroll={onScroll}
ref={(ref) => (cacheRef.current.cnt = ref)}
>
{scrollItems.map((item) => {
return (
<li class key={item}>
{item}
</li>
);
})}
</ul>
</div>
);
}
接下來重點就在於 listScroll
怎麼實現了。我們需要再去綁定一個 scroll 事件,不斷去監聽容器的 scrollTop 是否已經達到目標值,所以可以這麼組織:
import debounce from "lodash.debounce";
/** 誤差範圍內 */
export const withErrorRange = (
val: number,
target: number,
errorRange: number
) => {
return val <= target + errorRange && val >= target - errorRange;
};
/** 列表滾動封裝 */
export const listScroll = (
element: HTMLElement,
targetPos: number,
callback?: () => void
) => {
// 是否已成功卸載
let unMountFlag = false;
const { scrollHeight: listHeight } = element;
// 避免一些邊界情況
if (targetPos < 0 || targetPos > listHeight) {
return callback?.();
}
// 調用滾動方法
element.scrollTo({
top: targetPos,
left: 0,
behavior: "smooth"
});
// 沒有回調就直接返回
if (!callback) return;
// 如果已經到達目標位置了,可以先行返回
if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();
// 防抖處理
const cb = debounce(() => {
// 到達目標位置了,可以返回
if (withErrorRange(targetPos, element.scrollTop, 10)) {
element.removeEventListener("scroll", cb);
unMountFlag = true;
return callback();
}
}, 200);
element.addEventListener("scroll", cb, false);
// 兜底:卸載滾動回調,避免對之後的操作產生影響
setTimeout(() => {
if (!unMountFlag) {
element.removeEventListener("scroll", cb);
callback();
}
}, 1000);
};
按嚴謹的流程來寫的話,我們需要依靠 scroll 事件去不斷判斷 scrollTop,直至在誤差範圍內相等。
但實際上滾動是一個很快的過程,跟我們兜底的定時器邏輯,也就是前後腳的事情,是不是可以只保留兜底的邏輯?
而且,考慮到那些異常情況:
-
腳本滾動發生異常
-
腳本滾動被人爲滾動打斷
我們都得保證執行了一次回調,確保外部狀態被釋放,下一次滾動的邏輯正常。
所以在不那麼嚴格的場景下,上述的代碼其實可以拋棄 eventListener 的部分,只保留兜底的邏輯,進一步簡化:
/** 列表滾動封裝 */
export const listScroll = (
element: HTMLElement,
targetPos: number,
callback?: () => void
) => {
const { scrollHeight: listHeight } = element;
// 避免一些邊界情況
if (targetPos < 0 || targetPos > listHeight) {
return callback?.();
}
// 調用滾動方法
element.scrollTo({
top: targetPos,
left: 0,
behavior: "smooth"
});
// 沒有回調就直接返回
if (!callback) return;
// 如果已經到達目標位置了,可以先行返回
if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();
// 兜底:卸載滾動回調,避免對之後的操作產生影響
setTimeout(() => {
callback();
}, 1000);
};
當然,這個實現只是一種參考,相信大家也有別的更好的思路。
5、小結
回顧整篇文章,簡單介紹了關於 scroll 的一些 api 使用,原生 scrollIntoView
的坑以及區分人爲滾動和腳本滾動的實現參考。
滾動,這一個看似微小的交互點,實際上可能隱藏着不少的工作量,在往後的評估或者實踐中,需要多加重視和思考,隱藏在交互體驗之下的複雜邏輯。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/EhD8YIh8yAGRgXcibeEFsw