DOM של צל מוצהר

דרך חדשה להטמיע Shadow DOM ולהשתמש בו ישירות ב-HTML.

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

Shadow DOM הוא אחד משלושת התקנים של רכיבי אינטרנט, שמעוגלים באמצעות תבניות HTML ו-Custom Elements. פעולת הצללה של 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 Shadow Root הוא רכיב <template> עם המאפיין shadowrootmode:

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

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

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

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

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

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

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

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

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

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

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

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

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

שיוך של Root 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>

מנתח בלבד

DOM Delarative Shadow DOM הוא תכונה של מנתח ה-HTML. כלומר, שורש הצללה הצהרתי ינותח ויצורף רק לתגי <template> עם מאפיין shadowrootmode שנמצאים במהלך ניתוח ה-HTML. במילים אחרות, אפשר לבנות שורשים הצהרתיים של Shadow Roots במהלך ניתוח ה-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

כדי להימנע משיקולי אבטחה חשובים, אי אפשר גם ליצור שורשים הצהרתיים של Shadow Roots באמצעות ממשקי API לניתוח מקטעים כמו innerHTML או insertAdjacentHTML(). הדרך היחידה לנתח HTML עם החלה הצהרתית (Delarative Shadow Roots) היא להשתמש ב-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.

הימנעות מהבהוב של תוכן ללא סגנון

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

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

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

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

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

למרבה המזל, אפשר לפתור את הבעיה הזו ב-CSS על ידי שינוי כלל הסגנון של FOUC. בדפדפנים שתומכים ב-Delarative 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 החדש של פלטפורמת האינטרנט, Declarative Shadow DOM, אין עדיין תמיכה רחבה בכל הדפדפנים. כדי לאתר תמיכה בדפדפן, אפשר לבדוק אם קיים מאפיין shadowRootMode באב הטיפוס של HTMLTemplateElement:

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

פוליפיל

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

(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);

קריאה נוספת