<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Scroll,你玩明白了嘛?

          共 8683字,需瀏覽 18分鐘

           ·

          2022-05-13 23:44

          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è)高贊解答,可以幫助你更好的理解。

          1. 使用?{block: "start"},元素在其祖先的頂部對(duì)齊。

          2. 使用?{block: "center"},元素在其祖先的中間對(duì)齊。

          3. 使用?{block: "end"},元素在其祖先的底部對(duì)齊。

          4. 使用?{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ù)雜邏輯。




          瀏覽 99
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  福利一区二区导航在线 | 日韩免费无码电影 | 老司机无码视频 | 欧美性受XXX黑人XYX | 青青草在线免费观看视频 |