欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

基于React實(shí)現(xiàn)虛擬滾動(dòng)的方案詳解

 更新時(shí)間:2025年03月11日 10:58:20   作者:WindRunnerMax  
這篇文章將以固定高度和非固定高度兩種場(chǎng)景展開React中虛擬滾動(dòng)的實(shí)現(xiàn),文中的示例代碼講解詳細(xì),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下

在渲染列表時(shí)我們通常會(huì)一次性將所有列表項(xiàng)渲染到DOM中,在數(shù)據(jù)量大的時(shí)候這種操作會(huì)造成頁(yè)面響應(yīng)緩慢,因?yàn)闉g覽器需要處理大量的DOM元素。而此時(shí)我們通常就需要虛擬滾動(dòng)來(lái)實(shí)現(xiàn)性能優(yōu)化,當(dāng)我們擁有大量數(shù)據(jù)需要在用戶界面中以列表或表格的形式展示時(shí),這種性能優(yōu)化方式可以大幅改善用戶體驗(yàn)和應(yīng)用性能,那么在本文中就以固定高度和非固定高度兩種場(chǎng)景展開虛擬滾動(dòng)的實(shí)現(xiàn)。

1.描述

實(shí)現(xiàn)虛擬滾動(dòng)通常并不是非常復(fù)雜的事情,但是我們需要考慮到很多細(xì)節(jié)問(wèn)題。在具體實(shí)現(xiàn)之前我思考了一個(gè)比較有意思的事情,為什么虛擬滾動(dòng)能夠優(yōu)化性能。我們?cè)跒g覽器中進(jìn)行DOM操作的時(shí)候,此時(shí)這個(gè)DOM是真正存在的嗎,或者說(shuō)我們?cè)?code>PC上實(shí)現(xiàn)窗口管理的時(shí)候,這個(gè)窗口是真的存在的嗎。那么答案實(shí)際上很明確,這些視圖、窗口、DOM等等都是通過(guò)圖形化模擬出來(lái)的,雖然我們可以通過(guò)系統(tǒng)或者瀏覽器提供的API來(lái)非常簡(jiǎn)單地實(shí)現(xiàn)各種操作,但是實(shí)際上些內(nèi)容是系統(tǒng)幫我們繪制出來(lái)的圖像,本質(zhì)上還是通過(guò)外部輸入設(shè)備產(chǎn)生各種事件信號(hào),從而產(chǎn)生狀態(tài)與行為模擬,諸如碰撞檢測(cè)等等都是系統(tǒng)通過(guò)大量計(jì)算表現(xiàn)出的狀態(tài)而已。

那么緊接著,在前段時(shí)間我想學(xué)習(xí)下Canvas的基本操作,于是我實(shí)現(xiàn)了一個(gè)非?;A(chǔ)的圖形編輯器引擎。因?yàn)樵跒g覽器的Canvas只提供了最基本的圖形操作,沒(méi)有那么方便的DOM操作從而所有的交互事件都需要通過(guò)鼠標(biāo)與鍵盤事件自行模擬,這其中有一個(gè)非常重要的點(diǎn)是判斷兩個(gè)圖形是否相交,從而決定是否需要按需重新繪制這個(gè)圖形來(lái)提升性能。

那么我們?cè)O(shè)想一下,最簡(jiǎn)單的判斷方式就是遍歷一遍所有圖形,從而判斷是否與即將要刷新的圖形相交,那么這其中就可能涉及比較復(fù)雜的計(jì)算,而如果我們能夠提前判斷某些圖形是不可能相交的話,就能夠省去很多不必要的計(jì)算。那么在視口外的圖層就是類似的情況,如果我們能夠確定這個(gè)圖形是視口外的,我們就不需要判斷其相交性,而且本身其也不需要渲染,那么虛擬滾動(dòng)也是一樣,如果我們能夠減少DOM的數(shù)量就能夠減少很多計(jì)算,從而提升整個(gè)頁(yè)面的運(yùn)行時(shí)性能,至于首屏性能就自不必多說(shuō),減少了DOM數(shù)量首屏的繪制一定會(huì)變快。

當(dāng)然上邊只是我對(duì)于提升頁(yè)面交互或者說(shuō)運(yùn)行時(shí)性能的思考,實(shí)際上關(guān)于虛擬滾動(dòng)優(yōu)化性能的點(diǎn)在社區(qū)上有很多討論了。諸如減少DOM數(shù)量可以減少瀏覽器需要渲染和維持的DOM元素?cái)?shù)量,進(jìn)而內(nèi)存占用也隨之減少,這使得瀏覽器可以更快地響應(yīng)用戶操作。以及瀏覽器的reflow和重繪repaint操作通常是需要大量計(jì)算的,并且隨著DOM元素的增多而變得更加頻繁和復(fù)雜,通過(guò)虛擬滾動(dòng)個(gè)減少需要管理的DOM數(shù)量,同樣可顯著提高渲染性能。此

