DOM של צל מוצהר

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

תמיכה בדפדפן

  • Chrome: ‏ 111.
  • קצה: 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 בשילוב עם עיבוד בצד השרת כי לא הייתה דרך מובנית לבטא Roots של Shadow Roots ב-HTML שנוצר על ידי השרת. יש גם השלכות על הביצועים בעת צירוף Shadow Roots לרכיבי DOM שכבר עברו עיבוד בלעדיהם. הדבר עלול לגרום לשינוי הפריסה לאחר טעינת הדף, או להציג באופן זמני הבהוב של תוכן ללא סגנון ('FOUC') בזמן טעינת גיליונות הסגנון של Shadow Root.

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

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

Root Shadow 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 להצגת תוכן של Shadow DOM. לדוגמה, התו מייצג תוכן DOM של אור מחורר.

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

מאזן הנוזלים של הרכיבים

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

רכיב מותאם אישית שמשדרגים מ-HTML וכולל Root צללי דקלרטיבי כבר יהיה מחובר ל-Root הצללי הזה. כלומר, לאלמנט יהיה כבר מאפיין shadowRoot זמין כשהוא נוצר בלי שהקוד שלכם יוצר מאפיין כזה באופן מפורש. מומלץ לבדוק ב-this.shadowRoot אם יש שורשים מוצלים קיימים ב-constructor של הרכיב. אם כבר קיים ערך, ה-HTML של הרכיב הזה כולל Root 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>

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

לרכיבים מותאמים אישית שנוצרו לאחרונה, נכס ElementInternals.shadowRoot חדש מספק דרך מפורשת לקבל הפניה לשורש הקיים של הצללית ההצהרתית, גם פתוח וגם סגור. אפשר להשתמש באפשרות הזו כדי לבדוק אם יש עץ צללים דקלרטיבי ולהשתמש בו, ועדיין לעבור ל-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);

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

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

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

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

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

שיוך של שורשים הצהרתיים של Shadow Roots ישירות לרכיב ההורה שלהם מפשט את תהליך השדרוג ומצרפים אותם לאלמנט הזה. שורשים הצהרתיים של Shadow Roots מזוהים במהלך ניתוח ה-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. פירוש הדבר הוא שניתוח נתוני שורש הצהרתי (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>

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

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

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

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

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

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

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

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

למרבה המזל, אפשר לפתור את הבעיה הזו ב-CSS על ידי שינוי כלל הסגנון של FOUC. בדפדפנים שתומכים ב-declarative Shadow DOM, הרכיב <template shadowrootmode> מומר מיד לרמה הבסיסית (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.

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

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

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

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

פוליפיל

תהליך הבנייה של polyfill פשוט עבור DOM 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);

קריאה נוספת