רכיבים מותאמים אישית

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

מחזור חיים: החלקים שמוטמעים בצורה שגויה

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

  • אפשר להריץ את connectedCallback יותר מפעם אחת. הוא מופעל בכל הוספה, כך שהעברת הרכיב מפעילה אותו מחדש. ההגדרה צריכה להיות אידמפוטנטית – אל תניחו שהיא תופעל רק פעם אחת.
  • ‫connectedMoveCallback מדלג על צמד הניתוק/החיבור מחדש בהעברה. כשמעבירים רכיב באמצעות Node.moveBefore(), הקריאה החוזרת הזו מופעלת במקום disconnectedCallback ← connectedCallback, כך שהמצב, המיקוד וההפעלה של iframe או מדיה נשמרים אחרי ההעברה. הגדירו את זה כשההגדרה או הפירוק יקרים או כשהם פועלים שוב באופן גלוי.
  • פירוק תוך disconnectedCallback. הסירו מאזינים, טיימרים ומשקיפים גלובליים, אחרת תהיה דליפה. עם זאת, יכול להיות שהרכיב יוכנס מחדש, אז אל תתייחסו לפירוק כקבוע.
  • צריך להפעיל את super() קודם ב-constructor, ולא להסתמך על כך שכל ה-DOM יהיה קיים. הפעלת super() אחרי נגיעה ב-this גורמת ל-ReferenceError. בדרך כלל (אבל לא תמיד) יש מאפיינים. יכול להיות שרשימות ארוכות של צאצאים לא סיימו את הניתוח. משתמשים ב-connectedCallback לאתחול חד-פעמי, ב-slotchange למעקב אחרי צאצאים ישירים וב-attributeChangedCallback למעקב אחרי מאפיינים. יכול להיות שהאירוע connectedCallback עדיין יופעל לפני שכל הרכיבים המשניים ינותחו, לכן אל תתעלמו מהקריאות הבאות ל-slotchange או ל-attributeChangedCallback.
  • ‫attributeChangedCallback מופעל רק עבור observedAttributes, וצריך להישאר זול עבור מאפיינים שלא משפיעים על הפלט.
  • ה-lifecycle hooks ו-observedAttributes נשמרים בתמונת מצב בזמן הרישום. ‫customElements.define() קורא את שניהם פעם אחת. הוספת הוק או הרחבה של observedAttributes במחלקה אחרי הקריאה לא תגרום לשום פעולה. באופן ספציפי, attributeChangedCallback אף פעם לא מופעל אלא אם observedAttributes כבר היה קיים בזמן define().

מועד השדרוג ו:defined

רכיב יכול להיות קיים ב-DOM לפני שהמחלקה שלו נרשמת (ב-HTML שעבר עיבוד בצד השרת, או כשהסקריפט נטען באיחור). עד שמגדירים אותו, הוא HTMLElement אינרטי ללא ההתנהגות שלך. כש-customElements.define() פועל, הדפדפן משדרג כל רכיב תואם שכבר נמצא במסמך, ומפעיל את ה-constructor שלו ואת connectedCallback.

שתי השלכות שהמודלים בדרך כלל מפספסים:

  1. יכול להיות שהמאפיינים והצאצאים כבר קיימים כשה-constructor או ה-connectedCallback פועלים במהלך השדרוג. קוראים את המאפיינים הקיימים ב-connectedCallback כדי לסנכרן את המצב הראשוני, ולא מניחים שהרכיב התחיל ריק.
  2. יכול להיות שעדיין יהיו חסרים צאצאים אפילו ב-connectedCallback — מנתח ה-HTML יכול להניב ניתוח חלקי של רשימות צאצאים ארוכות, כך שהקריאה החוזרת לפעמים פועלת לפני שכל צאצא מצורף. התבנית (slotchange + attributeChangedCallback) מופיעה בתבליט של ה-constructor בקטע מחזור חיים שלמעלה.

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

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

הגדירו את ההיקף של אמצעי ההגנה האלה לרכיבים שזקוקים להם, או לעץ משנה ספציפי באמצעות :has(:not(:defined)), במקום לכל ה-body. ווידג'ט אחד איטי ולא קריטי (למשל, כפתור שיתוף) לא אמור לעכב את העיבוד של הדף כולו.

השתקפות מאפיין ⇄ נכס

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

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

