איך למצוא ולתקן render מיותר ביישום ריאקט

17/02/2019

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

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

מה שיפה בריאקט זה שאין יותר מדי קסמים - ההחלטה אם לקרוא או לא ל render היא תוצאה של פונקציה אחת בלבד: הפונקציה shouldComponentUpdate. זה אומר שאם אתם חושדים בקריאות מיותרות ל render קודם כל תרצו להיכנס לפונקציה הזאת ולראות מה היא מחזירה ואם עדיף שהיא תחזיר משהו אחר. בגדול אם פונקציה זו מחזירה true אז render ייקרא, ואם false אז נדלג על render.

מימוש ברירת המחדל של shouldComponentUpdate מחזיר תמיד true, מה שאומר שכל שינוי ב props או ב state של הפקד יביא להפעלת render מחדש. מחלקת אב בשם React.PureComponent מספקת מימוש קצת יותר יעיל שמבצע השוואת Shallow Comparison בין ה props החדש לישן ובין ה state החדש לישן, ורק אם יש הבדל יחזיר true.

לכן בשביל לזהות ולתקן פעולות render מיותרות נרצה לבצע:

  1. לוודא ש render מופעל יותר פעמים ממה שהיינו רוצים (אפשר לשים נקודת עצירה או אפילו הדפסת console.log)

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

  3. אם גילינו שהמימוש שלנו מבצע Shallow Compare - אפשר למחוק את הפונקציה שכתבנו ולהחליף את היררכיית הירושה כך שהפקד יירש מ PureComponent (לא חייבים).

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

אם אתם רואים את זה מהמייל ולא בא לכם להיכנס לקודפן זה קוד ה JavaScript שמפעיל את הסיפור:

class Item extends React.PureComponent {
  render() {
    const cls = this.props.winner ? 'red box' : 'grey box';
    return <div className={cls} onClick={this.props.onClick} />
  }
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { winner: 4 };
  }

  shuffle() {
    this.setState({
      winner: _.random(10),
    })
  }

  render() {
    return (
      <div>
        {_.range(10).map(i => (
          <Item winner={this.state.winner == i} onClick={() => this.shuffle()} />
        ))}
      </div>
    );
  }
}

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

קודם כל נרצה לשאול - בעת לחיצה על אחד הכפתורים (שגורמת להזזה של הריבוע האדום); כמה render-ים מבוצעים? דרך קלה לענות על זה היא להוסיף פקודת console.count בתוך פונקציית ה render של Item. כלומר הקומפוננטה תיראה כך:

class Item extends React.PureComponent {
  render() {
    console.count('Item::render');
    const cls = this.props.winner ? 'red box' : 'grey box';
    return <div className={cls} onClick={this.props.onClick} />
  }
}

התוצאה הלא מפתיעה היא שאחרי כל לחיצה יש לנו 10 הודעות:

console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 1
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 2
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 3
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 4
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 5
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 6
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 7
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 8
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 9
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 10

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

class Item extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    const sameWinner = nextProps.winner === this.props.winner;
    const sameOnClick = nextProps.onClick === this.props.onClick;
    console.log('<<<<<- ');
    console.log('Same class: ', sameWinner);
    console.log('Same on click: ', sameOnClick);
    console.log('>>>>>- ');
    return !(sameWinner && sameOnClick);
  }

  render() {
    console.count('Item::render');
    const cls = this.props.winner ? 'red box' : 'grey box';
    return <div className={cls} onClick={this.props.onClick} />
  }
}

והתוצאה:

<<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 1
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 2
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 3
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 4
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 5
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 6
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 7
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 8
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 9
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 <<<<<- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same class:  true
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Same on click:  false
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 >>>>>- 
console_runner-1df7d3399bdc1f40995a35209755dcfd8c7547da127f6469fd81e5fba982f6af.js:1 Item::render: 10

ואכן אנחנו רואים בהדפסה שרק הריבועים שצריכים להשתנות קיבלו ערכים אחרים כשאנחנו משווים את props.winner, אבל בהשוואת props.onClick כל ריבוע קיבל ערך אחר בכל render. הסיבה נעוצה בשורה:

          <Item winner={this.state.winner == i} onClick={() => this.shuffle()} />

בכל פעם שאנחנו מפעילים את ה render של App אנחנו גם יוצרים פונקציית onClick חדשה! ולכן כל render של Item מקבל בתור Property את ה onClick החדשה, ההשוואה מחזירה שיש הבדל ב Property זה וכך מגיעים לקריאות מיותרות ל render. דרך קלה לתקן את זה אחרי שהבנו את הבעיה היא להעביר את ה bind לבנאי כך שפקד App יראה כך:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { winner: 4 };
    this.shuffle = this.shuffle.bind(this);
  }

  shuffle() {
    this.setState({
      winner: _.random(10),
    })
  }

  render() {
    return (
      <div>
        {_.range(10).map(i => (
          <Item winner={this.state.winner == i} onClick={this.shuffle} />
        ))}
      </div>
    );
  }
}

ואכן אחרי השינוי כל לחיצה מייצרת רק שתי הדפסות Item::render במקום 10. הקוד המתוקן המלא נמצא בקודפן הזה:

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