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

像上面這個例子,我們首先通過?#?去錨定對應(yīng)內(nèi)容,實現(xiàn)了一個 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>同時,為了實現(xiàn)平滑滾動,我們在滾動容器上設(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)滾動呈現(xiàn)了平滑滾動的效果。
2.2 兼容性
IE 和 移動端 ios 上兼容性較差,必要時需要依賴 polyfill。

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

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

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

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

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

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

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

接下來先實現(xiàn)基本的頁面結(jié)構(gòu)。
1、定義一個長列表,并通過?useRef?記錄:
滾動容器的?
ref腳本滾動的判斷變量?
isScriptScroll當(dāng)前的滾動位置?
scrollTop
2、接著,為滾動容器綁定一個?onScroll?方法,在其中分別編寫人為滾動和腳本滾動的邏輯,并使用節(jié)流來避免頻繁觸發(fā)。
在人為滾動和腳本滾動的邏輯中,我們通過更新 wording 這個狀態(tài),來區(qū)分當(dāng)前處于人為滾動還是腳本滾動。
3、用一個 button 來觸發(fā)腳本滾動,調(diào)用?listScroll?方法,傳入容器?ref,想要滾動到的?scrollTop?以及滾動結(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("腳本滾動中");
? ?} 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("腳本滾動結(jié)束");
? ? ?cacheRef.current.isScriptScroll = false;
? ?});
?};
?return (
? ?<div className="App">
? ? ?<button
? ? ? ?className="btn"
? ? ? ?onClick={() => {
? ? ? ? ?scriptScroll();
? ? ? ?}}
? ? ?>
? ? ? ?觸發(fā)一次腳本滾動
? ? ?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>
?);
}
接下來重點就在于?listScroll?怎么實現(xiàn)了。我們需要再去綁定一個 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;
};
/** 列表滾動封裝 */
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)用滾動方法
?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);
?// 兜底:卸載滾動回調(diào),避免對之后的操作產(chǎn)生影響
?setTimeout(() => {
? ?if (!unMountFlag) {
? ? ?element.removeEventListener("scroll", cb);
? ? ?callback();
? ?}
?}, 1000);
};按嚴(yán)謹(jǐn)?shù)牧鞒虂韺懙脑?,我們需要依?scroll 事件去不斷判斷 scrollTop,直至在誤差范圍內(nèi)相等。
但實際上滾動是一個很快的過程,跟我們兜底的定時器邏輯,也就是前后腳的事情,是不是可以只保留兜底的邏輯?
而且,考慮到那些異常情況:
腳本滾動發(fā)生異常
腳本滾動被人為滾動打斷
我們都得保證執(zhí)行了一次回調(diào),確保外部狀態(tài)被釋放,下一次滾動的邏輯正常。
所以在不那么嚴(yán)格的場景下,上述的代碼其實可以拋棄 eventListener 的部分,只保留兜底的邏輯,進一步簡化:
/** 列表滾動封裝 */
export const listScroll = (
?element: HTMLElement,
?targetPos: number,
?callback?: () => void
) => {
?const { scrollHeight: listHeight } = element;
?// 避免一些邊界情況
?if (targetPos < 0 || targetPos > listHeight) {
? ?return callback?.();
?}
?// 調(diào)用滾動方法
?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();
?
?// 兜底:卸載滾動回調(diào),避免對之后的操作產(chǎn)生影響
?setTimeout(() => {
? ?callback();
?}, 1000);
};當(dāng)然,這個實現(xiàn)只是一種參考,相信大家也有別的更好的思路。
5、小結(jié)
回顧整篇文章,簡單介紹了關(guān)于 scroll 的一些 api 使用,原生?scrollIntoView?的坑以及區(qū)分人為滾動和腳本滾動的實現(xiàn)參考。
滾動,這一個看似微小的交互點,實際上可能隱藏著不少的工作量,在往后的評估或者實踐中,需要多加重視和思考,隱藏在交互體驗之下的復(fù)雜邏輯。
?? 謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了?分享、點贊、收藏?三連哦~。
歡迎關(guān)注公眾號?趣談前端?收獲大廠一手好文章~
LowCode可視化低代碼社區(qū)介紹
LowCode低代碼社區(qū)(http://lowcode.dooring.cn)是由在一線互聯(lián)網(wǎng)公司深耕技術(shù)多年的技術(shù)專家創(chuàng)辦,意在為企業(yè)技術(shù)人員提供低代碼可視化相關(guān)的技術(shù)交流和分享,并且鼓勵國內(nèi)擁有相關(guān)業(yè)務(wù)的企業(yè)積極推薦自身產(chǎn)品,為國內(nèi)B端技術(shù)領(lǐng)域積累知識資產(chǎn)。同時我們還歡迎開源大牛們分享自己的開源項目和技術(shù)視頻。
如需入駐請加下方小編微信:?lowcode-dooring

