DOM של צל מוצהר

Declarative Shadow DOM היא תכונה רגילה בפלטפורמת אינטרנט, שנתמכת ב-Chrome מגרסה 90. חשוב לדעת שהמפרט של התכונה הזו השתנה בשנת 2023 (כולל שינוי השם של shadowroot ל-shadowrootmode), והגרסאות הסטנדרטיות העדכניות ביותר של כל חלקי התכונה נוספו ל-Chrome בגרסה 124.

תמיכה בדפדפנים

  • Chrome: ‏ 111.
  • Edge: ‏ 111.
  • Firefox: ‏ 123.
  • Safari: 16.4.

מקור

Shadow DOM הוא אחד משלושת תקני Web Components, יחד עם תבניות HTML ו-Custom Elements. Shadow DOM מספק דרך להגדיר את היקף הסגנונות של CSS להסתעפות ספציפית של עץ DOM ולבודד את ההסתעפות הזו משאר המסמך. הרכיב <slot> מאפשר לנו לקבוע איפה הצאצאים של רכיב מותאם אישית ייכנסו לעץ הצל שלו. השילוב של התכונות האלה מאפשר ליצור מערכת ליצירת רכיבים עצמאיים לשימוש חוזר, שאפשר לשלב בצורה חלקה באפליקציות קיימות בדיוק כמו רכיב HTML מובנה.

עד עכשיו, הדרך היחידה להשתמש ב-Shadow DOM הייתה ליצור root של צל באמצעות JavaScript:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

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

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

בעבר היה קשה להשתמש ב-Shadow DOM בשילוב עם עיבוד בצד השרת, כי לא הייתה דרך מובנית להביע שורשי צללים ב-HTML שנוצר על ידי השרת. יש גם השלכות על הביצועים כשמחברים שורשי צללים לרכיבי DOM שכבר הועברו ל-render בלי אותם שורשים. המצב הזה עלול לגרום לפריסת הדף לזוז אחרי שהוא נטען, או להציג באופן זמני הבזק של תוכן ללא עיצוב ('FOUC') בזמן טעינת גיליונות הסגנון של Root הצל.

Declarative Shadow DOM (DSD) מסיר את המגבלה הזו ומאפשר להעביר את Shadow DOM לשרת.

איך יוצרים Root צללי דקלרטיבי

Root צללי דקלרטיבי הוא רכיב <template> עם מאפיין shadowrootmode:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

מנתח ה-HTML מזהה אלמנט תבנית עם המאפיין shadowrootmode ומחילה אותו באופן מיידי כשורש הצל של רכיב ההורה שלו. טעינת תגי העיצוב הטהורים של ה-HTML מהדוגמה שלמעלה מובילה לעץ ה-DOM הבא:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

דוגמת הקוד הזו תואמת למוסכמות של חלונית הרכיבים ב-Chrome DevTools להצגת תוכן של Shadow DOM. לדוגמה, התו מייצג תוכן Light DOM בחריץ.

כך אנחנו יכולים ליהנות מהיתרונות של אנקפסולציה והקרנה של חריצים ב-Shadow DOM ב-HTML סטטי. אין צורך ב-JavaScript כדי ליצור את כל העץ, כולל Root בצל.

העשרת דפי HTML של רכיבים

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

רכיב מותאם אישית שמשדרגים מ-HTML וכולל Root צללי דקלרטיבי כבר יהיה מחובר ל-Root הצללי הזה. המשמעות היא שלאלמננט יהיה כבר מאפיין shadowRoot כשייוצר, בלי שהקוד ייצור אותו באופן מפורש. מומלץ לבדוק ב-this.shadowRoot אם יש שורשים מוצלים קיימים ב-constructor של הרכיב. אם כבר יש ערך, ה-HTML של הרכיב הזה כולל Declarative Shadow Root. אם הערך הוא null, לא היה ב-HTML שורש צללי דקלרטיבי או שהדפדפן לא תומך ב-Declarative Shadow DOM.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

