百萬PV商城實踐系列 - 前端長列表渲染優化實戰

⚠️ 本文爲掘金社區首發簽約文章,未獲受權禁止轉載html

簡介

本篇文章是商城實踐系列的第二篇文章,主要內容是對商城項目中一些長列表渲染進行優化,提升渲染的效率、優化顯示速度。前端

咱們在使用電商平臺的過程當中,打開首頁時,咱們一直向下滑動就會有源源不斷的推薦內容向咱們展現。隨着瀏覽頁面操做愈來愈多,數據也愈來愈龐大,這類場景咱們均可以統一稱爲長列表渲染。react

在商城項目當中,長列表渲染出現的頁面都與用戶密切相關,如訂單列表優惠券列表購物車等都是咱們平常生活中常常瀏覽的一些頁面,所以長列表渲染的性能效率用戶體驗二者是成正比的。瀏覽器

image.png

而在長列表頁面作性能優化和開發設計的時候,咱們大多數會碰到如下兩個問題:緩存

  • 數據過多,首次展現內容時間過長,接口返回數據過多,頁面數據很差處理。
  • DOM元素過多,頁面渲染卡頓、操做不流暢,瀏覽器性能壓力重。

這些問題該怎麼解決呢?我建議使用分頁加載+虛擬列表的方案。性能優化

image.png

爲何使用分頁+虛擬列表的方案?

爲了方便你們查閱,我把詳細的場景問題和可用的解決方案整理在了思惟導圖中。其中,可用的解決方案包括分頁加載切片加載虛擬列表,以及分頁+虛擬列表。那麼,我爲何選擇分頁+虛擬列表這個方案呢?markdown

首先,咱們將每一個方案能夠解決的問題不能解決的問題作一個梳理,具體的優缺點以下:異步

  • 分頁加載:解決了數據過多問題,經過數據分頁的方式減小了首次頁面加載的數據和DOM數量。是現今絕大部分的應用都會採用的實施手段。隨着頁面瀏覽的頁面數據增多,DOM數量也愈來愈多,仍是會存在部分問題。函數

  • 分片加載:與分頁加載相同,只是將用戶觸底行爲獲取最新數據的時間節點在一開始進行了切片加載,優先顯示頁面數據在加載其餘數據。會出現頁面阻塞和性能問題oop

  • 虛擬列表:將驅動交給數據,經過區間來直接渲染區間內容中的數據DOM,解決了頁面列表內元素過多操做卡頓的問題, 與數據加載無掛鉤。

當列舉了三種常見的方式後,咱們發現單一的方案很難知足咱們的訴求。所以,我選擇使用分頁的方式處理數據加載,同時將渲染頁面的事情交給虛擬列表進行渲染。經過結合兩種不一樣側重點的方案,來知足咱們初步的訴求。

image.png

實現虛擬列表

既然敲定了解決方案,咱們就先來看看虛擬列表是什麼東西吧🥳。

經過下面的示意圖,咱們將總體列表劃分爲滾動窗口可視窗口。左邊是真實的列表,全部的列表項都是真實的DOM元素,而虛擬列表從圖中能夠看到,只有出如今可視窗口內的列表項纔是真實的DOM元素,而未出如今可視窗口中的元素則只是虛擬數據,並未加載到頁面上。

與真實列表不一樣的是,虛擬列表的滾動都是經過transform或者是marginTop作的偏移量,自己列表中只顯示視窗區的DOM元素。

image.png

下面,咱們就來從0到1實現一個基本的虛擬列表吧。

基本佈局

以下結構圖,咱們先分析下基本頁面構成:

  • 第一層爲容器層,選定一個固定高度,也就是咱們說的可視化窗口
  • 第二層爲內容層,通常在這裏撐開高度,使容器造成scroll
  • 第三層爲子內容層,居於內容層內部,也就是列表中的列表項。
  • ......

image.png

分析後,我將結構圖中代碼使用JSX實現後,就是下面這個簡單的結構:

頁面佈局代碼

<div>
  <div>
    ... List Item Element
  </div>
</div>;

.App {
    font-family: sans-serif;
    text-align: center;
}

