יצירת רשימה וירטואלית של רשימות גדולות באמצעות חלון תגובה

טבלאות ורשימות גדולות במיוחד עלולות להאט באופן משמעותי את ביצועי האתר. וירטואליזציה יכולה לעזור!

react-window היא ספרייה שמאפשרת להציג רשימות גדולות ביעילות.

דוגמה לרשימת 1,000 שורות שנעשה לה רינדור באמצעות react-window. כדאי לנסות לגלול מהר ככל האפשר.

למה זה מועיל?

יכול להיות שתצטרכו להציג טבלה או רשימה גדולה שמכילה הרבה שורות. טעינה של כל פריט ברשימת פריטים כזו עלולה להשפיע בצורה משמעותית על הביצועים.

וירטואליזציה של רשימות, או 'חלון', היא המושג של עיבוד רק את מה שגלוי למשתמש. מספר הרכיבים שמוצגים בהתחלה הוא קבוצת משנה קטנה מאוד של הרשימה כולה, ו'החלון' של התוכן הגלוי זז כשהמשתמש ממשיך לגלול. כך אפשר לשפר את הביצועים של העיבוד ושל הגלילה ברשימה.

חלון של תוכן ברשימה וירטואלית
הזזת 'חלון' של תוכן ברשימה וירטואלית

צמתים של DOM שיוצאים מה'חלון' עוברים מיחזור או מוחלפים מיד ברכיבים חדשים יותר כשהמשתמש גולל למטה ברשימה. כך מספר כל הפריטים שעבר רינדור יהיה ספציפי לגודל החלון.

react-window

react-window היא ספרייה קטנה של צד שלישי שמאפשרת ליצור רשימות וירטואליות באפליקציה בקלות רבה יותר. הוא מספק מספר ממשקי API בסיסיים שאפשר להשתמש בהם לסוגים שונים של רשימות וטבלאות.

מתי כדאי להשתמש ברשימות בגודל קבוע

השתמשו ברכיב FixedSizeList אם יש לכם רשימה ארוכה ואחד-מימדית של פריטים באותו גודל.

import React from 'react';
import { FixedSizeList } from 'react-window';

const items = [...] // some list of items

const Row = ({ index, style }) => (
  <div style={style}>
     {/* define the row component using items[index] */}
  </div>
);

const ListComponent = () => (
  <FixedSizeList
    height={500}
    width={500}
    itemSize={120}
    itemCount={items.length}
  >
    {Row}
  </FixedSizeList>
);

export default ListComponent;
  • הרכיב FixedSizeList מקבל את המאפיינים height, ‏ width ו-itemSize כדי לקבוע את הגודל של הפריטים ברשימה.
  • פונקציה שמרינדרת את השורות מועברת כצאצא ל-FixedSizeList. אפשר לגשת לפרטים על הפריט הספציפי באמצעות הארגומנט index (items[index]).
  • פרמטר style מועבר גם לשיטת הרינדור של השורה, שחייבת להיות מצורפת לאלמנט השורה. פריטים ברשימה ממוקמים באופן מוחלט, וערכי הגובה והרוחב שלהם מוקצים בשורה, והפרמטר style אחראי לכך.

הדוגמה ל-Glitch שמוצגת למעלה היא דוגמה לרכיב FixedSizeList.

מתי כדאי להשתמש ברשימות בגדלים משתנים

משתמשים ברכיב VariableSizeList כדי להציג רשימה של פריטים בגדלים שונים. הרכיב הזה פועל כמו רשימה בגודל קבוע, אבל במקום ערך ספציפי, הוא מצפה לפונקציה למאפיין itemSize.

import React from 'react';
import { VariableSizeList } from 'react-window';

const items = [...] // some list of items

const Row = ({ index, style }) => (
  <div style={style}>
     {/* define the row component using items[index] */}
  </div>
);

const getItemSize = index => {
  // return a size for items[index]
}

const ListComponent = () => (
  <VariableSizeList
    height={500}
    width={500}
    itemCount={items.length}
    itemSize={getItemSize}
  >
    {Row}
  </VariableSizeList>
);

export default ListComponent;

ההטמעה הבאה מציגה דוגמה לרכיב הזה.

בדוגמה הזו, פונקציית גודל הפריט שהועברה למאפיין itemSize משנה באופן אקראי את גובה השורות. עם זאת, באפליקציה אמיתית צריכה להיות לוגיקה בפועל שמגדירה את הגדלים של כל פריט. באופן אידיאלי, צריך לחשב את הגדלים האלה על סמך נתונים או לקבל אותם מ-API.