外虛擬滾動(dòng)還有更快的首屏渲染時(shí)間,特別是超大列表的全量渲染很容易導(dǎo)致首屏渲染時(shí)間過(guò)長(zhǎng),還能夠減少React維護(hù)組件狀態(tài)所帶來(lái)的Js性能消耗,特別是在存在Context的情況下,不特別關(guān)注就可能會(huì)存在性能劣化問(wèn)題。

文中會(huì)提到4種虛擬滾動(dòng)的實(shí)現(xiàn)方式,分別有固定高度的OnScroll實(shí)現(xiàn)和不定高度的IntersectionObserver+OnScroll實(shí)現(xiàn),相關(guān)DEMO都在https://github.com/WindrunnerMax/webpack-simple-environment/tree/react-virtual-list中。

2.固定高度

實(shí)際上關(guān)于虛擬滾動(dòng)的方案在社區(qū)有很多參考,特別是固定高度的虛擬滾動(dòng)實(shí)際上可以做成非常通用的解決方案。那么在這里我們以ArcoDesignList組件為例來(lái)研究一下通用的虛擬滾動(dòng)實(shí)現(xiàn)。在Arco給予的示例中我們可以看到其傳遞了height屬性,此時(shí)如果我們將這個(gè)屬性刪除的話虛擬滾動(dòng)是無(wú)法正常啟動(dòng)的。

那么實(shí)際上Arco就是通過(guò)列表元素的數(shù)量與每個(gè)元素的高度,從而計(jì)算出了整個(gè)容器的高度,這里要注意滾動(dòng)容器實(shí)際上應(yīng)該是虛擬滾動(dòng)的容器外的元素,而對(duì)于視口內(nèi)的區(qū)域則可以通過(guò)transform: translateY(Npx)來(lái)做實(shí)際偏移。當(dāng)我們滾動(dòng)的時(shí)候,我們需要通過(guò)滾動(dòng)條的實(shí)際滾動(dòng)距離以及滾動(dòng)容器的高度,配合我們配置的元素實(shí)際高度,就可以計(jì)算出來(lái)當(dāng)前視口實(shí)際需要渲染的節(jié)點(diǎn),而其他的節(jié)點(diǎn)并不實(shí)際渲染,從而實(shí)現(xiàn)虛擬滾動(dòng)。當(dāng)然實(shí)際上關(guān)于Arco虛擬滾動(dòng)的配置還有很多,在這里就不完整展開了。

<List
  {/* ... */}
  virtualListProps={{
    height: 560,
  }}
  {/* ... */}
/>

那么我們可以先來(lái)設(shè)想一下,當(dāng)我們有了每個(gè)元素的高度以及元素?cái)?shù)量,很明顯我們就可以計(jì)算出容器的高度了,當(dāng)我們有了容器的高度,此時(shí)滾動(dòng)容器的子元素就可以得到,此時(shí)我們就可以得到擁有滾動(dòng)條的滾動(dòng)容器了。

// packages/fixed-height-scroll/src/index.tsx
// ...
const totalHeight = useMemo(() => itemHeight * list.length, [itemHeight, list.length]);
// ...
<div
  style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
  onScroll={onScroll.run}
  ref={setScroll}
>
  {scroll && (
    <div style={{ height: totalHeight, position: "relative", overflow: "hidden" }}>
      {/* ... */}
    </div>
  )}
</div>

那么既然滾動(dòng)容器已經(jīng)有了,我們現(xiàn)在就需要關(guān)注于我們即將要展示的列表元素,因?yàn)槲覀兪谴嬖跐L動(dòng)條且實(shí)際有滾動(dòng)偏移的,所以我們的滾動(dòng)條位置需要鎖定在我們的視口位置上。我們只需要使用scrollTop / itemHeight取整即可,并且這里我們使用translateY來(lái)做整體偏移,使用translate還可以觸發(fā)硬件加速。那么除了列表的整體偏移之外,我們還需要計(jì)算出當(dāng)前視口內(nèi)的元素?cái)?shù)量,這里的計(jì)算同樣非常簡(jiǎn)單,因?yàn)槲覀兊母叨裙潭耍藭r(shí)只需要跟滾動(dòng)容器相除即可,實(shí)際上這部分在實(shí)例化組件的時(shí)候就已經(jīng)完成了。

useEffect(() => {
  if (!scroll) return void 0;
  setLen(Math.ceil(scroll.clientHeight / itemHeight));
}, [itemHeight, scroll]);

const onScroll = useThrottleFn(
  () => {
    const containerElement = container.current;
    if (!scroll || !containerElement) return void 0;
    const scrollTop = scroll.scrollTop;
    const newIndex = Math.floor(scrollTop / itemHeight);
    containerElement.style.transform = `translateY(${newIndex * itemHeight}px)`;
    setIndex(newIndex);
  },
  { wait: 17 }
);

3.動(dòng)態(tài)高度

固定高度的虛擬滾動(dòng)是比較適用于通用的場(chǎng)景的,實(shí)際上此處的固定高度不一定是指元素的高度是固定的,而是指元素的高度是可以直接計(jì)算得到而不是必須要渲染之后才能得到,例如圖片的寬高是可以在上傳時(shí)保存,然后在渲染時(shí)通過(guò)圖片寬高以及容器寬度計(jì)算得到的。然而實(shí)際上我們有很多場(chǎng)景下并不臺(tái)能夠完全做到元素的固定高度,例如在線文檔場(chǎng)景下的富文本編輯器中,特別是文本塊的高度,在不同的字體、瀏覽器寬度等情況下表現(xiàn)是不同的。

