如何實現一個高度自適應的虛擬列表

近期在某平臺開發迭代的過程當中遇到了超長List嵌套在antd Modal里加載慢,卡頓的狀況。因而心血來潮決定從零本身實現一個虛擬滾動列表來優化一下總體的體驗。node

改造前:

咱們能夠看出來在改造以前,打開編輯窗口Modal的時候會出現短暫的卡頓,而且在點擊Cancel關閉後也並非當即響應而是稍做遲疑以後才關閉的typescript

改造後:

改造完成後咱們能夠觀察到整個Modal的打開比以前變得流暢了很多,能夠作到當即響應用戶的點擊事件喚起/關閉Modal數組

0x0 基礎知識

因此什麼是虛擬滾動/列表呢?緩存

一個虛擬列表是指當咱們有成千上萬條數據須要進行展現可是用戶的「視窗」(一次性可見內容)又不大時咱們能夠經過巧妙的方法只渲染用戶最大可見條數+「BufferSize」個元素並在用戶進行滾動時動態更新每一個元素中的內容從而達到一個和長list滾動同樣的效果但花費很是少的資源。markdown

(從上圖中咱們能夠發現實際用戶每次能看到的元素/內容只有item-4 ~ item-13 也就是9個元素)antd

0x1 實現一個「定高」虛擬列表

  • 首先咱們須要定義幾個變量/名稱。函數

    • 從上圖中咱們能夠看出來用戶實際可見區域的開始元素是Item-4,因此他在數據數組中對應的下標也就是咱們的startIndex
    • 同理Item-13對應的數組下標則應該是咱們的endIndex
    • 因此Item-1,Item-2和Item-3則是被用戶的向上滑動操做所隱藏,因此咱們稱它爲startOffset(scrollTop)

由於咱們只對可視區域的內容作了渲染,因此爲了保持整個容器的行爲和一個長列表類似(滾動)咱們必須保持原列表的高度,因此咱們將HTML結構設計成以下oop

<!--ver 1.0 -->
<div className="vListContainer">
  <div className="phantomContent">
    ...
    <!-- item-1 -->
    <!-- item-2 -->
    <!-- item-3 -->
    ....
  </div>
</div>
複製代碼
  • 其中:
    • vListContainer 爲可視區域的容器,具備 overflow-y: auto 屬性。
    • phantom 中的每條數據都應該具備 position: absolute 屬性
    • phantomContent 則是咱們的「幻影」部分,其主要目的是爲了還原真實List的內容高度從而模擬正常長列表滾動的行爲。
  • 接着咱們對 vListContainer 綁定一個onScroll的響應函數,並在函數中根據原生滾動事件的scrollTop 屬性來計算咱們的 startIndexendIndex性能

    • 在開始計算以前,咱們先要定義幾個數值:
      • 咱們須要一個固定的列表元素高度:rowHeight
      • 咱們須要知道當前list一共有多少條數據: total
      • 咱們須要知道當前用戶可視區域的高度: height
    • 在有了上述數據以後咱們能夠經過計算得出下列數據:
      • 列表總高度: phantomHeight = total * rowHeight
      • 可視範圍內展現元素數:limit = Math.ceil(height/rowHeight)

(注意此處咱們用的是向上取整)優化

  • 因此咱們能夠在onScroll 回調中進行下列計算:
onScroll(evt: any) {
  // 判斷是不是咱們須要響應的滾動事件
  if (evt.target === this.scrollingContainer.current) {
    const { scrollTop } = evt.target;
    const { startIndex, total, rowHeight, limit } = this;

    // 計算當前startIndex
    const currentStartIndex = Math.floor(scrollTop / rowHeight);

    // 若是currentStartIndex 和 startIndex 不一樣(咱們須要更新數據了)
    if (currentStartIndex !== startIndex ) {
      this.startIndex = currentStartIndex;
      this.endIndex = Math.min(currentStartIndedx + limit, total - 1);
      this.setState({ scrollTop });
    }
  }
}
複製代碼
  • 當咱們一旦有了startIndex 和 endIndex 咱們就能夠渲染其對應的數據:
renderDisplayContent = () => {
  const { rowHeight, startIndex, endIndex } = this;
  const content = [];
  
  // 注意這塊咱們用了 <= 是爲了渲染x+1個元素用來在讓滾動變得連續(永遠渲染在判斷&渲染x+2)
  for (let i = startIndex; i <= endIndex; ++i) {
    // rowRenderer 是用戶定義的列表元素渲染方法,須要接收一個 index i 和
    // 當前位置對應的style
    content.push(
      rowRenderer({
        index: i, 
        style: {
          width: '100%',
          height: rowHeight + 'px',
          position: "absolute",
          left: 0,
          right: 0,
          top: i * rowHeight,
          borderBottom: "1px solid #000",
        }
      })
    );
  }
  
  return content;
};
複製代碼

線上Demo:codesandbox.io/s/a-naive-v…

原理:

  • 因此這個滾動效果到底是怎麼實現的呢?首先咱們在vListContainer中渲染了一個真實list高度的「幻影」容器從而容許用戶進行滾動操做。其次咱們監聽了onScroll事件,而且在每次用戶觸發滾動是動態計算當前滾動Offset(被滾上去隱藏了多少)所對應的開始下標(index)是多少。當咱們發現新的下邊和咱們當前展現的下標不一樣時進行賦值而且setState觸發重繪。當用戶當前的滾動offset未觸發下標更新時,則由於自己phantom的長度關係讓虛擬列表擁有和普通列表同樣的滾動能力。當觸發重繪時由於咱們計算的是startIndex 因此用戶感知不到頁面的重繪(由於當前滾動的下一幀和咱們重繪完的內容是一致的)。

優化:

  • 對於上邊咱們實現的虛擬列表,你們不難發現一但進行了快速滑動就會出現列表閃爍的現象/來不及渲染、空白的現象。還記得咱們一開始說的 **渲染用戶最大可見條數+「BufferSize」 麼?對於咱們渲染的實際內容,咱們能夠對其上下加入Buffer的概念(即上下多渲染一些元素用來過渡快速滑動時來不及渲染的問題)。優化後的onScroll 函數以下:
onScroll(evt: any) {
  ........
  // 計算當前startIndex
  const currentStartIndex = Math.floor(scrollTop / rowHeight);
    
  // 若是currentStartIndex 和 startIndex 不一樣(咱們須要更新數據了)
  if (currentStartIndex !== originStartIdx) {
    // 注意,此處咱們引入了一個新的變量叫originStartIdx,起到了和以前startIndex
    // 相同的效果,記錄當前的 真實 開始下標。
    this.originStartIdx = currentStartIndex;
    // 對 startIndex 進行 頭部 緩衝區 計算
    this.startIndex = Math.max(this.originStartIdx - bufferSize, 0);
    // 對 endIndex 進行 尾部 緩衝區 計算
    this.endIndex = Math.min(
      this.originStartIdx + this.limit + bufferSize,
      total - 1
    );

    this.setState({ scrollTop: scrollTop });
  }
}
複製代碼

線上Demo:codesandbox.io/s/A-better-…

0x2 列表元素高度自適應

如今咱們已經實現了「定高」元素的虛擬列表的實現,那麼若是說碰到了高度不固定的超長列表的業務場景呢?

  • 通常碰到不定高列表元素時有三種虛擬列表實現方式:
  1. 對輸入數據進行更改,傳入每個元素對應的高度 dynamicHeight[i] = x x 爲元素i 的行高

    須要實現知道每個元素的高度(不切實際)

  2. 將當前元素先在屏外進行繪製並對齊高度進行測量後再將其渲染到用戶可視區域內

    這種方法至關於雙倍渲染消耗(不切實際)

  3. 傳入一個estimateHeight 屬性先對行高進行估計並渲染,而後渲染完成後得到真實行高並進行更新和緩存

    會引入多餘的transform(能夠接受),會在後邊講爲何須要多餘的transform...

  • 讓咱們暫時先回到 HTML 部分