איפה הערך נמצא

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

ברכיבים עשירים יותר, כדאי לשמור את מקור האמת בשדה JS ולשקף כלפי חוץ למאפיין רק כשמשהו אחר צריך לראות אותו – טירגוט CSS או צרכן שקורא אותו. קריאת שדה היא הרבה יותר זולה מניתוח ואילוץ של מאפיין בכל גישה, וערכים שאינם מחרוזות אף פעם לא עוברים סבב של סריאליזציה. לכן ספריות כמו Lit שומרות את המצב ב-JS ומשקפות באופן סלקטיבי, במקום להתייחס למאפיינים כאל אחסון: משקפים רק את מה שהעולם החיצוני חייב לראות, ולא כל מאפיין.

בעיות נפוצות:

  • משמעת observedAttributes: צריך לציין רק מאפיינים שהשינוי שלהם מחייב עיבוד מחדש או מפעיל תופעת לוואי. אם צופים בכל דבר, נוסף קריאה חוזרת (callback) לכל מוטציה בלי שום תועלת.
  • לולאות השתקפות: פונקציית setter שכותבת מאפיין תיכנס מחדש אל attributeChangedCallback. צריך להגן באמצעות בדיקת oldVal !== newVal, כמו למעלה.
  • מצב של סגנון בלבד: למצב פנימי שקיים רק כדי להפעיל סגנון (בטעינה, פעיל, מורחב), עדיף להשתמש במצבים בהתאמה אישית במקום לשקף מאפיין סינתטי – ראו מצבים בהתאמה אישית בהמשך. משקפים למאפיין רק כשצרכנים צריכים להיות מסוגלים להגדיר את המצב מתגי עיצוב.

מצבים בהתאמה אישית

מצבים מותאמים אישית הם דגלים בוליאניים שרכיב חושף לגבי עצמו כדי ש-CSS יטרגט אותם. הם מוגדרים באמצעות ElementInternals ומתאימים למחלקה המדומה :state(). השתמשו בהם למצב ממשק משתמש פנימי בזמן ריצה – busy, expanded, invalid – במקום לשקף מאפיין סינתטי: הם לא יכולים להתנגש עם מאפיינים של צרכן, אף פעם לא מופיעים ב-DOM, והם לקריאה בלבד מבחוץ (צרכן יכול לעצב את :state() אבל לא יכול לזייף אותו).

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

‫CustomStateSet הוא קבוצה, כך שמצבים עצמאיים מרכיבים (busy פלוס error) בלי להמציא enum משולב של data-status.

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

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

מוסכמות למתן שמות

  • מצב פרטי אמיתי: שדות # מקוריים (#count) להכמסה קשיחה; קידומת _ רק כשמחלקות משנה עדיין צריכות להגיע אליה. שימו לב ששדות # לא עוברים בירושה – מחלקת משנה לא יכולה לראות את השדות הפרטיים של מחלקה בסיסית.
  • מאפיינים עם מפתחות סמלים הם פתרון ביניים: הם מוסתרים מהספירה הרגילה, אבל כל קוד שמכיל את הסמל יכול לגשת אליהם (אפשר לייצא אותו או לחשוף אותו כשדה static במחלקה). מכיוון שכל סמל הוא ייחודי, שני סמלים שחולקים תיאור אף פעם לא מתנגשים – זה שימושי כשמחלקה משנית או מחלקה אחות צריכה גישה מבוקרת ששדות # לא יכולים לספק.
  • אל תסתירו מאפיינים גלובליים (style, class, id, slot, part, title, lang, dir) באמצעות מאפיינים משלכם. פעולה כזו משבשת את ההתנהגות המובנית שלהם. הערה: disabled הוא גלובלי רק בפקדי טפסים. ברכיבים אחרים צריך להטמיע את האפקט שלו בעצמכם.

כשנותנים שם לממשק API חדש, צריך לפעול לפי המוסכמות של הפלטפורמה: להשתמש בפורמט kebab case באותיות קטנות עם מקף אחד לפחות בשמות של תגי רכיבים מותאמים אישית (לדוגמה my-toggle), ב-lowerCamelCase למאפיינים ולשיטות, ובשמות של מאפיינים באותיות קטנות שמסונכרנים עם המאפיינים התואמים שלהם.