我們無(wú)法在其渲染之前的到其高度,這就導(dǎo)致了我們無(wú)法像圖片一樣提前計(jì)算出其占位高度,從而對(duì)于文檔塊結(jié)構(gòu)的虛擬滾動(dòng)就必須要解決塊高度不固定的問(wèn)題,由此我們需要實(shí)現(xiàn)動(dòng)態(tài)高度的虛擬滾動(dòng)調(diào)度策略來(lái)處理這個(gè)問(wèn)題。

3.1IntersectionObserver占位符

如果我們需要判斷元素是否出現(xiàn)在視口當(dāng)中時(shí),通常會(huì)監(jiān)聽(tīng)onScroll事件用來(lái)判斷元素實(shí)際位置,而現(xiàn)如今絕大多數(shù)瀏覽器都提供了IntersectionObserver原生對(duì)象,用以異步地觀察目標(biāo)元素與其祖先元素或頂級(jí)文檔視口的交叉狀態(tài),這對(duì)判斷元素是否出現(xiàn)在視口范圍非常有用,那么同樣的,我們也可以借助IntersectionObserver來(lái)實(shí)現(xiàn)虛擬滾動(dòng)。

需要注意的是,IntersectionObserver對(duì)象的應(yīng)用場(chǎng)景是觀察目標(biāo)元素與視口的交叉狀態(tài),而我們的虛擬滾動(dòng)核心概念是不渲染非視口區(qū)域的元素,所以這里邊實(shí)際上出現(xiàn)了一個(gè)偏差,在虛擬滾動(dòng)中目標(biāo)元素都不存在或者說(shuō)并未渲染,那么此時(shí)是無(wú)法觀察其狀態(tài)的。所以為了配合IntersectionObserver的概念,我們需要渲染實(shí)際的占位符,例如10k個(gè)列表的節(jié)點(diǎn),我們首先就需要渲染10k個(gè)占位符,實(shí)際上這也是一件合理的事,除非我們最開始就注意到列表的性能問(wèn)題,而實(shí)際上大部分都是后期優(yōu)化頁(yè)面性能,特別是在復(fù)雜的場(chǎng)景下例如文檔中,所以假設(shè)原本有1w條數(shù)據(jù),每條數(shù)據(jù)即使僅渲染3個(gè)節(jié)點(diǎn),那么此時(shí)我們?nèi)绻麅H渲染占位符的情況下還能將原本頁(yè)面30k個(gè)節(jié)點(diǎn)優(yōu)化到大概10k個(gè)節(jié)點(diǎn),這對(duì)于性能提升本身也是非常有意義的。

此外,在https://caniuse.com/?search=IntersectionObserver可以觀察到兼容性還是不錯(cuò)的,在瀏覽器不支持的情況下可以采用OnScroll方案或者考慮使用polyfill。那么緊接著,我們來(lái)實(shí)現(xiàn)這部分內(nèi)容,首先我們需要生成數(shù)據(jù),在這里需要注意的是我們所說(shuō)的不定高度實(shí)際上應(yīng)該是被稱為動(dòng)態(tài)高度,元素的高度是需要我們實(shí)際渲染之后才能得到的,在渲染之前我們僅以估算的高度占位,從而能夠使?jié)L動(dòng)容器產(chǎn)生滾動(dòng)效果。

// packages/dynamic-height-placeholder/src/index.tsx
const LIST = Array.from({ length: 1000 }, (_, i) => {
  const height = Math.floor(Math.random() * 30) + 60;
  return {
    id: i,
    content: (
      <div style={{ height }}>
        {i}-高度:{height}
      </div>
    ),
  };
});

接下來(lái)我們需要?jiǎng)?chuàng)建IntersectionObserver,同樣的因?yàn)槲覀兊臐L動(dòng)容器可能并不一定是window,所以我們需要在滾動(dòng)容器上創(chuàng)建IntersectionObserver,此外通常我們會(huì)對(duì)視口區(qū)域做一層buffer,用來(lái)提前加載視口外的元素,這樣可以避免用戶滾動(dòng)時(shí)出現(xiàn)空白區(qū)域,這個(gè)buffer的大小通常選擇當(dāng)前視口高度的一半。

useLayoutEffect(() => {
  if (!scroll) return void 0;
  // 視口閾值 取滾動(dòng)容器高度的一半
  const margin = scroll.clientHeight / 2;
  const current = new IntersectionObserver(onIntersect, {
    root: scroll,
    rootMargin: `${margin}px 0px`,
  });
  setObserver(current);
  return () => {
    current.disconnect();
  };
}, [onIntersect, scroll]);

