<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>

          【W(wǎng)eb 技術(shù)】1753- 總結(jié)虛擬滾動的3種實現(xiàn)方式!

          共 31299字,需瀏覽 63分鐘

           ·

          2023-07-27 12:00

          者:豬頭切圖仔

          鏈接:https://juejin.cn/post/7232856799170805820

          前言

          工作中一直有接觸大量數(shù)據(jù)渲染的業(yè)務(wù),使用react-window多之又多,所以對虛擬列表有了些淺顯的理解。今天,我們就照著react-window的使用方式來實現(xiàn)三種虛擬列表。

          • 元素固定高度的虛擬列表
          • 元素不定高度的虛擬列表
          • 元素動態(tài)高度的虛擬列表

          虛擬列表核心原理

          我們先來看一下整個虛擬列表元素的表現(xiàn)。

          動畫.gif

          看右邊的元素個數(shù),會發(fā)現(xiàn)起初只有6個,之后無論怎么滾動,他都保持著8個元素,由此我們可以得出他的靜態(tài)原理圖是這樣的。

          static-height.png

          當我們進行了滾動后。

          static-height-scroll.png

          從上面兩圖我們可以總結(jié)出,整個虛擬列表劃分為三個區(qū)域,分別是上緩沖區(qū)(0/2個元素),可視區(qū)(n個元素),下緩沖區(qū)(2個元素)。當我們滾動到一個元素離開可視區(qū)范圍內(nèi)時,就去掉上緩沖區(qū)頂上的一個元素,然后再下緩沖區(qū)增加一個元素。這就是虛擬列表的核心原理了。

          虛擬列表的實現(xiàn)

          一、元素固定高度的虛擬列表

          使用:

          js
          復(fù)制代碼
          const Row = ({ index, style, forwardRef }) => {
            return (
              <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} ref={forwardRef}>
                {`Row ${index}`}
              </div>
            )
          }

          const App = () => {
            return (
              <FixedSizeList
                className="list"
                height={200}
                width={200}
                itemSize={50}
                itemCount={1000}
              >
                {Row}
              </FixedSizeList>
            );
          }

          實現(xiàn):

          (1)首先先計算出由1000個元素撐起的盒子(稱之為container)的高度,撐開盒子,讓用戶能進行滾動操作。

          (2)計算出可視區(qū)的起始索引、上緩沖區(qū)的起始索引以及下緩沖區(qū)的結(jié)束索引(就像上圖滾動后,上緩沖區(qū)的起始索引為2,可視區(qū)起始索引為4,下緩沖區(qū)結(jié)束索引為9)。

          (3)采用絕對定位,計算上緩沖區(qū)到下緩沖區(qū)之間的每一個元素在contianer中的top值,只有知道top值才能讓元素出現(xiàn)在可視區(qū)內(nèi)。

          static-height-scroll-over.png

          (4)將上緩沖區(qū)到下緩沖區(qū)的元素塞到container中。

          js
          復(fù)制代碼
          import { useState } from 'react';

          const FixedSizeList = (props) => {
            const { height, width, itemSize, itemCount, children: Child } = props;
            // 記錄滾動掉的高度
            const [scrollOffset, setScrollOffset] = useState(0);

            // 外部容器高度
            const containerStyle = {
              position: 'relative',
              width,
              height,
              overflow: 'auto',
            };

            // 1000個元素撐起盒子的實際高度
            const contentStyle = {
              height: itemSize * itemCount,
              width: '100%',
            };
              
            const getCurrentChildren = () => {
              // 可視區(qū)起始索引
              const startIndex = Math.floor(scrollOffset / itemSize);
              // 上緩沖區(qū)起始索引
              const finialStartIndex = Math.max(0, startIndex - 2);
              // 可視區(qū)能展示的元素的最大個數(shù)
              const numVisible = Math.ceil(height / itemSize);
              // 下緩沖區(qū)結(jié)束索引
              const endIndex = Math.min(itemCount - 1, startIndex + numVisible + 2);
              const items = [];
              // 根據(jù)上面計算的索引值,不斷添加元素給container
              for (let i = finialStartIndex; i < endIndex; i++) {
                const itemStyle = {
                  position: 'absolute',
                  height: itemSize,
                  width: '100%',
                  // 計算每個元素在container中的top值
                  top: itemSize * i,
                };
                items.push(
                  <Child key={i} index={i} style={itemStyle} />
                );
              }
              return items;
            }

            // 當觸發(fā)滾動就重新計算
            const scrollHandle = (event) => {
              const { scrollTop } = event.currentTarget;
              setScrollOffset(scrollTop);
            }

            return (
              <div style={containerStyle} onScroll={scrollHandle}>
                 <div style={contentStyle}>
                    {getCurrentChildren()}
                 </div>
              </div>
            );
          };

          export default FixedSizeList;

          結(jié)果

          動畫.gif

          二、元素不定高度的虛擬列表

          使用:

          js
          復(fù)制代碼
          const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 55))
          const getItemSize = (index) => rowSizes[index];

          const Row = ({ index, style }) => {
            return (
              <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} >
                Row {index}
              </div>
            )
          }

          const App = () => {
            return (
              <VariableSizeList
                className="list"
                height={200}
                width={200}
                itemSize={getItemSize}
                itemCount={1000}
              >
                {Row}
              </VariableSizeList>
            );
          }

          從代碼可以看出,Row每一個高度都是隨機的,就不能像第一種虛擬列表那樣簡單得通過itemSize * index計算出top值了。

          思路

          難點一:

          由于每個元素高度不一,我們起先無法直接計算出container的總高度。

          難點二:

          每個元素高度不一,每個元素的top值不能通過itemSize * index直接計算出top值。

          難點三:

          每個元素高度不一,不能直接通過scrollOffset / itemSize計算出已被滾動掉的元素的個數(shù),很難獲取到可視區(qū)的起始索引。

          難點一的解決方案

          可以通過遍歷所有的Row計算出總高度,但我認為計算出精確總高度的必要性不大,同時也為了兼容第三種虛擬列表,我們不去計算精確的總高度。現(xiàn)在我們回到出發(fā)點,思考container的高度的作用是什么?其實就是為了足夠大,讓用戶能進行滾動操作,那我們可以自己假設(shè)每一個元素的高度,在乘上個數(shù),弄出一個假的但足夠高的container讓用戶去觸發(fā)滾動事件。當然這種方案會帶來一些小bug(這個bug的影響大,我認為是可以忽略的)。

          難點二和難點三的解決方案

          其實難點二和難點三本質(zhì)都一樣,元素高度不一,導(dǎo)致不知道被滾動掉了多少元素,只要知道被滾動掉的元素的個數(shù),top值和索引都迎刃而解。

          我們可以采用這種解決方案,那就是每次只計算需要上緩沖區(qū)到下緩沖區(qū)之間的元素,并記錄他們,并且記錄下最底下的那個元素的索引,當用戶進行滾動時,如果我們是向上滾動,就可以直接從已經(jīng)計算好的記錄里取,如果向下滾動,我們根據(jù)上一次記錄的最大的索引的那個元素不斷累加新元素的高度,直到它大于已經(jīng)滾動掉的高度,此時的索引值就是可視區(qū)的起始索引了,這個起始索引所對應(yīng)的top就是累加的高度。

          文字看起來生硬拗口,我們可以看下面這張圖。

          unstable-height.png

          每一個元素的top值都能通過上一個元素的top值 + 上一個元素的height計算出來。

          舉個例子,假設(shè)我們需要知道item14的top值

          (1)我們先在記錄里找有沒有item13的數(shù)據(jù),如果有,我們就拿item13.top + item13.heighht得到item14的top。

          (2)如果記錄中(由上圖得知我們只記錄了item1-item10的數(shù)據(jù))沒有,我們就拿到記錄中最后一個元素的數(shù)據(jù)(item10)進行累加,先計算并記錄item11的,再計算并記錄item12的,再計算并記錄item13的,最后就是item14的了。

          實現(xiàn)

          js
          復(fù)制代碼
          import { useState } from 'react';

          // 元數(shù)據(jù)
          const measuredData = {
            measuredDataMap: {},
            LastMeasuredItemIndex: -1,
          };

          const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
            let measuredHeight = 0;
            const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
            // 計算已經(jīng)獲取過真實高度的項的高度之和
            if (LastMeasuredItemIndex >= 0) {
              const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
              measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
            }
            // 未計算過真實高度的項數(shù)
            const unMeasuredItemsCount = itemCount - measuredData.LastMeasuredItemIndex - 1;
            // 預(yù)測總高度
            const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
            return totalEstimatedHeight;
          }

          const getItemMetaData = (props, index) => {
            const { itemSize } = props;
            const { measuredDataMap, LastMeasuredItemIndex } = measuredData;
            // 如果當前索引比已記錄的索引要大,說明要計算當前索引的項的size和offset
            if (index > LastMeasuredItemIndex) {
              let offset = 0;
              // 計算當前能計算出來的最大offset值
              if (LastMeasuredItemIndex >= 0) {
                const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
                offset += lastMeasuredItem.offset + lastMeasuredItem.size;
              }
              // 計算直到index為止,所有未計算過的項
              for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
                const currentItemSize = itemSize(i);
                measuredDataMap[i] = { size: currentItemSize, offset };
                offset += currentItemSize;
              }
              // 更新已計算的項的索引值
              measuredData.LastMeasuredItemIndex = index;
            }
            return measuredDataMap[index];
          };

          const getStartIndex = (props, scrollOffset) => {
            let index = 0;
            while (true) {
              const currentOffset = getItemMetaData(props, index).offset;
              if (currentOffset >= scrollOffset) return index;
              index++
            }
          }

          const getEndIndex = (props, startIndex) => {
            const { height } = props;
            // 獲取可視區(qū)內(nèi)開始的項
            const startItem = getItemMetaData(props, startIndex);
            // 可視區(qū)內(nèi)最大的offset值
            const maxOffset = startItem.offset + height;
            // 開始項的下一項的offset,之后不斷累加此offset,知道等于或超過最大offset,就是找到結(jié)束索引了
            let offset = startItem.offset + startItem.size;
            // 結(jié)束索引
            let endIndex = startIndex;
            // 累加offset
            while (offset <= maxOffset) {
              endIndex++;
              const currentItem = getItemMetaData(props, endIndex);
              offset += currentItem.size;
            }
            return endIndex;
          };

          const getRangeToRender = (props, scrollOffset) => {
            const { itemCount } = props;
            const startIndex = getStartIndex(props, scrollOffset);
            const endIndex = getEndIndex(props, startIndex);
            return [
              Math.max(0, startIndex - 2),
              Math.min(itemCount - 1, endIndex + 2),
              startIndex,
              endIndex,
            ];
          };

          const VariableSizeList = (props) => {
            const { height, width, itemCount, itemEstimatedSize, children: Child } = props;
            const [scrollOffset, setScrollOffset] = useState(0);

            const containerStyle = {
              position: 'relative',
              width,
              height,
              overflow: 'auto',
              willChange: 'transform'
            };

            const contentStyle = {
              height: estimatedHeight(itemEstimatedSize, itemCount),
              width: '100%',
            };
              
            const getCurrentChildren = () => {
              const [startIndex, endIndex, originStartIndex, originEndIndex] = getRangeToRender(props, scrollOffset)
              const items = [];
              for (let i = startIndex; i < endIndex; i++) {
                const item = getItemMetaData(props, i);
                const itemStyle = {
                  position: 'absolute',
                  height: item.size,
                  width: '100%',
                  top: item.offset,
                };
                items.push(
                  <Child key={i} index={i} style={itemStyle} />
                );
              }
              return items;
            }

            const scrollHandle = (event) => {
              const { scrollTop } = event.currentTarget;
              setScrollOffset(scrollTop);
            }

            return (
              <div style={containerStyle} onScroll={scrollHandle}>
                <div style={contentStyle}>
                  {getCurrentChildren()}
                </div>
              </div>
            );
          };

          export default VariableSizeList;

          難點的地方都給了注釋,如果一遍看不懂的話,可以去調(diào)試調(diào)試。

          以上代碼主要寫了個思路和功能,其實優(yōu)化點是很多的,這里給出兩個顯而易見的優(yōu)化點。

          • 緩存每一個已經(jīng)計算完成的item的樣式,這樣回滾的時候不用重新計算樣式。
          • getStartIndex可以通過二分法去優(yōu)化。

          結(jié)果

          動畫.gif

          結(jié)果還是挺滿意的了,這里提一下上文提到的小bug,那就是在向下拉動滾動條時,鼠標和滾動條時脫節(jié)的。

          元素動態(tài)高度的虛擬列表

          最后這一種虛擬列表其實就是基于第二種來實現(xiàn)的,只不過增加監(jiān)聽元素高度變化事件,在某個元素發(fā)生變化的時候重新計算各種數(shù)據(jù)。

          使用

          js
          復(fù)制代碼
          const items = [];
          const itemCount = 1000;
          for (let i = 0; i < itemCount; i++) {
              const height = (30 + Math.floor(Math.random() * 30));
              const style = {
                  height,
                  width: '100%',
              }
              items.push(
                  <div className={i % 2 ? 'list-item-odd' : 'list-item-even'} style={style}>Row {i}</div>
              )
          }

          const Row = ({ index }) => items[index];

          const App = () => {
              // 注意:這里我沒有把itemSize傳過去
              return (
                  <VariableSizeList
                    className="list"
                    height={200}
                    width={200}
                    itemCount={itemCount}
                    isDynamic
                  >
                      {Row}
                  </VariableSizeList>
              );
          }

          從上面代碼可以看出,我們沒將itemSize傳過去,虛擬列表是不知道每一個元素的高度的,只有在渲染的時候執(zhí)行了Row才知道。

          實現(xiàn)

          在上面那種虛擬列表進行改動

          js
          復(fù)制代碼
          // 修改getCurrentChildren函數(shù)
          const getCurrentChildren = () => {
            const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
            const items = [];
            for (let i = startIndex; i <= endIndex; i++) {
              const item = getItemMetaData(props, i);
              const itemStyle = {
                position: 'absolute',
                height: item.size,
                width: '100%',
                top: item.offset,
              };
              items.push(
                <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />
              );
            }
            return items;
          }
          // 增加sizeChangeHandle
          const sizeChangeHandle = (index, domNode) => {
            const height = domNode.offsetHeight;
            const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
            const itemMetaData = measuredDataMap[index];
            itemMetaData.size = height;
            let offset = 0;
            for (let i = 0; i <= lastMeasuredItemIndex; i++) {
              const itemMetaData = measuredDataMap[i];
              itemMetaData.offset = offset;
              offset += itemMetaData.size;
            }
            setState({});
          }
          js
          復(fù)制代碼
          // 增加一個ListItem組件
          class ListItem extends React.Component {
            constructor(props) {
              super(props);
              this.domRef = React.createRef();
              this.resizeObserver = null;
            }
            componentDidMount() {
              if (this.domRef.current) {
                const domNode = this.domRef.current.firstChild;
                const { index, onSizeChange } = this.props;
                this.resizeObserver = new ResizeObserver(() => {
                  onSizeChange(index, domNode);
                });
                this.resizeObserver.observe(domNode);
              }
            }
            componentWillUnmount() {
              if (this.resizeObserver && this.domRef.current.firstChild) {
                this.resizeObserver.unobserve(this.domRef.current.firstChild);
              }
            }
            render() {
              const { index, style, ComponentType } = this.props;
              return (
                <div style={style} ref={this.domRef}>
                  <ComponentType index={index} />
                </div>
              )
            }
          }

          完整代碼

          js
          復(fù)制代碼
          import React, { useState } from 'react';

          // 元數(shù)據(jù)
          const measuredData = {
            measuredDataMap: {},
            lastMeasuredItemIndex: -1,
          };

          const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
            let measuredHeight = 0;
            const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
            // 計算已經(jīng)獲取過真實高度的項的高度之和
            if (lastMeasuredItemIndex >= 0) {
              const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
              measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
            }
            // 未計算過真實高度的項數(shù)
            const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
            // 預(yù)測總高度
            const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
            return totalEstimatedHeight;
          }

          const getItemMetaData = (props, index) => {
            const { itemSize } = props;
            const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
            // 如果當前索引比已記錄的索引要大,說明要計算當前索引的項的size和offset
            if (index > lastMeasuredItemIndex) {
              let offset = 0;
              // 計算當前能計算出來的最大offset值
              if (lastMeasuredItemIndex > 0) {
                const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
                offset += lastMeasuredItem.offset + lastMeasuredItem.size;
              }
              // 計算直到index為止,所有未計算過的項
              for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
                const currentItemSize = itemSize ? itemSize(i) : 50;
                measuredDataMap[i] = { size: currentItemSize, offset };
                offset += currentItemSize;
              }
              // 更新已計算的項的索引值
              measuredData.lastMeasuredItemIndex = index;
            }
            return measuredDataMap[index];
          };

          const getStartIndex = (props, scrollOffset) => {
            let index = 0;
            while (true) {
              const currentOffset = getItemMetaData(props, index).offset;
              if (currentOffset >= scrollOffset) return index;
              index++
            }
          }

          const getEndIndex = (props, startIndex) => {
            const { height } = props;
            // 獲取可視區(qū)內(nèi)開始的項
            const startItem = getItemMetaData(props, startIndex);
            // 可視區(qū)內(nèi)最大的offset值
            const maxOffset = startItem.offset + height;
            // 開始項的下一項的offset,之后不斷累加此offset,知道等于或超過最大offset,就是找到結(jié)束索引了
            let offset = startItem.offset + startItem.size;
            // 結(jié)束索引
            let endIndex = startIndex;
            // 累加offset
            while (offset <= maxOffset) {
              endIndex++;
              const currentItem = getItemMetaData(props, endIndex);
              offset += currentItem.size;
            }
            return endIndex;
          };

          const getRangeToRender = (props, scrollOffset) => {
            const { itemCount } = props;
            const startIndex = getStartIndex(props, scrollOffset);
            const endIndex = getEndIndex(props, startIndex);
            return [
              Math.max(0, startIndex - 2),
              Math.min(itemCount - 1, endIndex + 2),
              startIndex,
              endIndex,
            ];
          };

          class ListItem extends React.Component {
            constructor(props) {
              super(props);
              this.domRef = React.createRef();
              this.resizeObserver = null;
            }
            componentDidMount() {
              if (this.domRef.current) {
                const domNode = this.domRef.current.firstChild;
                const { index, onSizeChange } = this.props;
                this.resizeObserver = new ResizeObserver(() => {
                  onSizeChange(index, domNode);
                });
                this.resizeObserver.observe(domNode);
              }
            }
            componentWillUnmount() {
              if (this.resizeObserver && this.domRef.current.firstChild) {
                this.resizeObserver.unobserve(this.domRef.current.firstChild);
              }
            }
            render() {
              const { index, style, ComponentType } = this.props;
              return (
                <div style={style} ref={this.domRef}>
                  <ComponentType index={index} />
                </div>
              )
            }
          }

          const VariableSizeList = (props) => {
            const { height, width, itemCount, itemEstimatedSize, children: Child } = props;
            const [scrollOffset, setScrollOffset] = useState(0);
            const [, setState] = useState({});

            const containerStyle = {
              position: 'relative',
              width,
              height,
              overflow: 'auto',
              willChange: 'transform'
            };

            const contentStyle = {
              height: estimatedHeight(itemEstimatedSize, itemCount),
              width: '100%',
            };

            const sizeChangeHandle = (index, domNode) => {
              const height = domNode.offsetHeight;
              const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
              const itemMetaData = measuredDataMap[index];
              itemMetaData.size = height;
              let offset = 0;
              for (let i = 0; i <= lastMeasuredItemIndex; i++) {
                const itemMetaData = measuredDataMap[i];
                itemMetaData.offset = offset;
                offset += itemMetaData.size;
              }
              setState({});
            }
              
            const getCurrentChildren = () => {
              const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
              const items = [];
              for (let i = startIndex; i <= endIndex; i++) {
                const item = getItemMetaData(props, i);
                const itemStyle = {
                  position: 'absolute',
                  height: item.size,
                  width: '100%',
                  top: item.offset,
                };
                items.push(
                  <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />
                );
              }
              return items;
            }

            const scrollHandle = (event) => {
              const { scrollTop } = event.currentTarget;
              setScrollOffset(scrollTop);
            }

            return (
              <div style={containerStyle} onScroll={scrollHandle}>
                <div style={contentStyle}>
                  {getCurrentChildren()}
                </div>
              </div>
            );
          };

          export default VariableSizeList;

          結(jié)果

          動畫.gif

          結(jié)尾

          react-window只有前兩種虛擬列表,最后一種虛擬列表是在別的虛擬列表庫中有,借鑒了一下各路大佬的思路實現(xiàn)的,總得來說三種虛擬列表雖然表現(xiàn)和實現(xiàn)都不同,但只要掌握了核心原理,手擼出來虛擬列表還是手到擒來的。

          最后,希望這篇文章能幫助到各位讀者。同時也非常歡迎各位大佬對上面的各種實現(xiàn)提出建議,也希望各位大佬對于第二種虛擬列表提出更多的優(yōu)化點。

             

          往期回顧

          #

          如何使用 TypeScript 開發(fā) React 函數(shù)式組件?

          #

          11 個需要避免的 React 錯誤用法

          #

          6 個 Vue3 開發(fā)必備的 VSCode 插件

          #

          3 款非常實用的 Node.js 版本管理工具

          #

          6 個你必須明白 Vue3 的 ref 和 reactive 問題

          #

          6 個意想不到的 JavaScript 問題

          #

          試著換個角度理解低代碼平臺設(shè)計的本質(zhì)

          回復(fù)“加群”,一起學習進步

          瀏覽 457
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <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>
                  小黄片在线免费看 | 黄色高清网站 | 欧美经典怡红院肏肥屄淫荡视频在线观看 | 夜夜爽天天操 | 五月色综合网 |