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

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

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

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

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

זיהוי השינויים של Inside Angular

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

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

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

  • AppComponent, שהוא הרכיב הבסיסי (root) של האפליקציה, וגם
  • שני מופעים של 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) כדי לפתוח את כלי הפיתוח.
  2. לוחצים על הכרטיסייה ביצועים.

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

פרופיילינג של ביצועים

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

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

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

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

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

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

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

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

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

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

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

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

כך מעבירים את החישוב העסקי לצינור עיבוד נתונים בשם 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);
  }
}

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

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

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

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

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

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

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

סיכום

במקרה של האטה בזמן ריצה באפליקציה Angular:

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