מדידה ושיפור ביצועים ב React

18/07/2018

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

https://www.tocode.co.il/workshops/40

1. קצת תיאוריה

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

ב React הפעולות המרכזיות שמשפיעות על הביצועים הן:

  1. בכל פעם שמשתנה State של פקד ריאקט מעדכן את ה Properties בכל עץ הפקדים שמתחתיו.
  2. בכל פעם שהשתנה State או Property של פקד ריאקט יקרא ל shouldComponentUpdate כדי להבין אם צריך לקרוא ל render עבור פקד זה.
  3. ריאקט יקרא ל render לכל הפקדים שצריכים render מחדש.
  4. לאחר מכן ריאקט יבדוק בכל אחד מפקדים אלה אם תוצאת ה render זהה או שונה למה שיש על המסך כרגע. אם יש שינוי יעדכן את ה DOM בהתאם ולפי גודל השינוי.

לכן הסיבות המרכזיות שיגרמו לבעיות ביצועים בריאקט הן:

  1. קריאות מיותרות ל render: בגלל מימוש לא נכון או חסר של shouldComponentUpdate או בגלל חלוקה לא נכונה לפקדים, ריאקט יקרא ל render גם אם אין שינוי בפקד.

  2. פונקציית render שמחזירה שינויים גדולים מדי: התוצאה של render יכולה להשפיע על איך ריאקט יעדכן את ה DOM בעיקר בגלל שימוש במאפיין key.

בנוסף על שתי אלה אנו עשויים למצוא קוד שהיה גורם לבעיות ביצועים גם בלי ריאקט: קוד טיפול באירוע שלוקח יותר מדי זמן או Event Handlers על אירועים שקורים לעתים מאוד קרובות ולא באמת נחוצים.

2. איך מודדים

ריאקט 16 מגיע עם אינטגרציה מלאה לכלי מדידת הביצועים של כרום ב Devtools ולכן נשתמש בכלים אלה כדי למדוד את הביצועים של התוכנית.

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

import React from 'react';
import ReactDOM from 'react-dom';
import tinycolor from 'tinycolor2';

class ColorPalette extends React.Component {
    render() {
        const colors = [];
        for (let i=-360; i < 360; i++) {
            colors.push(tinycolor(this.props.start).spin(i).toString());
        }
        return (
            colors.map(color => (
                <div style={{
                    width: '100px',
                    height: '100px',
                    background: color,
                    display: 'inline-block',
                    margin: '5px',
                }} />
            ))
        );
    }
}

class ColorSelector extends React.Component {
    constructor(props) {
        super(props);
        this.state = { color: '#000000' };
        this.setColor = this.setColor.bind(this);
    }

    setColor(e) {
        this.setState({ color: e.target.value });
    }

    render() {
        return (
          <div>
              <div>
                <input type="color" value={this.state.color} onChange={this.setColor} />
              </div>
              <ColorPalette start={this.state.color} />
          </div>
        );
    }
}

ReactDOM.render(<ColorSelector/>, document.querySelector('main'));

ואפשר לשחק עם התוכנית בקודפן הבא:

בשביל למדוד את זמני התגובה של התוכנית:

  1. ניכנס לכלי הפיתוח של כרום למסך Performance.

  2. נלחץ על כפתור "הקלטה" ונחזור לתוכנית לשנות צבעים.

  3. נלחץ על כפתור "עצור" ונתבונן בגרף שנוצר.

שימו לב לבצע את הבדיקה כשה React Developer Tools מנוטרל ו webpack בנה לכם את כל הקבצים במצב פיתוח.

הגרף מראה לנו את רשימת הפונקציות שהופעלו ועבור כל פונקציה כמה זמן היא לקחה. קל לראות שעיקר הזמן מתבזבז לנו בפונקציית render של הפקד ColorPalette מאחר וכל שינוי קטן בצבע משנה את כל 720 התיבות הצבעוניות.

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

class ColorBox extends React.Component {
    render() {
        return (
            <div
                onClick={this.props.onClick}
                data-id={this.props.id}
                className="box"
                style={{background: this.props.color}}
            >
                {this.props.id}
            </div>
        );
    }
}

באופן כללי הרזולוציה של מדידה (ועדכון) בריאקט היא תמיד רזולוציה של פקדים. לכן כשאתם רוצים לאפשר לחלקים שונים להתעדכן בזמנים שונים, או כשאתם רוצים למדוד מה מתעדכן מתי כדאי להפריד את הדברים ל Components שונים. מצד שני איחוד פקדים לפקד יחיד יכול לתת שיפור ביצועים כשהשינויים בכולם קורים תמיד באותו זמן.

בחזרה לתוכנית תיקון שאר הקוד כדי שישתמש בפקד החדש נראה כך:

class ColorPalette extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            deletedIndexes: new Set(),
        };
        this.removeBox = this.removeBox.bind(this);
    }

    removeBox(e) {
        const { deletedIndexes } = this.state;
        deletedIndexes.add(Number(e.target.dataset.id));

        this.setState({
            deletedIndexes,
        })

    }

    render() {
        const colors = [];
        for (let i=-360; i < 360; i++) {
            if (this.state.deletedIndexes.has(i)) continue;
            colors.push(
                <ColorBox
                    color={tinycolor(this.props.start).spin(i).toString()}
                    id={i}
                    onClick={this.removeBox}
                />
            );
        }
        return colors;
    }
}

class ColorSelector extends React.Component {
    constructor(props) {
        super(props);
        this.state = { color: '#000000' };
        this.setColor = this.setColor.bind(this);
    }