.showElement {
    display: flex;
    justify-content: center;
    align-items: center;
    border: 1px solid #000;
    margin-bottom: 8px;
    border-radius: 4px;
}
複製代碼

先搭建一個簡單的頁面,而後經過currentViewList渲染出對應的列表項內容。

初始化頁面

當咱們肯定了頁面的基本結構後,咱們再來完善一些佈局與配置,實現一個真實渲染上千條數據的列表。

我先定義了一些配置,包含容器高度、列表項高度、預加載偏移數量等須要用到的固定內容。

  • 容器高度:當前虛擬列表的高度
  • 列表項高度: 列表項的高度
  • 預加載偏移:可視窗上下作預加載時須要額外展現幾個預備內容

頁面屬性

/** @name 頁面容器高度 */

const SCROLL_VIEW_HEIGHT: number = 500;

/** @name 列表項高度 */

const ITEM_HEIGHT: number = 50;

/** @name 預加載數量 */

const PRE_LOAD_COUNT: number = SCROLL_VIEW_HEIGHT / ITEM_HEIGHT;
複製代碼

接着,建立一個useRef用來存儲元素,而後獲取視窗高度和偏移屬性。

/** 容器Ref */

const containerRef = useRef<HTMLDivElement | null>(null);
複製代碼

而後,建立數據源,而且生成3000條隨機數據作顯示處理。

const [sourceData, setSourceData] = useState<number[]>([]);

/** * 建立列表顯示數據 */
const createListData = () => {
  const initnalList: number[] = Array.from(Array(4000).keys());
  setSourceData(initnalList);
};

useEffect(() => {
  createListData();
}, []);
複製代碼

最後,爲相對應的容器綁定高度。在最外層div標籤設置高度爲SCROLL_VIEW_HEIGHT,對列表div的高度則設置爲sourceData.length * ITEM_HEIGHT

獲取列表總體高度

/** * scrollView總體高度 */
 const scrollViewHeight = useMemo(() => {
  return sourceData.length * ITEM_HEIGHT;
}, [sourceData]);
複製代碼

綁定頁面視圖

<div ref={containerRef} style={{ height: SCROLL_VIEW_HEIGHT, overflow: "auto", }} onScroll={onContainerScroll} >
  <div style={{ width: "100%", height: scrollViewHeight - scrollViewOffset, marginTop: scrollViewOffset, }} >
    {sourceData.map((e) => (
      <div style={{ height: ITEM_HEIGHT, }} className="showElement" key={e} >
        Current Position: {e}
      </div>
    ))}
  </div>
</div>;
複製代碼

當數據初始化後,咱們的列表頁面就初步完成了,來看下效果吧。

image.png

內容截取

對於虛擬列表來講,並不須要全量將數據渲染在頁面上。那麼,在這裏咱們就要開始作數據截取的工做了。

首先,以下圖,咱們經過showRange來控制頁面顯示元素的數量。經過Array.slice的函數方法對sourceData進行數據截取, 返回值就是咱們在頁面上去顯示的列表數據了。我將上面代碼中直接遍歷souceData換成咱們的新數據列表。以下:

{currentViewList.map((e) => (
  <div style={{ height: ITEM_HEIGHT }} className="showElement" key={e.data} > Current Position: {e.data} </div>
))}
複製代碼

上面使用到的currentViewList是一個useMemo的返回值,它會隨着showRangesourceData的更新發生變化。

/** * 當前scrollView展現列表 */
 const currentViewList = useMemo(() => {
  return sourceData.slice(showRange.start, showRange.end).map((el, index) => ({
    data: el,
    index,
  }));
}, [showRange, sourceData]);
複製代碼

image.png

滾動計算

至此,已經完成了一個基本的虛擬列表雛形,下一步咱們就須要監聽視窗滾動事件來計算showRange中的startend的偏移量,同時調整對應的滾動條進度來實現一個真正的列表效果。

首先,我先爲滾動視窗(scrollContainer)綁定onScroll事件,也就是下面的onContainerScroll函數方法。

/** * onScroll事件回調 * @param event { UIEvent<HTMLDivElement> } scrollview滾動參數 */
 const onContainerScroll = (event: UIEvent<HTMLDivElement>) => {
  event.preventDefault();
  calculateRange();
};
複製代碼

