Scroll,你玩明白了嘛?
1、引言
最近在實(shí)現(xiàn)列表的滾動(dòng)交互時(shí),算是被復(fù)雜的業(yè)務(wù)場(chǎng)景整得懷疑人生了。今天主要聊一下關(guān)于 scroll 的應(yīng)用:
CSS 平滑滾動(dòng)
JS 滾動(dòng)方法
區(qū)分人為滾動(dòng)和腳本滾動(dòng)
2、CSS 平滑滾動(dòng)
2.1 一行樣式改善體驗(yàn)
在一些滾動(dòng)交互比較頻繁的場(chǎng)景,我們可以通過在可滾動(dòng)容器上增加一行樣式來改善用戶體驗(yàn)。
scroll-behavior: smooth;比如說,在文檔網(wǎng)站里,我們常使用?#?來去定位到對(duì)應(yīng)的瀏覽位置。

像上面這個(gè)例子,我們首先通過?#?去錨定對(duì)應(yīng)內(nèi)容,實(shí)現(xiàn)了一個(gè) tab 切換的效果:
<div>
?<a href="#A">Aa>
?<a href="#B">Ba>
?<a href="#C">Ca>
div>
<div className="scroll-ctn">
?<div id="A" className="scroll-panel">
? ?A
?div>
?<div id="B" className="scroll-panel">
? ?B
?div>
?<div id="C" className="scroll-panel">
? ?C
?div>
div>同時(shí),為了實(shí)現(xiàn)平滑滾動(dòng),我們?cè)跐L動(dòng)容器上設(shè)置了如下的 CSS:
.scroll-ctn {
?display: block;
?width: 100%;
?height: 300px;
?overflow-y: scroll;
?scroll-behavior: smooth;
?border: 1px solid grey;
}在?scroll-behavior: smooth?的作用下,容器內(nèi)的默認(rèn)滾動(dòng)呈現(xiàn)了平滑滾動(dòng)的效果。
2.2 兼容性
IE 和 移動(dòng)端 ios 上兼容性較差,必要時(shí)需要依賴 polyfill。

2.3 注意
1、在可滾動(dòng)的容器上設(shè)置了?scroll-behavior: smooth?之后,其優(yōu)先級(jí)是高于 JS 方法的。也就是說,在 JS 中指定?behavior: auto,想要恢復(fù)立即滾動(dòng)到目標(biāo)位置的效果,將不會(huì)生效。
2、在可滾動(dòng)的容器上設(shè)置了?scroll-behavior: smooth?之后,還能夠影響到瀏覽器 Ctrl+F 的表現(xiàn),使其也呈現(xiàn)平滑滾動(dòng)的效果。
3、JS 滾動(dòng)方法
3.1 基本方法
我們熟知的原生 scroll 方法,大概有這些:
scrollTo:滾動(dòng)到目標(biāo)位置
scrollBy:相對(duì)當(dāng)前位置滾動(dòng)
scrollIntoView:讓元素滾動(dòng)到視野內(nèi)
scrollIntoViewIfNeeded:讓元素滾動(dòng)到視野內(nèi)(如果不在視野內(nèi))
以大家用得比較多的?scrollTo?為例,它有兩種調(diào)用方式:
// 第一種形式
const x = 0, y = 200;
element.scrollTo(x, y);
// 第二種形式
const options = {
?top: 200,
?left: 0,
?behavior: 'smooth'
};
element.scrollTo(options);而滾動(dòng)的行為,即方法參數(shù)中的?behavior?分為兩種:
auto:立即滾動(dòng)
smooth:平滑滾動(dòng)
除了上述的 3 個(gè) api,我們還可以通過簡(jiǎn)單粗暴的?scrollTop、?scrollLeft?去設(shè)置滾動(dòng)位置:
// 設(shè)置 container 上滾動(dòng)距離 200
container.scrollTop = 200;
// 設(shè)置 container 左滾動(dòng)距離 200
container.scrollLeft = 200;值得一提的是,?scrollTop、?scrollLeft?的兼容性很好。而且相較于其他的方法,一般不會(huì)出什么幺蛾子(后文會(huì)講到)。
3.2 應(yīng)用
自己以往需要用到滾動(dòng)的場(chǎng)景有:
組件初始化,定位到目標(biāo)位置
點(diǎn)擊當(dāng)前頁(yè)靠底部的某個(gè)元素,觸發(fā)滾動(dòng)翻頁(yè)
......
舉個(gè)例子,現(xiàn)在我希望在列表組件加載完成后,列表能夠自動(dòng)滾動(dòng)到第三個(gè)元素。
根據(jù)上面提到的我們可以用很多種方式去實(shí)現(xiàn),假設(shè)我們已經(jīng)為列表容器增加了?scroll-behavior: smooth?的樣式,然后在 useEffect hook 中去調(diào)用滾動(dòng)方法:
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(() => {
? ?// 定位到第三個(gè)
? ?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 className="App">
? ? ?<ul className="list-ctn" ref={(ref) => (listRef.current.cnt = ref)}>
? ? ? ?{listItems.map((item, index) => {
? ? ? ? ?return (
? ? ? ? ? ?<li
? ? ? ? ? ? ?className="list-item"
? ? ? ? ? ? ?ref={(ref) => (listRef.current.items[index] = ref)}
? ? ? ? ? ? ?key={item}
? ? ? ? ? ?>
? ? ? ? ? ? ?{item}
? ? ? ? ? ?li>
? ? ? ? ?);
? ? ? ?})}
? ? ?ul>
? ?div>
?);
}
上述代碼中,提到了四種方式:
容器的 scrollTop 賦值
容器的 scrollTo 方法,傳入橫縱滾動(dòng)位置
容器的 scrollTo 方法,傳入滾動(dòng)配置
元素的 scrollIntoView / scrollIntoViewIfNeeded 方法
雖然最后效果都是一樣的,但這幾種方法實(shí)際上還是有些許差異的。