<!--ver 1.0 -->
<div className="vListContainer"> <div className="phantomContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>


<!--ver 1.1 -->
<div className="vListContainer"> <div className="phantomContent" /> <div className="actualContent"> ... <!-- item-1 --> <!-- item-2 --> <!-- item-3 --> .... </div> </div>
複製代碼
  • 在咱們實現 「定高」 虛擬列表時,咱們是採用了把元素渲染在phantomContent 容器裏,而且經過設置每個item的positionabsolute 加上定義top 屬性等於 i * rowHeight 來實現不管怎麼滾動,渲染內容始終是在用戶的可視範圍內的。在列表高度不能肯定的狀況下,咱們就沒法準確的經過estimateHeight 來計算出當前元素所處的y位置,因此咱們須要一個容器來幫咱們作這個絕對定位。

  • actualContent 則是咱們新引入的列表內容渲染容器,經過在此容器上設置position: absolute 屬性來避免在每一個item上設置。

  • 有一點不一樣的是,由於咱們改用actualContent 容器。當咱們進行滑動時須要動態的對容器的位置進行一個 y-transform 從而實現容器永遠處於用戶的視窗之中:

getTransform() {
  const { scrollTop } = this.state;
  const { rowHeight, bufferSize, originStartIdx } = this;

  // 當前滑動offset - 當前被截斷的(沒有徹底消失的元素)距離 - 頭部緩衝區距離
  return `translate3d(0,${ scrollTop - (scrollTop % rowHeight) - Math.min(originStartIdx, bufferSize) * rowHeight }px,0)`;

}
複製代碼

線上Demo:codesandbox.io/s/a-v-list-…

(注:當沒有高度自適應要求時且沒有實現cell複用時,把元素經過absolute渲染在phantom裏會比經過transform的性能要好一些。由於每次渲染content時都會進行重排,可是若是使用transform時就至關於進行了( 重排 + transform) > 重排)

  • 回到列表元素高度自適應這個問題上來,如今咱們有了一個能夠在內部進行正常block排布的元素渲染容器(actualContent ),咱們如今就能夠直接在不給定高度的狀況下先把內容都渲染進去。對於以前咱們須要用rowHeight 作高度計算的地方,咱們統一替換成estimateHeight 進行計算。

    • limit = Math.ceil(height / estimateHeight)
    • phantomHeight = total * estimateHeight
  • 同時爲了不重複計算每個元素渲染後的高度(getBoundingClientReact().height) 咱們須要一個數組來存儲這些高度

interface CachedPosition {
  index: number;         // 當前pos對應的元素的下標
  top: number;           // 頂部位置
  bottom: number;        // 底部位置
  height: number;        // 元素高度
  dValue: number;        // 高度是否和以前(estimate)存在不一樣
}

cachedPositions: CachedPosition[] = [];

// 初始化cachedPositions
initCachedPositions = () => {
  const { estimatedRowHeight } = this;
  this.cachedPositions = [];
  for (let i = 0; i < this.total; ++i) {
    this.cachedPositions[i] = {
      index: i,
      height: estimatedRowHeight,             // 先使用estimateHeight估計
      top: i * estimatedRowHeight,            // 同上
      bottom: (i + 1) * estimatedRowHeight,   // same above
      dValue: 0,
    };
  }
};
複製代碼
  • 當咱們計算完(初始化完) cachedPositions 以後因爲咱們計算了每個元素的top和bottom,因此phantom 的高度就是cachedPositions 中最後一個元素的bottom值
this.phantomHeight = this.cachedPositions[cachedPositionsLen - 1].bottom;
複製代碼
  • 當咱們根據estimateHeight 渲染完用戶視窗內的元素後,咱們須要對渲染出來的元素作實際高度更新,此時咱們能夠利用componentDidUpdate 生命週期鉤子來計算、判斷和更新:
componentDidUpdate() {
  ......
  // actualContentRef必須存在current (已經渲染出來) + total 必須 > 0
  if (this.actualContentRef.current && this.total > 0) {
    this.updateCachedPositions();
  }
}