在事件主要作的事情就計算當前showRange中的startend所處位置,同時更新頁面視圖數據。下面,咱們來看看它是怎麼處理的吧!

首先,經過containerRef.current.scrollTop能夠知道元素滾動條內的頂部隱藏列表的高度,而後使用Math.floor方法向下取整後,來獲取當前偏移的元素數量,在減去一開始的上下文預加載數量PRE_LOAD_COUNT,就能夠得出截取內容開始的位置。

其次,經過containerRef.current.clientHeight能夠獲取滾動視窗的高度,那麼經過containerRef.current.clientHeight / ITEM_HEIGHT這個公式就能夠得出當前容器窗口能夠容納幾個列表項。

當我經過當前滾動條位置下以前滾動的元素個數且已經計算出截取窗口的起始位置後,就能夠經過啓動位置 + 容器顯示個數 + 預加載個數這個公式計算出了當前截取窗口的結束位置。使用setShowPageRange方法更新新的位置下標後,當我上下滑動窗口,顯示的數據會根據showRange切割成爲不一樣的數據渲染在頁面上。

/** * 計算元素範圍 */
 const calculateRange = () => {
  const element = containerRef.current;
  if (element) {
    const offset: number = Math.floor(element.scrollTop / ITEM_HEIGHT) + 1;
    console.log(offset, "offset");
    const viewItemSize: number = Math.ceil(element.clientHeight / ITEM_HEIGHT);
    const startSize: number = offset - PRE_LOAD_COUNT;
    const endSize: number = viewItemSize + offset + PRE_LOAD_COUNT;
    setShowPageRange({
      start: startSize < 0 ? 0 : startSize,
      end: endSize > sourceData.length ? sourceData.length : endSize,
    });
  }
};
複製代碼

image.png

滾動條偏移

上面,咱們提到會根據containerRef.current.scrollTop計算當前滾動過的高度。那麼問題來了,頁面上其實並無真實的元素,又該如何去撐開這個高度呢?

目前而言,比較流行的解決方案分爲MarinTopTranForm作距離頂部的偏移來實現高度的撐開。

  • margin是屬於佈局屬性,該屬性的變化會致使頁面的重排
  • transform是合成屬性,瀏覽器會爲元素建立一個獨立的複合層,當元素內容沒有發生變化,該層不會被重繪,經過從新複合來建立動畫幀。

兩種方案並無太大的區別,均可以用來實現距離頂部位置的偏移,達到撐開列表實際高度的做用。

下面,我就以MarinTop的方法來處理這個問題,來完善當前的虛擬列表。

首先,咱們須要計算出列表頁面距離頂部的MarginTop的距離,經過公式:當前虛擬列表的起始位置 * 列表項高度,咱們能夠計算出當前的scrollTop距離。

經過useMemo將邏輯作一個緩存處理,依賴項爲showRange.start, 當showRange.start發生變化時會更新marginTop的高度計算。

/** * scrollView 偏移量 */
 const scrollViewOffset = useMemo(() => {
  console.log(showRange.start, "showRange.start");
  return showRange.start * ITEM_HEIGHT;
}, [showRange.start]);
複製代碼

在頁面上爲列表窗口綁定marginTop: scrollViewOffset屬性,而且在總高度中減去scrollViewOffset來維持平衡,防止多出距離的白底。

以下代碼

<div style={{ width: "100%", height: scrollViewHeight - scrollViewOffset, marginTop: scrollViewOffset }} >
複製代碼

至此,咱們已經完成了一個基本的虛擬列表,下面咱們來一塊兒看看實際的效果吧。

Kapture 2021-08-08 at 17.51.29.gif

結合分頁加載

當咱們有了一個虛擬列表後,就能夠嘗試結合分頁加載來實現一個懶加載的長虛擬列表了。

若是作過度頁滾動加載的小夥伴可能立馬就想到實現思路了,不瞭解的同窗也不要着急,下面我就帶你們一塊兒來實現一個帶分頁加載的虛擬列表,相信你看完以後會對這類問題有一個更加深刻的理解。

判斷是否到底部

