גישה בטוחה ל-DOM באמצעות Angular SSR

Gerald Monaco
Gerald Monaco

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

לצערנו, יש דפוס אחד שעלול למנוע מהאפליקציה או מהספרייה שלכם לנצל את כל התכונות החדשות והתכונות שצפויות בקרוב: מניפולציה ידנית של מבנה ה-DOM הבסיסי. ב-Angular, המבנה של DOM נשאר עקבי מרגע שהשרת מסדר את הרכיב בסדרה, ועד שהוא מתחדש בדפדפן. שימוש ב-ElementRef, ב-Renderer2 או בממשקי API של DOM כדי להוסיף, להעביר או להסיר צמתים באופן ידני מ-DOM לפני ההוספה שלהם למבנה יכול לגרום לאי-עקביות שמונעת את הפעולה של התכונות האלה.

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

הימנעות ממניפולציה ידנית של DOM

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

שינוי רכיב DOM של רכיב מסוים

כשכותבים רכיב או הוראה, ייתכן שיהיה צורך לשנות את רכיב ה-host (כלומר, רכיב ה-DOM שתואם לבורר של הרכיב או ההוראה) כדי, למשל, להוסיף מחלקה, סגנון או מאפיין, במקום לטרגט או להציג רכיב wrapper. קל להשתמש ב-ElementRef כדי לשנות את רכיב ה-DOM הבסיסי. במקום זאת, צריך להשתמש בקישורי מארח כדי לקשר את הערכים לביטוי בהצהרה:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

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

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

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

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

שינוי של רכיבי DOM מחוץ לתבנית

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

במקום זאת, הרכיב צריך להתייחס לכל רכיב אחר כקופסה שחורה. כדאי להקדיש זמן כדי לחשוב מתי ואיפה רכיבים אחרים (אפילו באותה אפליקציה או ספרייה) עשויים להזדקק לאינטראקציה עם הרכיב או להתאמה אישית של ההתנהגות או המראה שלו, ואז לחשוף דרך בטוחה ומתוועדת לעשות זאת. אפשר להשתמש בתכונות כמו הזרקת יחסי תלות היררכיים כדי להפוך ממשק API לזמין להסתעפות עץ משנה כשמאפיינים פשוטים של @Input ו-@Output לא מספיקים.

בעבר, היה מקובל להטמיע תכונות כמו תיבת דו-שיח מודאלית או טיפים על ידי הוספת רכיב לסוף ה-<body> או לרכיב מארח אחר, ולאחר מכן העברת תוכן או הקרנת תוכן שם. עם זאת, בימים אלה סביר להניח שתוכלו להציג במקום זאת רכיב <dialog> פשוט בתבנית:

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

דחייה של מניפולציית DOM ידנית

אחרי שתשתמשו בהנחיות הקודמות כדי לצמצם ככל האפשר את הגישה והמניפולציה הישירה של DOM, יכול להיות שעדיין יהיו לכם פעולות שאי אפשר להימנע מהן. במקרים כאלה, חשוב לדחות את העניין כמה שיותר. קריאות חוזרות (callback) של afterRender ו-afterNextRender הן דרך מצוינת לעשות זאת, כי הן פועלות רק בדפדפן, אחרי ש-Agular בדקה אם יש שינויים והתחייבה אותם ל-DOM.

הפעלת JavaScript בדפדפן בלבד

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

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

ביצוע פריסה מותאמת אישית

לפעמים צריך לקרוא או לכתוב ב-DOM כדי לבצע פריסה מסוימת שעדיין לא נתמכת בדפדפני היעד, כמו מיקום של תיבת עזרה. afterRender היא בחירה מצוינת לכך, כי אפשר להיות בטוחים שה-DOM נמצא במצב עקבי. afterRender ו-afterNextRender מקבלים ערך phase של EarlyRead, Read או Write. קריאה של פריסת ה-DOM אחרי הכתיבה מאלצת את הדפדפן לחשב מחדש את הפריסה באופן סינכרוני. מצב כזה יכול להשפיע באופן משמעותי על הביצועים (למידע נוסף: סינון מחדש של הפריסה). לכן חשוב לפצל את הלוגיקה שלכם בקפידה לשלבים המתאימים.

לדוגמה, רכיב של תיאור קצר שרוצים להציג תיאור קצר ביחס לאלמנט אחר בדף, ישתמש בשני שלבים. השלב EarlyRead ישמש קודם כל כדי לקבל את הגודל והמיקום של הרכיבים:

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

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

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

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

סיכום

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