接下來(lái)我們需要對(duì)占位節(jié)點(diǎn)的狀態(tài)進(jìn)行管理,因?yàn)槲覀兇藭r(shí)有實(shí)際占位,所以就不再需要預(yù)估整個(gè)容器的高度,而且只需要實(shí)際滾動(dòng)到相關(guān)位置將節(jié)點(diǎn)渲染即可。我們?yōu)楣?jié)點(diǎn)設(shè)置三個(gè)狀態(tài),loading狀態(tài)即占位狀態(tài),此時(shí)節(jié)點(diǎn)只渲染空的占位符也可以渲染一個(gè)loading標(biāo)識(shí),此時(shí)我們還不知道這個(gè)節(jié)點(diǎn)的實(shí)際高度;viewport狀態(tài)即為節(jié)點(diǎn)真實(shí)渲染狀態(tài),也就是說(shuō)節(jié)點(diǎn)在邏輯視口內(nèi),此時(shí)我們可以記錄節(jié)點(diǎn)的真實(shí)高度;placeholder狀態(tài)為渲染后的占位狀態(tài),相當(dāng)于節(jié)點(diǎn)從在視口內(nèi)滾動(dòng)到了視口外,此時(shí)節(jié)點(diǎn)的高度已經(jīng)被記錄,我們可以將節(jié)點(diǎn)的高度設(shè)置為真實(shí)高度。

loading -> viewport <-> placeholder
type NodeState = {
  mode: "loading" | "placeholder" | "viewport";
  height: number;
};

public changeStatus = (mode: NodeState["mode"], height: number): void => {
  this.setState({ mode, height: height || this.state.height });
};

render() {
  return (
    <div ref={this.ref} data-state={this.state.mode}>
      {this.state.mode === "loading" && (
        <div style={{ height: this.state.height }}>loading...</div>
      )}
      {this.state.mode === "placeholder" && <div style={{ height: this.state.height }}></div>}
      {this.state.mode === "viewport" && this.props.content}
    </div>
  );
}

當(dāng)然我們的Observer的觀察同樣需要配置,這里需要注意的是IntersectionObserver的回調(diào)函數(shù)只會(huì)攜帶target節(jié)點(diǎn)信息,我們需要通過(guò)節(jié)點(diǎn)信息找到我們實(shí)際的Node來(lái)管理節(jié)點(diǎn)狀態(tài),所以此處我們借助WeakMap來(lái)建立元素到節(jié)點(diǎn)的關(guān)系,從而方便我們處理。

export const ELEMENT_TO_NODE = new WeakMap<Element, Node>();
componentDidMount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  ELEMENT_TO_NODE.set(el, this);
  this.observer.observe(el);
}

componentWillUnmount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  ELEMENT_TO_NODE.delete(el);
  this.observer.unobserve(el);
}

最后就是實(shí)際滾動(dòng)調(diào)度了,當(dāng)節(jié)點(diǎn)出現(xiàn)在視口時(shí)我們需要根據(jù)ELEMENT_TO_NODE獲取節(jié)點(diǎn)信息,然后根據(jù)當(dāng)前視口信息來(lái)設(shè)置狀態(tài),如果當(dāng)前節(jié)點(diǎn)是進(jìn)入視口的狀態(tài)我們就將節(jié)點(diǎn)狀態(tài)設(shè)置為viewport,如果此時(shí)是出視口的狀態(tài)則需要二次判斷當(dāng)前狀態(tài),如果不是初始的loading狀態(tài)則可以直接將高度與placeholder設(shè)置到節(jié)點(diǎn)狀態(tài)上,此時(shí)節(jié)點(diǎn)的高度就是實(shí)際高度。

const onIntersect = useMemoizedFn((entries: IntersectionObserverEntry[]) => {
  entries.forEach(entry => {
    const node = ELEMENT_TO_NODE.get(entry.target);
    if (!node) {
      console.warn("Node Not Found", entry.target);
      return void 0;
    }
    const rect = entry.boundingClientRect;
    if (entry.isIntersecting || entry.intersectionRatio > 0) {
      // 進(jìn)入視口
      node.changeStatus("viewport", rect.height);
    } else {
      // 脫離視口
      if (node.state.mode !== "loading") {
        node.changeStatus("placeholder", rect.height);
      }
    }
  });
});

3.2IntersectionObserver虛擬化

在前邊我們也提到了IntersectionObserver的目標(biāo)是觀察目標(biāo)元素與視口的交叉狀態(tài),而我們的虛擬滾動(dòng)核心概念是不渲染非視口區(qū)域的元素,那么究竟能不能通過(guò)IntersectionObserver實(shí)現(xiàn)虛擬滾動(dòng)的效果,實(shí)際上是可以的,但是可能需要OnScroll來(lái)輔助節(jié)點(diǎn)的強(qiáng)制刷新。在這里我們嘗試使用標(biāo)記節(jié)點(diǎn)以及額外渲染的方式來(lái)實(shí)現(xiàn)虛擬列表,但是要注意的是,在這里因?yàn)闆](méi)有使用OnScroll來(lái)強(qiáng)制刷新節(jié)點(diǎn),當(dāng)快速滾動(dòng)的時(shí)候可能會(huì)出現(xiàn)空白的情況。