רכיבי UI מותאמים אישית קיימים כבר זמן מה, ועד עכשיו לא הייתה סיבה לבדוק אם כבר קיים שורש צל לפני שיוצרים אותו באמצעות attachShadow(). ב-Declarative Shadow DOM יש שינוי קטן שמאפשר לרכיבים קיימים לפעול למרות זאת: קריאה ל-method‏ attachShadow() על אלמנט עם שורש צל דקלרטיבי קיים לא תגרום להצגת שגיאה. במקום זאת, ה-Declarative Shadow Root מתרוקן ומוחזר. כך רכיבים ישנים שלא נוצרו עבור Declarative Shadow DOM ימשיכו לפעול, כי שורשים מצהירים נשמרים עד ליצירת תחליף אימפרטיבי.

ברכיבים מותאמים אישית שנוצרו לאחרונה, המאפיין החדש ElementInternals.shadowRoot מספק דרך מפורשת לקבל הפניה ל-Declarative Shadow Root הקיים של רכיב, גם פתוח וגם סגור. אפשר להשתמש באפשרות הזו כדי לבדוק אם יש עץ צללים דקלרטיבי ולהשתמש בו, ועדיין לעבור ל-attachShadow() במקרים שבהם לא סופק עץ כזה.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

צל אחד לכל שורש

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

החיסרון של שיוך שורשי צללים לרכיב ההורה שלהם הוא שלא ניתן לאתחל כמה רכיבים מאותו שורש צללים דקלרטיבי <template>. עם זאת, סביר להניח שזה לא ישנה הרבה ברוב המקרים שבהם נעשה שימוש ב-Declarative Shadow DOM, כי התוכן של כל שורש צל הוא לרוב שונה. HTML שעבר עיבוד בשרת מכיל לעיתים קרובות מבני רכיבים חוזרים, אבל התוכן שלהם בדרך כלל שונה – לדוגמה, וריאציות קלות בטקסט או במאפיינים. מכיוון שהתוכן של Declarative Shadow Root בסריאליזציה הוא סטטי לחלוטין, שדרוג של כמה רכיבים מ-Declarative Shadow Root יחיד יפעל רק אם הרכיבים זהים. לבסוף, ההשפעה של שורשי צל דומים חוזרים על גודל ההעברה ברשת היא קטנה יחסית בגלל השפעות הדחיסה.

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

סטרימינג זה מגניב

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

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

מנתח בלבד

Declarative Shadow DOM היא תכונה של מנתח ה-HTML. המשמעות היא שניתוח וחיבור של Declarative Shadow Root יתבצעו רק עבור תגי <template> עם מאפיין shadowrootmode שנמצאים במהלך ניתוח ה-HTML. במילים אחרות, אפשר ליצור שורשי צלילה מוצהריים במהלך הניתוח הראשוני של HTML:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

הגדרת המאפיין shadowrootmode של רכיב <template> לא גורמת לשום דבר, והתבנית נשארת רכיב תבנית רגיל:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

כדי להימנע מחששות אבטחה חשובים, אי אפשר ליצור גם שורשים מוצלים ודקלרטיביים באמצעות ממשקי API לניתוח קטעי טקסט כמו innerHTML או insertAdjacentHTML(). הדרך היחידה לנתח HTML עם שורשי צללים מוצהריים היא להשתמש ב-setHTMLUnsafe() או ב-parseHTMLUnsafe():

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

עיבוד בצד השרת עם סגנון

יש תמיכה מלאה בגיליונות סגנונות מוטמעים וחיצוניים בתוך שורשי צללים מוצגים באמצעות התגים הרגילים <style> ו-<link>:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

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

אין תמיכה ב-Declarative Shadow DOM בגיליונות סגנונות שניתן ליצור אותם. הסיבה לכך היא שבשלב הזה אין דרך לסדר בסדרת נתונים גיליונות סגנונות שניתן ליצור ב-HTML, ואין דרך להפנות אליהם כשמאכלסים את adoptedStyleSheets.