想要實現列表的分頁加載,咱們須要綁定onScroll事件來判斷當前滾動視窗是否滾動到了底部,當滾動到底部後須要爲sourceData進行數據的添加。同時將挪動指針,將數據指向下一個起始點。

具體實現代碼以下,reachScrollBottom函數的返回值是當前滾動窗口是否已經到達了底部。所以,咱們經過函數的返回值進行條件判斷。到達底部後,咱們模擬一批數據後經過setSourceData設置源數據。結束以後在執行calculateRange從新設置內容截取的區間。

/** * onScroll事件回調 * @param event { UIEvent<HTMLDivElement> } scrollview滾動參數 */
 const onContainerScroll = (event: UIEvent<HTMLDivElement>) => {
  event.preventDefault();
  if (reachScrollBottom()) {
    // 模擬數據添加,其實是 await 異步請求作爲數據的添加
    let endIndex = showRange.end;
    let pushData: number[] = [];
    for (let index = 0; index < 20; index++) {
      pushData.push(endIndex++);
    }
    setSourceData((arr) => {
      return [...arr, ...pushData];
    });
  }
  calculateRange();
};
複製代碼

那麼,calculatScrollTop是如何判斷當前是否已經觸底呢?

image.png

分析上圖,我經過containerRef能夠拿到滾動窗口的高度scrollHeight或者直接使用soureData.length * ITEM_HEIGHT充當滾動窗口的高度二者做用是同樣的。

同時,我也能夠拿到scrollTop滾動位置距離頂部的高度和clientHeight當前視窗高度。經過三者的關係,能夠得出條件公式:scrollTop + clientHeight >= scrollHeight,知足這個條件就說明當前窗口已經到達底部。咱們將其寫成reachScrollBottom方法,以下:

/** * 計算當前是否已經到底底部 * @returns 是否到達底部 */
 const reachScrollBottom = (): boolean => {
  //滾動條距離頂部
  const contentScrollTop = containerRef.current?.scrollTop || 0; 
  //可視區域
  const clientHeight = containerRef.current?.clientHeight || 0; 
  //滾動條內容的總高度
  const scrollHeight = containerRef.current?.scrollHeight || 0;
  if (contentScrollTop + clientHeight >= scrollHeight) {
    return true;
  }
  return false;
};
複製代碼

無限列表演示

至此,咱們的虛擬列表實現已經基本完成了,下面咱們一塊兒來看看效果吧,這裏先簡單的模擬一個商品列表來做爲演示頁面,效果以下:

Kapture 2021-08-08 at 22.51.01.gif

資源推薦

總結

本篇文章中,我講了針對商城項目中出現長列表的部分場景,同時針對這些場景列舉了不一樣的解決方案及其優缺點。在選擇分頁 + 虛擬列表的組合方式來解決問題的過程當中,我一步一步帶你們實現了一個簡單的分頁虛擬列表,幫助你們瞭解其內部的原理。

固然,這個方案還有不少須要完善的地方,我也在這裏說說它須要優化的地方。

  • 滾動事件能夠添加節流事件避免形成性能浪費。
  • 列表項高度不固定須要給定一個默認高度後設置新的高度在從新刷新容易截取的開始和結束位置。
  • 滑動過快出現白屏問題能夠嘗試動態加載loading顯示過渡,優化一些細節體驗。
  • 列表項中存在陰影元素須要考慮緩存處理,否則滾動時必然會引發從新加載。

市面上已經有不少開源庫能夠解決這些問題,如react中ahooks就有相對完善的虛擬列表實踐,本文的代碼相對而言也是對其的源碼分析。

總的來講,咱們在真實開發中並不須要從零開始造一個完善的輪子,直接使用成熟的方案,搭配好的產品設計能夠很好地解決大部分的問題。

對於一個商城項目來講,它的挑戰性不是在於功能的實現邏輯上,而在於部分視覺感覺與體驗的優化上。若是以爲文章對你有幫助,能夠點個👍,給我加個油。若是對前端電商項目想了解更多的Yoyo們能夠關注本專欄。

近期好文

尾註

本文首發於:掘金技術社區
類型:簽約文章
做者:wangly19
收藏於專欄:# 百萬PV商城實踐系列 公衆號: ItCodes 程序人生

相關文章
相關標籤/搜索