在先前的占位方案中,我們已經(jīng)實(shí)現(xiàn)了IntersectionObserver的基本操作,在這里就不再贅述了。而在這里我們的核心思路是標(biāo)記虛擬列表節(jié)點(diǎn)的首位,并且節(jié)點(diǎn)的首尾是額外渲染的,相當(dāng)于首尾節(jié)點(diǎn)是在視口外的節(jié)點(diǎn),當(dāng)首尾節(jié)點(diǎn)的狀態(tài)發(fā)生改變時(shí),我們可以通過(guò)回調(diào)函數(shù)來(lái)控制其首尾的指針?lè)秶?,從而?shí)現(xiàn)虛擬滾動(dòng)。那么在這之前,我們需要先控制好首尾指針的狀態(tài),避免出現(xiàn)負(fù)值或者越界的情況。

// packages/dynamic-height-virtualization/src/index.tsx
const setSafeStart = useMemoizedFn((next: number | ((index: number) => number)) => {
  if (typeof next === "function") {
    setStart(v => {
      const index = next(v);
      return Math.min(Math.max(0, index), list.length);
    });
  } else {
    setStart(Math.min(Math.max(0, next), list.length));
  }
});

const setSafeEnd = useMemoizedFn((next: number | ((index: number) => number)) => {
  if (typeof next === "function") {
    setEnd(v => {
      const index = next(v);
      return Math.max(Math.min(list.length, index), 1);
    });
  } else {
    setEnd(Math.max(Math.min(list.length, next), 1));
  }
});

緊接著我們還需要兩個(gè)數(shù)組,分別用來(lái)管理所有的節(jié)點(diǎn)以及節(jié)點(diǎn)的高度值,因?yàn)榇藭r(shí)我們的節(jié)點(diǎn)可能是不存在的,所以其狀態(tài)與高度需要額外的變量來(lái)管理,并且我們還需要兩個(gè)占位塊來(lái)作為首尾節(jié)點(diǎn)的占位,用來(lái)實(shí)現(xiàn)在滾動(dòng)容器中滾動(dòng)的效果。占位塊同樣需要對(duì)其進(jìn)行觀察,并且其高度就需要根據(jù)高度值的節(jié)點(diǎn)計(jì)算,當(dāng)然這部分計(jì)算寫的比較粗暴,還有很大的優(yōu)化空間,例如額外維護(hù)一個(gè)單調(diào)遞增的隊(duì)列來(lái)計(jì)算高度。

const instances: Node[] = useMemo(() => [], []);
const record = useMemo(() => {
  return Array.from({ length: list.length }, () => DEFAULT_HEIGHT);
}, [list]);

<div
  ref={startPlaceHolder}
  style={{ height: record.slice(0, start).reduce((a, b) => a + b, 0) }}
></div>
// ...
<div
  ref={endPlaceHolder}
  style={{ height: record.slice(end, record.length).reduce((a, b) => a + b, 0) }}
></div>

在節(jié)點(diǎn)渲染時(shí),我們需要標(biāo)記其狀態(tài),這里的Node節(jié)點(diǎn)的數(shù)據(jù)會(huì)變得更多,在這里主要是需要標(biāo)注isFirstNode、isLastNode兩個(gè)狀態(tài),并且initHeight需要從外部傳遞,之前也提到過(guò)了,節(jié)點(diǎn)可能不存在,此時(shí)如果再?gòu)念^加載的話高度會(huì)不正確,倒是滾動(dòng)不流暢的問(wèn)題,所以我們需要在節(jié)點(diǎn)渲染時(shí)傳遞initHeight,這個(gè)高度值就是節(jié)點(diǎn)渲染記錄的實(shí)際高度或者未渲染過(guò)的占位高度。

<Node
  scroll={scroll}
  instances={instances}
  key={item.id}
  index={item.id}
  id={item.id}
  content={item.content}
  observer={observer}
  isFirstNode={index === 0}
  initHeight={record[item.id]}
  isLastNode={index === current.length - 1}
></Node>

還有一個(gè)需要關(guān)注的問(wèn)題是視口鎖定,當(dāng)在可見(jiàn)區(qū)域之外的節(jié)點(diǎn)高度發(fā)生變化時(shí),如果不進(jìn)行視口鎖定,就會(huì)出現(xiàn)可視區(qū)域跳變的問(wèn)題。這里還需要注意的是我們不能使用smooth滾動(dòng)的動(dòng)畫表現(xiàn),如果使用動(dòng)畫的話可能會(huì)導(dǎo)致滾動(dòng)的過(guò)程中其他節(jié)點(diǎn)高度變更且視口鎖定失效的情況,此時(shí)依然會(huì)導(dǎo)致視口區(qū)域跳變,我們必須明確地指定滾動(dòng)的位置,如果實(shí)在需要?jiǎng)赢嫷脑挘瑯右残枰ㄟ^(guò)明確的數(shù)值緩慢遞增來(lái)模擬,而不是直接使用scrollTosmooth參數(shù)。

componentDidUpdate(prevProps: Readonly<NodeProps>, prevState: Readonly<NodeState>): void {
  if (prevState.mode === "loading" && this.state.mode === "viewport" && this.ref.current) {
    const rect = this.ref.current.getBoundingClientRect();
    const SCROLL_TOP = 0;
    if (rect.height !== prevState.height && rect.top < SCROLL_TOP) {
      this.scrollDeltaY(rect.height - prevState.height);
    }
  }
}

