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

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

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

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

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

הסבר על זיהוי שינויים ב-Angular

כדי להבין איך עובד זיהוי השינויים ב-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 הוא רכיב הבסיס של האפליקציה. רכיבי הבן שלו הם שני המקרים של EmployeeListComponent. לכל מופע יש רשימה של פריטים (E1,‏ E2 וכו') שמייצגים את העובדים הנפרדים במחלקה.

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

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

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

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

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

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

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

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

כשמשתמש מקליד בשדה להזנת טקסט ב-sales EmployeeListComponent אתם יודעים שהנתונים במחלקה 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 {...}

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

זיהוי שינויים בעץ משנה של רכיב

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

בתיעוד הרשמי של Angular אפשר לקרוא מידע נוסף על האסטרטגיה של OnPushזיהוי שינויים.

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

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

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

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

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

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

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

סיכום

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

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