3.3 scrollIntoView 的奇怪現(xiàn)象
3.3.1 頁(yè)面整體偏移
最近在過一些歷史用例的時(shí)候,遇到了這種情況:

現(xiàn)象大概就是,當(dāng)我通過按鈕,滾動(dòng)定位到聊天區(qū)域的某條消息時(shí),頁(yè)面整體發(fā)生了偏移(向上移動(dòng))。再看一眼代碼,發(fā)現(xiàn)使用的是 scrollIntoView:

因?yàn)槭堑谝淮斡龅?,所以上萬(wàn)能的 stack overflow 上逛了一圈,看到了類似的問題:scrollIntoView 導(dǎo)致頁(yè)面整體移動(dòng)?。
這個(gè)問題常常發(fā)生在哪些情況下呢?
1、頁(yè)面有 iframe 的情況下,比如說這個(gè)例子。
表現(xiàn)是當(dāng) iframe 內(nèi)的內(nèi)容發(fā)生滾動(dòng)時(shí),主頁(yè)面也發(fā)生了滾動(dòng)。這顯然和 MDN 上的描述不一致:
Element 接口的 scrollIntoView () 方法會(huì)滾動(dòng)元素的父容器,使被調(diào)用 scrollIntoView () 的元素對(duì)用戶可見。
2、直接使用?scrollIntoView()?的默認(rèn)參數(shù)
先說說?scrollIntoView()?支持什么參數(shù):
element.scrollIntoView(alignToTop); // Boolean 型參數(shù)
element.scrollIntoView(scrollIntoViewOptions); // Object 型參數(shù)(1)alignToTop
如果為?
true,元素的頂端將和其所在滾動(dòng)區(qū)的可視區(qū)域的頂端對(duì)齊。相應(yīng)的?scrollIntoViewOptions: {block: "start", inline: "nearest"}。這是這個(gè)參數(shù)的默認(rèn)值。如果為?
false,元素的底端將和其所在滾動(dòng)區(qū)的可視區(qū)域的底端對(duì)齊。相應(yīng)的?scrollIntoViewOptions: {block: "end", inline: "nearest"}。
(2)scrollIntoViewOptions
包含下列屬性:
behavior?可選定義動(dòng)畫過渡效果,?
"auto"?或?"smooth"?之一。默認(rèn)為?"auto"。block?可選定義垂直方向的對(duì)齊,?
"start",?"center",?"end", 或?"nearest"?之一。默認(rèn)為?"start"。inline?可選定義水平方向的對(duì)齊,?
"start",?"center",?"end", 或?"nearest"?之一。默認(rèn)為?"nearest"。
回到我們的問題,為什么使用默認(rèn)參數(shù),即?element.scrollIntoView(),會(huì)引發(fā)頁(yè)面偏移的問題呢?
關(guān)鍵在于?block: "start",從上面的參數(shù)說明我們了解到,默認(rèn)不傳參數(shù)的情況下,取的是?block: start,它表示 “元素頂端與所在滾動(dòng)區(qū)的可視區(qū)域頂端對(duì)齊”。但從現(xiàn)象上看,影響的不只是 “所在滾動(dòng)區(qū)” 或者 “父容器”,祖先 DOM 元素也被影響了。
由于尋覓不到?scrollIntoView?的源碼,暫時(shí)只能定位到是?start?這個(gè)默認(rèn)值在做妖。既然原生的方法有問題,我們需要采取一些別的方式來代替。
3.3.2 解決方式
1、更換參數(shù)
既然是?block: start?有問題,那咱們換一個(gè)效果就好了,這里建議使用?nearest。
element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });可能也有好奇的朋友想問,這些對(duì)齊的選項(xiàng)具體代表了什么含義?在 MDN 里面好像都沒有做特別的解釋。這里引用 stackoverflow 上的一個(gè)高贊解答,可以幫助你更好的理解。
使用?
{block: "start"},元素在其祖先的頂部對(duì)齊。使用?
{block: "center"},元素在其祖先的中間對(duì)齊。使用?
{block: "end"},元素在其祖先的底部對(duì)齊。使用?
{block: "nearest"}:
如果您當(dāng)前位于其祖先的下方,則元素在其祖先的頂部對(duì)齊。
如果您當(dāng)前位于其祖先之上,則元素在其祖先的底部對(duì)齊。
如果它已經(jīng)在視圖中,保持原樣。
2、scrollTop/scrollLeft
上文也提到 scrollTop/scrollLeft 賦值是兼容性最好的滾動(dòng)方式,我們可以利用它來代替默認(rèn)的 scrollIntoView () 的表現(xiàn)。
比如說置頂某個(gè)元素,可以定義可滾動(dòng)容器的 scrollTop 為該元素的 offsetTop:
container.scrollTop = element.offsetTop;值得一提的是,結(jié)合 CSS 的 scroll-behavior,這種賦值方式也可以實(shí)現(xiàn)平滑滾動(dòng)效果。
4、如何區(qū)分人為滾動(dòng)和腳本滾動(dòng)
4.1 背景
最近遇到這么一個(gè)需求,做一個(gè)實(shí)時(shí)高亮當(dāng)前播放內(nèi)容的字幕文稿。核心的交互是:
1、當(dāng)用戶沒有人為滾動(dòng)文稿時(shí),會(huì)保持自動(dòng)翻頁(yè)的功能
2、當(dāng)用戶人為滾動(dòng)文稿時(shí),后續(xù)將不會(huì)自動(dòng)翻頁(yè),并出現(xiàn) “回到當(dāng)前播放位置” 的按鈕
3、假如點(diǎn)擊了 “回到當(dāng)前播放位置” 的按鈕,會(huì)回到目標(biāo)位置,并恢復(fù)自動(dòng)翻頁(yè)的功能。