רשתות

react-window תומך גם בווירטואליזציה של רשימות או רשתות מרובות-מימדים. בהקשר הזה, 'החלון' של התוכן הגלוי משתנה כשהמשתמש גולל אופקית וגם אנכית.

חלון נע של תוכן בחלוקה וירטואלית לרשת הוא דו-מימדי
העברת 'חלון' של תוכן ברשת וירטואלית היא דו-מימדית

באופן דומה, אפשר להשתמש גם ברכיב FixedSizeGrid וגם ברכיב VariableSizeGrid, בהתאם לאפשרות שהגודל של פריטים ספציפיים ברשימה ישתנה.

  • ב-FixedSizeGrid, ה-API דומה, אבל צריך לייצג את הגבהים, הרוחבים ומספרי הפריטים גם בעמודות וגם בשורות.
  • ב-VariableSizeGrid, אפשר לשנות את רוחב העמודות ואת גובה השורות על ידי העברת פונקציות במקום ערכים ל-props המתאימים.

כדאי לעיין במסמכי העזרה כדי לראות דוגמאות לרשתות וירטואליות.

טעינה מדורגת בזמן גלילה

באתרים רבים, כדי לשפר את הביצועים, המערכת ממתינה לטעינת הפריטים ברשימת פריטים ארוכה ולעיבוד שלהם עד שהמשתמש גולל למטה. הטכניקה הזו, שנקראת בדרך כלל 'טעינה אינסופית', מוסיפה צמתים חדשים של DOM לרשימה כשהמשתמש גולל מעבר לסף מסוים קרוב לסוף. אמנם הפתרון הזה טוב יותר מלטעון את כל הפריטים ברשימה בבת אחת, אבל עדיין יתבצע אכלוס של DOM עם אלפי רשומות של שורות אם המשתמש גולל מעבר למספר הזה. המצב הזה עלול להוביל לגודל DOM גדול מדי, שמשפיע על הביצועים על ידי האטת החישובים של הסגנונות והמוטציות של DOM.

התרשים הבא יכול לעזור לסכם את הנושא:

ההבדל בין גלילה ברשימה רגילה לבין גלילה ברשימה וירטואלית
הבדלים בגלילה בין רשימה רגילה לבין רשימה וירטואלית

הגישה הטובה ביותר לפתרון הבעיה הזו היא להמשיך להשתמש בספרייה כמו react-window כדי לשמור על 'חלון' קטן של רכיבים בדף, אבל גם לבצע טעינת פריטים חדשים באופן איטי כשהמשתמש גולל למטה. אפשר לעשות זאת באמצעות react-window באמצעות חבילת react-window-infinite-loader נפרדת.

קטע הקוד הבא מציג דוגמה למצב שמנוהל ברכיב הורה App.

import React, { Component } from 'react';

import ListComponent from './ListComponent';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [], // instantiate initial list here
      moreItemsLoading: false,
      hasNextPage: true
    };

    this.loadMore = this.loadMore.bind(this);
  }

  loadMore() {
   // method to fetch newer entries for the list
  }

  render() {
    const { items, moreItemsLoading, hasNextPage } = this.state;

    return (
      <ListComponent
        items={items}
        moreItemsLoading={moreItemsLoading}
        loadMore={this.loadMore}
        hasNextPage={hasNextPage}
      />
    );
  }
}

export default App;

שיטת loadMore מועברת לצאצא ListComponent שמכיל את רשימת הטעינה האינסופית. זה חשוב כי הטעינה האינסופית צריכה להפעיל קריאה חוזרת (callback) כדי לטעון פריטים נוספים אחרי שהמשתמש גולל מעבר לנקודה מסוימת.

כך יכול להיראות ה-ListComponent שמרינדר את הרשימה:

import React from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from "react-window-infinite-loader";

const ListComponent = ({ items, moreItemsLoading, loadMore, hasNextPage }) => {
  const Row = ({ index, style }) => (
     {/* define the row component using items[index] */}
  );

  const itemCount = hasNextPage ? items.length + 1 : items.length;

  return (
    <InfiniteLoader
      isItemLoaded={index => index < items.length}
      itemCount={itemCount}
      loadMoreItems={loadMore}
    >
      {({ onItemsRendered, ref }) => (
        <FixedSizeList
          height={500}
          width={500}
          itemCount={itemCount}
          itemSize={120}
          onItemsRendered={onItemsRendered}
          ref={ref}
        >
          {Row}
        </FixedSizeList>
      )}
  </InfiniteLoader>
  )
};

export default ListComponent;

