DOM של צל מוצהר

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

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

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

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

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

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

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

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

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

בניית שורש הצהרתי

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

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

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

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

לרכיב מותאם אישית שמשודרג מ-HTML שכולל Root מוצהר, כבר תצורף השורש של הצללית. כלומר, לאלמנט יהיה מאפיין shadowRoot כבר זמין כשיוצרים יצירת אובייקט, בלי שהקוד שלכם ייצור כזה באופן מפורש. מומלץ לבדוק ב-this.shadowRoot אם יש שורש צל קיים בבנאי של הרכיב. אם כבר קיים ערך, ה-HTML של הרכיב הזה כולל בסיס מוצהר (Dellarative Shadow Root). אם הערך הוא null, לא היה שם Root מוצהר ב-HTML, או שהדפדפן לא תומך ב-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(). DOM של Shadow הצהרתי כולל שינוי קטן שמאפשר לרכיבים קיימים לפעול למרות זאת: קריאה ל-method attachShadow() באלמנט עם Declarative Shadow Root קיימת לא תוביל לשגיאה. במקום זאת, השורש המוצהר מרוקן ומוחזר. כך רכיבים ישנים יותר שלא נוצרו ל-DOM של צללית מדויקת (Declarative Shadow DOM) להמשיך לפעול, כי שורשים מוצהרים נשמרים עד ליצירת החלפה חיונית.

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

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

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

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

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

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

שיוך של שורשי צל מוצהרים ישירות לאלמנט ההורה שלהם מפשט את תהליך השדרוג וצירוף שלהם לאלמנט הזה. שורשי צל מוצהרים מזוהים במהלך ניתוח 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 של צל מוצהר הוא תכונה של מנתח ה-HTML. המשמעות היא ש-Dellarative 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 עם שורשי צללים מוצהרים היא להעביר אפשרות includeShadowRoots חדשה ל-DOMParser:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root 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 לגיבוי אחד שמשותף לכל שורשי הצללית, וכך מבוטלת תקורת הזיכרון הכפולה.

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

הימנעות מהבזק תוכן לא מעוצב

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

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

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

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

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

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

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

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

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

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

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

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

פוליפיל

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

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

קריאה נוספת