像上面的演示中,用戶觸發(fā)了人為滾動(dòng),之后點(diǎn)擊 “回到當(dāng)前播放位置”,觸發(fā)了腳本滾動(dòng)。
4.2 人為滾動(dòng)
怎么定義 “人為滾動(dòng)” 呢?我們所了解的人為滾動(dòng),包含:
鼠標(biāo)滾動(dòng)
鍵盤方向鍵滾動(dòng)
縮進(jìn)鍵滾動(dòng)
翻頁(yè)鍵滾動(dòng)
......
假如說,我們通過 onWheel、onKeyDown 等事件,去監(jiān)聽人為滾動(dòng),定是不能盡善盡美的。那么我們換個(gè)思路,能否去對(duì) “腳本滾動(dòng)” 下功夫?
4.3 腳本滾動(dòng)
怎么定義 “腳本滾動(dòng)”?我們將由代碼觸發(fā)的滾動(dòng),定義為 “腳本滾動(dòng)”。
我們需要用一種方式描述 “腳本滾動(dòng)”,來和 “人為滾動(dòng)” 做區(qū)分。由于它們是非此即彼的關(guān)系,那實(shí)際上我們只需要在?onScroll?這個(gè)事件上,通過一個(gè) flag 去區(qū)分即可。
流程圖如下:

而這其中唯一需要關(guān)注的點(diǎn)在于,需要通過什么方式知道,腳本滾動(dòng)結(jié)束了?
scrollTo 等原生方式,顯然沒有給我們提供回調(diào)方法,來告訴我們滾動(dòng)在什么時(shí)候結(jié)束。所以我們還是需要依賴 onScroll 去監(jiān)聽當(dāng)前的滾動(dòng)位置,來得知滾動(dòng)什么時(shí)候達(dá)到目標(biāo)位置。
所以上面的流程還要再加一步:

接下來看看代碼要怎么組織。
4.4 代碼實(shí)現(xiàn)
首先看一下我們想要實(shí)現(xiàn)的?demo:

接下來先實(shí)現(xiàn)基本的頁(yè)面結(jié)構(gòu)。
1、定義一個(gè)長(zhǎng)列表,并通過?useRef?記錄:
滾動(dòng)容器的?
ref腳本滾動(dòng)的判斷變量?
isScriptScroll當(dāng)前的滾動(dòng)位置?
scrollTop
2、接著,為滾動(dòng)容器綁定一個(gè)?onScroll?方法,在其中分別編寫人為滾動(dòng)和腳本滾動(dòng)的邏輯,并使用節(jié)流來避免頻繁觸發(fā)。
在人為滾動(dòng)和腳本滾動(dòng)的邏輯中,我們通過更新 wording 這個(gè)狀態(tài),來區(qū)分當(dāng)前處于人為滾動(dòng)還是腳本滾動(dòng)。
3、用一個(gè) button 來觸發(fā)腳本滾動(dòng),調(diào)用?listScroll?方法,傳入容器?ref,想要滾動(dòng)到的?scrollTop?以及滾動(dòng)結(jié)束后的?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("腳本滾動(dòng)中");
? ?} else {
? ? ?cacheRef.current.scrollTop = cacheRef.current.cnt.scrollTop;
? ? ?setWording("人為滾動(dòng)中");
? ?}
?}, 200);
?const scriptScroll = () => {
? ?cacheRef.current.scrollTop += 600;
? ?cacheRef.current.isScriptScroll = true;
? ?listScroll(cacheRef.current.cnt, cacheRef.current.scrollTop, () => {
? ? ?setWording("腳本滾動(dòng)結(jié)束");
? ? ?cacheRef.current.isScriptScroll = false;
? ?});
?};
?return (
? ?<div className="App">
? ? ?<button
? ? ? ?className="btn"
? ? ? ?onClick={() => {
? ? ? ? ?scriptScroll();
? ? ? ?}}
? ? ?>
? ? ? ?觸發(fā)一次腳本滾動(dòng)
? ? ?button>
? ? ?<p className="wording">當(dāng)前狀態(tài):{wording}p>
? ? ?<ul
? ? ? ?className="list-ctn"
? ? ? ?onScroll={onScroll}
? ? ? ?ref={(ref) => (cacheRef.current.cnt = ref)}
? ? ?>
? ? ? ?{scrollItems.map((item) => {
? ? ? ? ?return (
? ? ? ? ? ?<li className="list-item" key={item}>
? ? ? ? ? ? ?{item}
? ? ? ? ? ?li>
? ? ? ? ?);
? ? ? ?})}
? ? ?ul>
? ?div>
?);
}
接下來重點(diǎn)就在于?listScroll?怎么實(shí)現(xiàn)了。我們需要再去綁定一個(gè) scroll 事件,不斷去監(jiān)聽容器的 scrollTop 是否已經(jīng)達(dá)到目標(biāo)值,所以可以這么組織:
import debounce from "lodash.debounce";
/** 誤差范圍內(nèi) */
export const withErrorRange = (
?val: number,
?target: number,
?errorRange: number
) => {
?return val <= target + errorRange && val >= target - errorRange;
};
/** 列表滾動(dòng)封裝 */
export const listScroll = (
?element: HTMLElement,
?targetPos: number,
?callback?: () => void
) => {
?// 是否已成功卸載
?let unMountFlag = false;
?const { scrollHeight: listHeight } = element;
?// 避免一些邊界情況
?if (targetPos < 0 || targetPos > listHeight) {
? ?return callback?.();
?}
?// 調(diào)用滾動(dòng)方法
?element.scrollTo({
? ?top: targetPos,
? ?left: 0,
? ?behavior: "smooth"
?});
?// 沒有回調(diào)就直接返回
?if (!callback) return;
?// 如果已經(jīng)到達(dá)目標(biāo)位置了,可以先行返回
?if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();
?// 防抖處理
?const cb = debounce(() => {
? ?// 到達(dá)目標(biāo)位置了,可以返回
? ?if (withErrorRange(targetPos, element.scrollTop, 10)) {
? ? ?element.removeEventListener("scroll", cb);
? ? ?unMountFlag = true;
? ? ?return callback();
? ?}
?}, 200);
?element.addEventListener("scroll", cb, false);
?// 兜底:卸載滾動(dòng)回調(diào),避免對(duì)之后的操作產(chǎn)生影響
?setTimeout(() => {
? ?if (!unMountFlag) {
? ? ?element.removeEventListener("scroll", cb);
? ? ?callback();
? ?}
?}, 1000);
};按嚴(yán)謹(jǐn)?shù)牧鞒虂韺懙脑?,我們需要依?scroll 事件去不斷判斷 scrollTop,直至在誤差范圍內(nèi)相等。
但實(shí)際上滾動(dòng)是一個(gè)很快的過程,跟我們兜底的定時(shí)器邏輯,也就是前后腳的事情,是不是可以只保留兜底的邏輯?
而且,考慮到那些異常情況:
腳本滾動(dòng)發(fā)生異常
腳本滾動(dòng)被人為滾動(dòng)打斷
我們都得保證執(zhí)行了一次回調(diào),確保外部狀態(tài)被釋放,下一次滾動(dòng)的邏輯正常。
所以在不那么嚴(yán)格的場(chǎng)景下,上述的代碼其實(shí)可以拋棄 eventListener 的部分,只保留兜底的邏輯,進(jìn)一步簡(jiǎn)化:
/** 列表滾動(dòng)封裝 */
export const listScroll = (
?element: HTMLElement,
?targetPos: number,
?callback?: () => void
) => {
?const { scrollHeight: listHeight } = element;
?// 避免一些邊界情況
?if (targetPos < 0 || targetPos > listHeight) {
? ?return callback?.();
?}
?// 調(diào)用滾動(dòng)方法
?element.scrollTo({
? ?top: targetPos,
? ?left: 0,
? ?behavior: "smooth"
?});
?// 沒有回調(diào)就直接返回
?if (!callback) return;
?// 如果已經(jīng)到達(dá)目標(biāo)位置了,可以先行返回
?if (withErrorRange(targetPos, element.scrollTop, 10)) return callback();
?
?// 兜底:卸載滾動(dòng)回調(diào),避免對(duì)之后的操作產(chǎn)生影響
?setTimeout(() => {
? ?callback();
?}, 1000);
};當(dāng)然,這個(gè)實(shí)現(xiàn)只是一種參考,相信大家也有別的更好的思路。
5、小結(jié)
回顧整篇文章,簡(jiǎn)單介紹了關(guān)于 scroll 的一些 api 使用,原生?scrollIntoView?的坑以及區(qū)分人為滾動(dòng)和腳本滾動(dòng)的實(shí)現(xiàn)參考。
滾動(dòng),這一個(gè)看似微小的交互點(diǎn),實(shí)際上可能隱藏著不少的工作量,在往后的評(píng)估或者實(shí)踐中,需要多加重視和思考,隱藏在交互體驗(yàn)之下的復(fù)雜邏輯。