כאן, הרכיב FixedSizeList עטוף ב-InfiniteLoader. הפרמטרים שהוקצו למטען הם:

  • isItemLoaded: שיטה לבדיקה אם פריט מסוים נטען
  • itemCount: מספר הפריטים ברשימה (או הצפוי)
  • loadMoreItems: פונקציית קריאה חוזרת שמחזירה הבטחה שמתקבלים ממנה נתונים נוספים לרשימה

פרמטר ה-render משמש להחזרת פונקציה שבה רכיב הרשימה משתמש כדי לבצע רינדור. גם המאפיין onItemsRendered וגם המאפיין ref הם מאפיינים שצריך להעביר.

הדוגמה הבאה ממחישה איך אפשר להשתמש בחיבור לטעינה אינסופית עם רשימה וירטואלית.

גלילה למטה ברשימה עשויה להיראות זהה, אבל עכשיו מתבצעת בקשה לאחזר 10 משתמשים מ-random user API בכל פעם שמגללים קרוב לסוף הרשימה. כל זה מתבצע תוך עיבוד של 'חלון' אחד בלבד של תוצאות בכל פעם.

בדיקת הערך של index של פריט מסוים יכולה להציג מצב טעינה שונה של הפריט, בהתאם לשאלה אם נשלחה בקשה להוספת רשומות חדשות והפריט עדיין נטען.

לדוגמה:

const Row = ({ index, style }) => {
  const itemLoading = index === items.length;

  if (itemLoading) {
      // return loading state
  } else {
      // return item
  }
};

סריקת יתר

מאחר שהפריטים ברשימה וירטואלית משתנים רק כשהמשתמש גולל, יכול להיות ששטח ריק יהבהב לרגע כשרשומות חדשות עומדות להופיע. כדי להבחין בכך, אפשר לנסות לגלול במהירות באחת מהדוגמאות הקודמות במדריך הזה.

כדי לשפר את חוויית המשתמש ברשימות וירטואליות, המאפיין react-window מאפשר לסרוק פריטים עם המאפיין overscanCount. כך תוכלו להגדיר כמה פריטים מחוץ ל'חלון' הגלוי ייגרמו לעיבוד בכל רגע נתון.

<FixedSizeList
  //...
  overscanCount={4}
>
  {...}
</FixedSizeList>

הערך overscanCount פועל גם ברכיב FixedSizeList וגם ברכיב VariableSizeList, וערך ברירת המחדל שלו הוא 1. בהתאם לגודל הרשימה ולגודל של כל פריט, סריקת יתר של יותר מרשומות אחת יכולה למנוע הבהוב ניכר של שטח ריק כשהמשתמש גולל. עם זאת, סריקת יתר של יותר מדי רשומות עלולה להשפיע לרעה על הביצועים. המטרה של שימוש ברשימה וירטואלית היא למזער את מספר הרשומות למה שהמשתמש יכול לראות בכל רגע נתון, לכן כדאי לנסות לצמצם את מספר הפריטים שנסרקים יתר על המידה.

עבור FixedSizeGrid ו-VariableSizeGrid, משתמשים במאפיינים overscanColumnsCount ו-overscanRowsCount כדי לקבוע את מספר העמודות והשורות שיהיו בבדיקה החורגת, בהתאמה.

סיכום

אם אתם לא בטוחים מאיפה כדאי להתחיל את הווירטואליזציה של רשימות וטבלאות באפליקציה, תוכלו לפעול לפי השלבים הבאים:

  1. מדידת הביצועים של הרינדור והגלילה. במאמר הזה מוסבר איך אפשר להשתמש במדד ה-FPS בכלים למפתחים של Chrome כדי לבדוק את היעילות של עיבוד הפריטים ברשימת פריטים.
  2. מוסיפים את הערך react-window לכל רשימה ארוכה או לכל רשת ארוכה שמשפיעות על הביצועים.
  3. אם יש תכונות מסוימות שלא נתמכות ב-react-window, כדאי לשקול להשתמש ב-react-virtualized אם אתם לא יכולים להוסיף את הפונקציונליות הזו בעצמכם.
  4. אם אתם צריכים לטעון פריטים באיטרציות בזמן שהמשתמש גולל, צריך לעטוף את הרשימה הווירטואלית ב-react-window-infinite-loader.
  5. כדי למנוע הצגה קצרה של תוכן ריק, מומלץ להשתמש בנכס overscanCount ברשימות ובנכסים overscanColumnsCount ו-overscanRowsCount ברשתות. אל תסרקו יותר מדי רשומות, כי זה ישפיע לרעה על הביצועים.