    setColor(e) {
        this.setState({ color: e.target.value });
    }

    render() {
        return (
          <div>
              <div>
                <input type="color" value={this.state.color} onChange={this.setColor} />
              </div>
              <ColorPalette start={this.state.color} key={this.state.color} />
          </div>
        );
    }
}

ועכשיו כל לחיצה על תיבת צבע תמחק אותה וכל שינוי צבע יחזיר את כל התיבות למצבן המקורי.

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

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

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

3. הגדרת key בתור מזהה ייחודי ומדידה חוזרת

ברגע שאנחנו מבינים איך key עובד קל לראות איך שינוי קטן ב key מייצר שינוי גדול בעבודה שריאקט צריך לעשות. נוסיף מזהה ייחודי בתור ה id של כל תיבת צבע בתוך הפונקציה render של ColorPalette ונקבל:

    render() {
        const colors = [];
        for (let i=-360; i < 360; i++) {
            if (this.state.deletedIndexes.has(i)) continue;
            colors.push(
                <ColorBox
                    color={tinycolor(this.props.start).spin(i).toString()}
                    id={i}
                    onClick={this.removeBox}
                    key={i}
                />
            );
        }
        return colors;
    }

מדידה חוזרת מראה שבמקום 718 שינויים יש ל React עכשיו רק שינוי אחד לבצע: מחיקת אלמנט יחיד. קיבלנו את אותו אפקט אבל עם הרבה פחות עבודה מהצד של ריאקט ולכן גם הרבה יותר מהר.

4. הגדרת key בתור מספר אקראי ומדידה חוזרת

בשביל המשחק בואו ננסה להגדיר את key כמספר אקראי:

    render() {
        const colors = [];
        for (let i=-360; i < 360; i++) {
            if (this.state.deletedIndexes.has(i)) continue;
            colors.push(
                <ColorBox
                    color={tinycolor(this.props.start).spin(i).toString()}
                    id={i}
                    onClick={this.removeBox}
                    key={Math.random()}
                />
            );
        }
        return colors;
    }

מדידה חוזרת מראה שעכשיו יש לריאקט 1439 שינויים לשים ב DOM. הזמן שלקח לריאקט לבצע את השינויים עלה מאזור ה 0.8ms ל 14ms.

5. הוספת אוביקט Immutable ומדידה חוזרת

הרבה מדברים על הייתרון של השימוש ב Immutable Data בעבודה עם ריאקט. עכשיו שאנחנו יודעים איך למדוד יהיה מעניין לבדוק ייתרון זה ולראות איפה בא לידי ביטוי. נעדכן את הפקד ColorBox כך שיירש מ PureComponent:

class ColorBox extends React.PureComponent {
    render() {
        return (
            <div
                onClick={this.props.onClick}
                data-id={this.props.id}
                className="box"
                style={{background: this.props.color}}
            >
                {this.props.id}
            </div>
        );
    }
}

הפעלה חוזרת של התוכנית ובדיקה מגלה תוצאות מעניינות:

  1. לפני הירושה מ PureComponent המערכת בזבזה הרבה זמן על הפעלת render של ColorBox. ריאקט היה צריך לעבור על כל אחת מהקופסאות ולקרוא ל render שלה כדי לדעת איזה מאפיינים של האלמנט השתנו. אומנם בזכות השימוש החכם ב key הצלחנו לצמצם כמעט לאפס את מספר השינויים שנכתבו ל DOM, אך את render עדיין היה צריך להפעיל.

  2. לאחר הוספת ירושה מ PureComponent גם על render הצלחנו לוותר. הגרף החדש מראה שעדכון ColorPalette לא דורש אפילו קריאה ל render של הקופסאות עצמן. מאחר וכל הקופסאות (מלבד זו שנמחקה) נשארו עם אותו key ועם אותם properties ריאקט יכול להניח שגם render יחזיר את אותה תוצאה ולכן אין טעם להפעילו.

6. מתי Pure Component לא עוזר

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

נתבונן בקוד הבא של אלמנט ColorPalette בו במקום לבצע bind ב constructor ביצעתי את ה bind בתוך ה render:

class ColorPalette extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            deletedIndexes: new Set(),
        };
    }

    removeBox(e) {
        const { deletedIndexes } = this.state;
        deletedIndexes.add(Number(e.target.dataset.id));

        this.setState({
            deletedIndexes,
        })

    }

    render() {
        const colors = [];
        for (let i=-360; i < 360; i++) {
            if (this.state.deletedIndexes.has(i)) continue;
            colors.push(
                <ColorBox
                    color={tinycolor(this.props.start).spin(i).toString()}
                    id={i}
                    onClick={(e) => this.removeBox(e)}
                    key={i}
                />
            );
        }
        return colors;
    }
}

זה שינוי של שתי שורות בהשוואה לגירסא הקודמת: ה bind ירד מהבנאי ובמקומו הוספתי פונקציית חץ בתוך הלולאה שב render. עכשיו ההתנהגות מאוד שונה.

נפעיל שוב את התוכנית ונוכל לראות שמחיקת אלמנטים מפעילה שוב את ה render של ColorBox לכל התיבות. הסיבה שהפעם אחד ה properties השתנה: הפונקציה onClick נוצרת מחדש בכל איטרציה של הלולאה ולכן אחרי מחיקת אלמנט והפעלה חדשה של render של ColorPalette כל פקדי ה ColorBox מכילים פונקציית onClick חדשה. הפקדים כבר לא זהים ולכן render יבוצע.

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