private scrollDeltaY = (deltaY: number): void => {
  const scroll = this.props.scroll;
  if (scroll instanceof Window) {
    scroll.scrollTo({ top: scroll.scrollY + deltaY });
  } else {
    scroll.scrollTop = scroll.scrollTop + deltaY;
  }
};

接下來(lái)就是重點(diǎn)的回調(diào)函數(shù)處理了,這里涉及到比較復(fù)雜的狀態(tài)管理。首先是兩個(gè)占位節(jié)點(diǎn),當(dāng)兩個(gè)占位節(jié)點(diǎn)出現(xiàn)在視口時(shí),我們認(rèn)為此時(shí)是需要加載其他節(jié)點(diǎn)的,以起始占位節(jié)點(diǎn)為例,當(dāng)其出現(xiàn)在視口時(shí),我們需要將起始指針前移,而前移的數(shù)量需要根據(jù)實(shí)際視口 交叉的范圍計(jì)算。

const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0;
if (entry.target === startPlaceHolder.current) {
  // 起始占位符進(jìn)入視口
  if (isIntersecting && entry.target.clientHeight > 0) {
    const delta = entry.intersectionRect.height || 1;
    let index = start - 1;
    let count = 0;
    let increment = 0;
    while (index >= 0 && count < delta) {
      count = count + record[index];
      increment++;
      index--;
    }
    setSafeStart(index => index - increment);
  }
  return void 0;
}
if (entry.target === endPlaceHolder.current) {
  // 結(jié)束占位符進(jìn)入視口
  if (isIntersecting && entry.target.clientHeight > 0) {
    // ....
    setSafeEnd(end => end + increment);
  }
  return void 0;
}

接下來(lái)跟占位方案一樣,我們同樣需要根據(jù)ELEMENT_TO_NODE來(lái)獲取節(jié)點(diǎn)信息,然后此時(shí)需要更新我們的高度記錄變量。由于我們?cè)?code>IntersectionObserver回調(diào)中無(wú)法判斷實(shí)際滾動(dòng)方向,也不容易判斷實(shí)際滾動(dòng)范圍,所以此時(shí)我們需要根據(jù)之前提到的isFirstNodeisLastNode信息來(lái)控制首尾游標(biāo)指針。FirstNode進(jìn)入視口認(rèn)為是向下滾動(dòng),此時(shí)需要將上方范圍的節(jié)點(diǎn)渲染出來(lái),而LastNode進(jìn)入視口認(rèn)為是向上滾動(dòng),此時(shí)需要將下方范圍的節(jié)點(diǎn)渲染出來(lái)。FirstNode脫離視口認(rèn)為是向上滾動(dòng),此時(shí)需要將上方范圍的節(jié)點(diǎn)移除,而LastNode脫離視口認(rèn)為是向下滾動(dòng),此時(shí)需要將下方范圍的節(jié)點(diǎn)移除。這里可以注意到我們?cè)黾庸?jié)點(diǎn)范圍使用的是THRESHOLD,而減少節(jié)點(diǎn)范圍使用的是1,這里就是我們需要額外渲染的首尾節(jié)點(diǎn)。

const node = ELEMENT_TO_NODE.get(entry.target);
const rect = entry.boundingClientRect;
record[node.props.index] = rect.height;
if (isIntersecting) {
  // 進(jìn)入視口
  if (node.props.isFirstNode) {
    setSafeStart(index => index - THRESHOLD);
  }
  if (node.props.isLastNode) {
    setSafeEnd(end => end + THRESHOLD);
  }
  node.changeStatus("viewport", rect.height);
} else {
  // 脫離視口
  if (node.props.isFirstNode) {
    setSafeStart(index => index + 1);
  }
  if (node.props.isLastNode) {
    setSafeEnd(end => end - 1);
  }
  if (node.state.mode !== "loading") {
    node.changeStatus("placeholder", rect.height);
  }
}

在最后,因?yàn)檫@個(gè)狀態(tài)很難控制的比較完善,我們還需要為其做兜底處理,防止頁(yè)面上遺留過(guò)多節(jié)點(diǎn)。當(dāng)然實(shí)際上即使遺留了節(jié)點(diǎn)也沒(méi)有問(wèn)題,相當(dāng)于降級(jí)到了我們上邊提到的占位方案,實(shí)際上并不會(huì)出現(xiàn)大量的節(jié)點(diǎn),相當(dāng)于在這里實(shí)現(xiàn)的是懶加載的占位節(jié)點(diǎn)。不過(guò)我們?cè)谶@里依然給予了處理方案,可以通過(guò)節(jié)點(diǎn)狀態(tài)來(lái)標(biāo)識(shí)節(jié)點(diǎn)是否是作為分界線需要實(shí)際處理為首尾游標(biāo)邊界。