איך להימנע מהצגה קצרה של תוכן ללא עיצוב

בעיה פוטנציאלית אחת בדפדפנים שעדיין לא תומכים ב-Declarative Shadow DOM היא הימנעות מ'הבזק של תוכן ללא עיצוב' (FOUC), שבו התוכן הגולמי מוצג לאלמנטים מותאמים אישית שעדיין לא שודרגו. לפני ש-Declarative Shadow DOM היה קיים, אחת מהשיטות הנפוצות להימנעות מ-FOUC הייתה להחיל כלל סגנון display:none על רכיבי Custom שעדיין לא נטענו, כי עדיין לא חובר והאוכלס שורש הצל שלהם. כך התוכן לא יוצג עד שהוא יהיה 'מוכן':

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

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

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

במקרה כזה, הכלל display:none 'FOUC' ימנע את הצגת התוכן של שורש הצל הдекларטיבי. עם זאת, הסרת הכלל הזה תגרום לדפדפנים ללא תמיכה ב-Declarative Shadow DOM להציג תוכן שגוי או ללא סגנון, עד ש-polyfill של Declarative Shadow DOM יטמיע את התבנית של שורש הצל והמיר אותה לשורש צל אמיתי.

למרבה המזל, אפשר לפתור את הבעיה ב-CSS על ידי שינוי כלל הסגנון של FOUC. בדפדפנים שתומכים ב-Declarative Shadow DOM, הרכיב <template shadowrootmode> מומר באופן מיידי ל-shadow root, כך שלא נשאר רכיב <template> בעץ ה-DOM. בדפדפנים שלא תומכים ב-Declarative Shadow DOM, הרכיב <template> נשמר, ואנחנו יכולים להשתמש בו כדי למנוע FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

במקום להסתיר את הרכיב המותאם אישית שעדיין לא הוגדר, הכלל המעודכן 'FOUC' מסתיר את הצאצאים שלו כשהם מופיעים אחרי אלמנט <template shadowrootmode>. אחרי הגדרת הרכיב המותאם אישית, הכלל לא יתאים יותר. הדפדפנים שתומכים ב-Declarative Shadow DOM מתעלמים מהכלל הזה כי הצאצא <template shadowrootmode> מוסר במהלך ניתוח ה-HTML.

זיהוי תכונות ותמיכה בדפדפנים

‏Declarative Shadow DOM זמין מאז Chrome 90 ו-Edge 91, אבל הוא השתמש במאפיין לא סטנדרטי ישן יותר שנקרא shadowroot במקום במאפיין הסטנדרטי shadowrootmode. המאפיין shadowrootmode וההתנהגות החדשה של הסטרימינג זמינים ב-Chrome 111 וב-Edge 111.

כ-API חדש של פלטפורמת אינטרנט, עדיין אין תמיכה רחבה ב-Declarative Shadow DOM בכל הדפדפנים. כדי לזהות את תמיכת הדפדפן, בודקים אם קיים נכס shadowRootMode באב טיפוס של HTMLTemplateElement:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

פוליפיל

יצירת polyfill פשוט ל-Declarative Shadow DOM היא פשוטה יחסית, כי polyfill לא צריך לשחזר בצורה מושלמת את סמנטיקה של התזמון או את המאפיינים של הניתוח בלבד שההטמעה בדפדפן מתמקדת בהם. כדי להוסיף תמיכה ל-Declarative Shadow DOM, אפשר לסרוק את ה-DOM כדי למצוא את כל הרכיבים מסוג <template shadowrootmode>, ואז להמיר אותם לשורשי צל מצורפים ברכיב ההורה שלהם. אפשר לבצע את התהליך הזה כשהמסמך מוכן, או להפעיל אותו באמצעות אירועים ספציפיים יותר, כמו מחזורי חיים של רכיבים מותאמים אישית.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

קריאה נוספת