updateCachedPositions = () => {
  // update cached item height
  const nodes: NodeListOf<any> = this.actualContentRef.current.childNodes;
  const start = nodes[0];

  // calculate height diff for each visible node...
  nodes.forEach((node: HTMLDivElement) => {
    if (!node) {
      // scroll too fast?...
      return;
    }
    const rect = node.getBoundingClientRect();
    const { height } = rect;
    const index = Number(node.id.split('-')[1]);
    const oldHeight = this.cachedPositions[index].height;
    const dValue = oldHeight - height;

    if (dValue) {
      this.cachedPositions[index].bottom -= dValue;
      this.cachedPositions[index].height = height;
      this.cachedPositions[index].dValue = dValue;
    }
  });

  // perform one time height update...
  let startIdx = 0;
  
  if (start) {
    startIdx = Number(start.id.split('-')[1]);
  }
  
  const cachedPositionsLen = this.cachedPositions.length;
  let cumulativeDiffHeight = this.cachedPositions[startIdx].dValue;
  this.cachedPositions[startIdx].dValue = 0;

  for (let i = startIdx + 1; i < cachedPositionsLen; ++i) {
    const item = this.cachedPositions[i];
    // update height
    this.cachedPositions[i].top = this.cachedPositions[i - 1].bottom;
    this.cachedPositions[i].bottom = this.cachedPositions[i].bottom - cumulativeDiffHeight;

    if (item.dValue !== 0) {
      cumulativeDiffHeight += item.dValue;
      item.dValue = 0;
    }
  }

  // update our phantom div height
  const height = this.cachedPositions[cachedPositionsLen - 1].bottom;
  this.phantomHeight = height;
  this.phantomContentRef.current.style.height = `${height}px`;
};
複製代碼
  • 當咱們如今有了全部元素的準確高度和位置值時,咱們獲取當前scrollTop (Offset)所對應的開始元素的方法修改成經過 cachedPositions 獲取:

    由於咱們的cachedPositions 是一個有序數組,因此咱們在搜索時能夠利用二分查找來下降時間複雜度

getStartIndex = (scrollTop = 0) => {
  let idx = binarySearch<CachedPosition, number>(this.cachedPositions, scrollTop, 
    (currentValue: CachedPosition, targetValue: number) => {
      const currentCompareValue = currentValue.bottom;
      if (currentCompareValue === targetValue) {
        return CompareResult.eq;
      }

      if (currentCompareValue < targetValue) {
        return CompareResult.lt;
      }

      return CompareResult.gt;
    }
  );

  const targetItem = this.cachedPositions[idx];

  // Incase of binarySearch give us a not visible data(an idx of current visible - 1)...
  if (targetItem.bottom < scrollTop) {
    idx += 1;
  }

  return idx;
};

  

onScroll = (evt: any) => {
  if (evt.target === this.scrollingContainer.current) {
    ....
    const currentStartIndex = this.getStartIndex(scrollTop);
    ....
  }
};
複製代碼
  • 二分查找實現:
export enum CompareResult {
  eq = 1,
  lt,
  gt,
}



export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;

  while (start <= end) {
    tempIndex = Math.floor((start + end) / 2);
    const midValue = list[tempIndex];
    const compareRes: CompareResult = compareFunc(midValue, value);

    if (compareRes === CompareResult.eq) {
      return tempIndex;
    }
    
    if (compareRes === CompareResult.lt) {
      start = tempIndex + 1;
    } else if (compareRes === CompareResult.gt) {
      end = tempIndex - 1;
    }
  }

  return tempIndex;
}
複製代碼
  • 最後,咱們滾動後獲取transform的方法改形成以下:
getTransform = () =>
    `translate3d(0,${this.startIndex >= 1 ? this.cachedPositions[this.startIndex - 1].bottom : 0}px,0)`;
複製代碼

線上Demo:codesandbox.io/s/a-v-list-…

相關文章
相關標籤/搜索