public prevNode = (): Node | null => {
  return this.props.instances[this.props.index - 1] || null;
};
public nextNode = (): Node | null => {
  return this.props.instances[this.props.index + 1] || null;
};
// ...
const prev = node.prevNode();
const next = node.nextNode();
const isActualFirstNode = prev?.state.mode !== "viewport" && next?.state.mode === "viewport";
const isActualLastNode = prev?.state.mode === "viewport" && next?.state.mode !== "viewport";
if (isActualFirstNode) {
  setSafeStart(node.props.index - THRESHOLD);
}
if (isActualLastNode) {
  setSafeEnd(node.props.index + THRESHOLD);
}

3.3OnScroll滾動(dòng)事件監(jiān)聽(tīng)

那么實(shí)現(xiàn)動(dòng)態(tài)高度的虛擬滾動(dòng),我們也不能忘記常用的OnScroll方案,實(shí)際上相對(duì)于使用IntersectionObserver來(lái)說(shuō),單純的虛擬滾動(dòng)OnScroll方案更加簡(jiǎn)單,當(dāng)然同樣的也更加容易出現(xiàn)性能問(wèn)題。使用OnScroll的核心思路同樣是需要一個(gè)滾動(dòng)容器,然后我們需要監(jiān)聽(tīng)滾動(dòng)事件,當(dāng)滾動(dòng)事件觸發(fā)時(shí),我們需要根據(jù)滾動(dòng)的位置來(lái)計(jì)算當(dāng)前視口內(nèi)的節(jié)點(diǎn),然后根據(jù)節(jié)點(diǎn)的高度來(lái)計(jì)算實(shí)際需要渲染的節(jié)點(diǎn),從而實(shí)現(xiàn)虛擬滾動(dòng)。

那么動(dòng)態(tài)高度的虛擬滾動(dòng)與最開始我們實(shí)現(xiàn)的固定高度的虛擬滾動(dòng)區(qū)別在哪,首先是滾動(dòng)容器的高度,我們?cè)谧铋_始不能夠知道滾動(dòng)容器實(shí)際有多高,而是在不斷渲染的過(guò)程中才能知道實(shí)際高度;其次我們不能直接根據(jù)滾動(dòng)的高度計(jì)算出當(dāng)前需要渲染的節(jié)點(diǎn),在之前我們渲染的起始index游標(biāo)是直接根據(jù)滾動(dòng)容器高度和列表所有節(jié)點(diǎn)總高度算出來(lái)的,而在動(dòng)態(tài)高度的虛擬滾動(dòng)中,我們無(wú)法獲得總高度,同樣的渲染節(jié)點(diǎn)的長(zhǎng)度也是如此,我們無(wú)法得知本次渲染究竟需要渲染多少節(jié)點(diǎn);再有我們不容易判斷節(jié)點(diǎn)距離滾動(dòng)容器頂部的高度,也就是之前我們提到的translateY,我們需要使用這個(gè)高度來(lái)?yè)纹饾L動(dòng)的區(qū)域,從而讓我們能夠?qū)嶋H做到滾動(dòng)。

那么我們說(shuō)的這些數(shù)值都是無(wú)法計(jì)算的嘛,顯然不是這樣的,在我們沒(méi)有任何優(yōu)化的情況下,這些數(shù)據(jù)都是可以強(qiáng)行遍歷計(jì)算的,而實(shí)際上對(duì)于現(xiàn)代瀏覽器來(lái)說(shuō),執(zhí)行加法計(jì)算需要的性能消耗并不是很高,例如我們實(shí)現(xiàn)1萬(wàn)次加法運(yùn)算,實(shí)際上的時(shí)間消耗也只有不到1ms

console.time("addition time");
let count = 0;
for (let i = 0; i < 10000; i++) {
  count = count + i;
}
console.log(count);
console.timeEnd("addition time"); // 0.64306640625 ms

那么接下來(lái)我們就以遍歷的方式粗暴地計(jì)算我們所需要的數(shù)據(jù),在最后我們會(huì)聊一聊基本的優(yōu)化方案。首先我們?nèi)匀恍枰涗浉叨?,因?yàn)楣?jié)點(diǎn)并不一定會(huì)存在視圖中,所以最開始我們以基本占位高度存儲(chǔ),當(dāng)節(jié)點(diǎn)實(shí)際渲染之后,我們?cè)俑鹿?jié)點(diǎn)高度。

// packages/dynamic-height-scroll/src/index.tsx
const heightTable = useMemo(() => {
  return Array.from({ length: list.length }, () => DEFAULT_HEIGHT);
}, [list]);

componentDidMount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  const rect = el.getBoundingClientRect();
  this.props.heightTable[this.props.index] = rect.height;
}

還記得之前我們聊到的buffer嘛,在IntersectionObserver中提供了rootMargin配置來(lái)維護(hù)視口的buffer,而在OnScroll中我們需要自行維護(hù),所以在這里我們需要設(shè)置一個(gè)buffer變量,當(dāng)滾動(dòng)容器被實(shí)際創(chuàng)建之后我們來(lái)更新這個(gè)buffer的值以及滾動(dòng)容器。

const [scroll, setScroll] = useState<HTMLDivElement | null>(null);
const buffer = useRef(0);

