אופטימיזציה של זיהוי השינויים של Angular

הטמעת זיהוי שינויים מהיר יותר לשיפור חוויית המשתמש.

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

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

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

כדי להבין איך פועל זיהוי השינויים ב-Angular, נבחן אפליקציה לדוגמה.

הקוד של האפליקציה זמין במאגר הזה ב-GitHub.

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

  • AppComponent, שהוא הרכיב הבסיסי של האפליקציה, ו-
  • שתי מכונות של EmployeeListComponent, אחת למכירות ואחת למחקר ופיתוח.

אפליקציה לדוגמה

אפשר לראות את שתי המופעים של EmployeeListComponent בתבנית של AppComponent:

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

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

עכשיו נבחן את EmployeeListComponent:

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Component(...)
export class EmployeeListComponent {
  @Input() data: EmployeeData[];
  @Input() department: string;
  @Output() remove = new EventEmitter<EmployeeData>();
  @Output() add = new EventEmitter<string>();

  label: string;

  handleKey(event: any) {
    if (event.keyCode === 13) {
      this.add.emit(this.label);
      this.label = '';
    }
  }

  calculate(num: number) {
    return fibonacci(num);
  }
}

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

זו התבנית של EmployeeListComponent:

<h1 title="Department">{{ department }}</h1>
<mat-form-field>
  <input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
  <mat-list-item *ngFor="let item of data">
    <h3 matLine title="Name">
      {{ item.label }}
    </h3>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

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

בעזרת שתי המכונות של EmployeeListComponent, האפליקציה יוצרת את עץ הרכיבים הבא:

עץ הרכיבים

AppComponent הוא הרכיב ברמה הבסיסית (root) של האפליקציה. רכיבי הצאצא שלו הם שתי המופעים של EmployeeListComponent. לכל מופע יש רשימה של פריטים (E1,‏ E2 וכו') שמייצגים את העובדים הספציפיים במחלקה.

כשהמשתמש מתחיל להזין את שם העובד החדש בתיבת הקלט ב-EmployeeListComponent, ‏Angular מפעילה זיהוי שינויים לכל עץ הרכיבים, החל מ-AppComponent. המשמעות היא שכשהמשתמש מקלידים את הקלט של הטקסט, Angular מחשבת מחדש שוב ושוב את הערכים המספריים שמשויכים לכל עובד כדי לוודא שהם לא השתנו מאז הבדיקה האחרונה.

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

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

  1. מקישים על Control+Shift+J (או על Command+Option+J ב-Mac) כדי לפתוח את DevTools.
  2. לוחצים על הכרטיסייה ביצועים.

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

יצירת פרופיל ביצועים

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

דילוג על עצי משנה של רכיבים

כשהמשתמש מקלידים את הקלט של הטקסט עבור EmployeeListComponent של sales, אתם יודעים שהנתונים במחלקה R&D לא משתנים – לכן אין סיבה להריץ זיהוי שינויים ברכיב שלה. כדי לוודא שמופע ה-R&D לא יפעיל זיהוי שינויים, מגדירים את changeDetectionStrategy של EmployeeListComponent ל-OnPush:

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

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

זיהוי שינויים ב-subtree של רכיב

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

מידע נוסף על שיטת זיהוי השינויים OnPush זמין במסמכי העזרה הרשמיים של Angular.

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

שימוש בצינורות טהורים

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

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

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

כך מעבירים את החישוב העסקי לצינור שנקרא CalculatePipe:

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

השיטה transform של הצינור מפעילה את הפונקציה fibonacci. שימו לב שהצינור נקי. מערכת Angular תתייחס לכל הצינורות כטהורים, אלא אם יצוין אחרת.

לבסוף, מעדכנים את הביטוי בתוך התבנית עבור EmployeeListComponent:

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

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

באפליקציה הבאה אפשר לראות כמה קל יותר להקליד!

כדי לראות את ההשפעה של האופטימיזציה האחרונה, אפשר לנסות את הדוגמה הזו ב-StackBlitz.

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

סיכום

אם אתם נתקלים בהאטה במהלך זמן הריצה באפליקציית Angular:

  1. בודקים את פרופיל האפליקציה באמצעות כלי הפיתוח ל-Chrome כדי לראות מה גורם להאטה.
  2. הצגת שיטת זיהוי השינויים OnPush לצורך גיזום של עצי משנה של רכיבים.
  3. העברת חישובים כבדים לצינורות טהורים כדי לאפשר למסגרת לבצע אחסון במטמון של הערכים המחושבים.