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

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

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;

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

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

רשתות

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

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

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

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

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

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

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

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

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

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

בדוגמה הבאה מוצג קטע קוד שממחיש מצב שמנוהל ברכיב 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. המאפיינים שמוקצים ל-loader הם:

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

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

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

הגלילה ברשימה עשויה להיראות אותו הדבר, אבל עכשיו נשלחת בקשה לאחזור של 10 משתמשים מ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 לרשתות. לא מומלץ לסרוק יותר מדי רשומות, כי זה ישפיע לרעה על הביצועים.