const onUpdateInformation = (el: HTMLDivElement) => {
  if (!el) return void 0;
  buffer.current = el.clientHeight / 2;
  setScroll(el);
  Promise.resolve().then(onScroll.run);
};

return (
<div
  style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
  ref={onUpdateInformation}
>
  {/* ... */}
</div>
);

接下來(lái)我們來(lái)處理兩個(gè)占位塊,在這里沒(méi)有使用translateY來(lái)做整體偏移,而是直接使用占位塊的方式來(lái)?yè)纹饾L動(dòng)區(qū)域,那么此時(shí)我們就需要根據(jù)首尾游標(biāo)來(lái)計(jì)算具體占位,實(shí)際上這里就是之前我們說(shuō)的萬(wàn)次加法計(jì)算的時(shí)間消耗問(wèn)題,在這里我們直接遍歷計(jì)算高度。

const startPlaceHolderHeight = useMemo(() => {
  return heightTable.slice(0, start).reduce((a, b) => a + b, 0);
}, [heightTable, start]);

const endPlaceHolderHeight = useMemo(() => {
  return heightTable.slice(end, heightTable.length).reduce((a, b) => a + b, 0);
}, [end, heightTable]);

return (
  <div
    style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
    onScroll={onScroll.run}
    ref={onUpdateInformation}
  >
    <div data-index={`0-${start}`} style={{ height: startPlaceHolderHeight }}></div>
    {/* ... */}
    <div data-index={`${end}-${list.length}`} style={{ height: endPlaceHolderHeight }}></div>
  </div>
);

那么接下來(lái)就需要我們?cè)?code>OnScroll事件中處理我們需要渲染的節(jié)點(diǎn)內(nèi)容,實(shí)際上主要是處理首尾的游標(biāo)位置,對(duì)于首部游標(biāo)我們直接根據(jù)滾動(dòng)的高度來(lái)計(jì)算即可,遍歷到首個(gè)節(jié)點(diǎn)的高度大于滾動(dòng)高度時(shí),我們就可以認(rèn)為此時(shí)的游標(biāo)就是我們需要渲染的首個(gè)節(jié)點(diǎn),而對(duì)于尾部游標(biāo)我們需要根據(jù)首部游標(biāo)以及滾動(dòng)容器的高度來(lái)計(jì)算,同樣也是遍歷到超出滾動(dòng)容器高度的節(jié)點(diǎn)時(shí),我們就可以認(rèn)為此時(shí)的游標(biāo)就是我們需要渲染的尾部節(jié)點(diǎn)。當(dāng)然,在這游標(biāo)的計(jì)算中別忘了我們的buffer數(shù)據(jù),這是盡量避免滾動(dòng)時(shí)出現(xiàn)空白區(qū)域的關(guān)鍵。

const getStartIndex = (top: number) => {
  const topStart = top - buffer.current;
  let count = 0;
  let index = 0;
  while (count < topStart) {
    count = count + heightTable[index];
    index++;
  }
  return index;
};

const getEndIndex = (clientHeight: number, startIndex: number) => {
  const topEnd = clientHeight + buffer.current;
  let count = 0;
  let index = startIndex;
  while (count < topEnd) {
    count = count + heightTable[index];
    index++;
  }
  return index;
};

const onScroll = useThrottleFn(
  () => {
    if (!scroll) return void 0;
    const scrollTop = scroll.scrollTop;
    const clientHeight = scroll.clientHeight;
    const startIndex = getStartIndex(scrollTop);
    const endIndex = getEndIndex(clientHeight, startIndex);
    setStart(startIndex);
    setEnd(endIndex);
  },
  { wait: 17 }
);

因?yàn)槲蚁肓牡氖翘摂M滾動(dòng)最基本的原理,所以在這里的示例中基本沒(méi)有什么優(yōu)化,顯而易見(jiàn)的是我們對(duì)于高度的遍歷處理是比較低效的,即使進(jìn)行萬(wàn)次加法計(jì)算的消耗并不大,但是在大型應(yīng)用中還是應(yīng)該盡量避免做如此大量的計(jì)算。那么顯而易見(jiàn)的一個(gè)優(yōu)化方向是我們可以實(shí)現(xiàn)高度的緩存,簡(jiǎn)單來(lái)說(shuō)就是對(duì)于已經(jīng)計(jì)算過(guò)的高度我們可以緩存下來(lái),這樣在下次計(jì)算時(shí)就可以直接使用緩存的高度,而不需要再次遍歷計(jì)算,而出現(xiàn)高度變化需要更新時(shí),我們可以從當(dāng)前節(jié)點(diǎn)到最新的緩存節(jié)點(diǎn)之間,重新計(jì)算緩存高度。而且這種方式相當(dāng)于是遞增的有序數(shù)組,還可以通過(guò)二分等方式解決查找的問(wèn)題,這樣就可以避免大量的遍歷計(jì)算。

height: 10 20 30 40 50 60 ...
cache: 10 30 60 100 150 210 ...

以上就是基于React實(shí)現(xiàn)虛擬滾動(dòng)的方案詳解的詳細(xì)內(nèi)容,更多關(guān)于React虛擬滾動(dòng)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評(píng)論