‫Shadow DOM v1 – רכיבי אינטרנט עצמאיים

‫Shadow DOM מאפשר למפתחי אתרים ליצור DOM ו-CSS מחולקים לרכיבי אינטרנט

סיכום

‫Shadow DOM מסיר את השבריריות של פיתוח אפליקציות אינטרנט. השבריריות נובעת מהאופי הגלובלי של HTML,‏ CSS ו-JS. במהלך השנים המצאנו מספר עצום של כלים כדי לעקוף את הבעיות. לדוגמה, כשמשתמשים במזהה או במחלקה חדשים של HTML, אי אפשר לדעת אם הם יתנגשו עם שם קיים שמופיע בדף. באגים קטנים מתחילים להצטבר, הספציפיות של CSS הופכת לבעיה גדולה (!important כל הדברים!), בוררי הסגנונות יוצאים משליטה והביצועים עלולים להיפגע. הרשימה עוד ארוכה.

Shadow DOM מתקן את CSS ו-DOM. הוא מוסיף סגנונות ברמת ההיקף לפלטפורמת האינטרנט. בלי כלים או מוסכמות למתן שמות, אתם יכולים לשלב CSS עם תגי עיצוב, להסתיר פרטי הטמעה וליצור רכיבים עצמאיים ב-JavaScript וניל.

מבוא

‫Shadow DOM הוא אחד משלושת התקנים של Web Component: תבניות HTML, Shadow DOM ו רכיבים בהתאמה אישית. ייבוא HTML היה בעבר חלק מהרשימה, אבל עכשיו הוא נחשב ללא בשימוש.

אתם לא צריכים ליצור רכיבי אינטרנט שמשתמשים ב-shadow DOM. אבל כשמשתמשים בו, נהנים מהיתרונות שלו (היקף CSS, אנקפסולציה של DOM, קומפוזיציה) ויוצרים רכיבים מותאמים אישית לשימוש חוזר, שהם גמישים, ניתנים להגדרה ומתאימים מאוד לשימוש חוזר. אם רכיבים בהתאמה אישית הם הדרך ליצור HTML חדש (עם JS API), אז Shadow DOM הוא הדרך לספק את ה-HTML וה-CSS שלו. שני ממשקי ה-API משתלבים כדי ליצור רכיב עם HTML,‏ CSS ו-JavaScript עצמאיים.

Shadow DOM הוא כלי לפיתוח אפליקציות שמבוססות על רכיבים. לכן, הוא מביא פתרונות לבעיות נפוצות בפיתוח אתרים:

  • DOM מבודד: ה-DOM של הרכיב הוא עצמאי (לדוגמה, document.querySelector() לא יחזיר צמתים ב-shadow DOM של הרכיב).
  • CSS מוגבל: CSS שמוגדר בתוך Shadow DOM מוגבל אליו. כללי הסגנון לא זולגים החוצה וסגנונות הדף לא זולגים פנימה.
  • קומפוזיציה: תכנון API דקלרטיבי שמבוסס על תגי עיצוב לרכיב.
  • מפשט את ה-CSS – DOM מוגדר מאפשר להשתמש בסלקטורים ב-CSS פשוטים, בשמות כלליים יותר של מזהים/מחלקות, ולא לדאוג לגבי התנגשויות בשמות.
  • פרודוקטיביות – כדאי לחשוב על אפליקציות כעל מקטעים של DOM ולא כעל דף אחד גדול (גלובלי).

הדגמה של fancy-tabs

במאמר הזה, אתייחס לרכיב הדגמה (<fancy-tabs>) ולקטעי קוד ממנו. אם הדפדפן שלכם תומך בממשקי ה-API, תראו הדגמה שלהם בשידור חי ממש כאן למטה. אחרת, אפשר לעיין בקוד המקור המלא ב-GitHub.

הצגת המקור ב-GitHub

מה זה Shadow DOM?

רקע על DOM

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

כשהדפדפן טוען דף אינטרנט, הוא מבצע כמה פעולות מעניינות. אחד מהדברים שהוא עושה הוא להפוך את ה-HTML של המחבר למסמך פעיל. כדי להבין את מבנה הדף, הדפדפן מנתח את ה-HTML (מחרוזות טקסט סטטיות) למודל נתונים (אובייקטים/צמתים). הדפדפן שומר על ההיררכיה של ה-HTML על ידי יצירת עץ של הצמתים האלה: ה-DOM. הקטע המגניב ב-DOM הוא שהוא ייצוג חי של הדף. בניגוד ל-HTML הסטטי שאנחנו יוצרים, הצמתים שהדפדפן מייצר מכילים מאפיינים, שיטות והכי חשוב… אפשר לשנות אותם באמצעות תוכנות! לכן אנחנו יכולים ליצור רכיבי DOM ישירות באמצעות JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

יוצר את תגי העיצוב הבאים ב-HTML:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

זה נחמד וטוב. אז מה זה בכלל Shadow DOM?

‫DOM… בצללים

Shadow DOM הוא DOM רגיל עם שני הבדלים: 1) איך הוא נוצר/משמש ו-2) איך הוא מתנהג ביחס לשאר הדף. בדרך כלל, יוצרים צומתי DOM ומוסיפים אותם כצאצאים של רכיב אחר. בעזרת shadow DOM, אתם יוצרים עץ DOM מוגדר שמצורף לרכיב, אבל נפרד מהצאצאים שלו בפועל. עץ המשנה הזה נקרא עץ צל. הרכיב שאליו הוא מחובר הוא המארח של ה-Shadow DOM. כל מה שמוסיפים ב-shadow DOM הופך למקומי לאלמנט המארח, כולל <style>. כך Shadow DOM משיג את היקף הסגנון של CSS.

יצירת Shadow DOM

שורש צל הוא קטע ממסמך שמצורף לרכיב "מארח". הפעולה של צירוף שורש צל היא הדרך שבה הרכיב מקבל את ה-shadow DOM שלו. כדי ליצור shadow DOM לרכיב, צריך להפעיל את element.attachShadow():

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

אני משתמש ב-.innerHTML כדי למלא את שורש הצל, אבל אפשר גם להשתמש בממשקי API אחרים של DOM. זה האינטרנט. יש לנו אפשרות בחירה.

המפרט מגדיר רשימה של רכיבים שלא יכולים לארח עץ צל. יש כמה סיבות לכך שרכיב מסוים מופיע ברשימה:

  • הדפדפן כבר מארח את ה-Shadow DOM הפנימי שלו לרכיב (<textarea>, <input>).
  • אין סיבה שהרכיב יארח Shadow DOM‏ (<img>).

לדוגמה, הפורמט הזה לא יעבוד:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

יצירת shadow DOM לרכיב מותאם אישית

Shadow DOM הוא שימושי במיוחד כשיוצרים רכיבים בהתאמה אישית. משתמשים ב-shadow DOM כדי להפריד בין ה-HTML, ה-CSS וה-JS של רכיב, וכך ליצור "רכיב אינטרנט".

דוגמה – רכיב בהתאמה אישית מצרף לעצמו Shadow DOM, ומכיל את ה-DOM או ה-CSS שלו:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

יש כאן כמה דברים מעניינים. הראשון הוא שהרכיב המותאם אישית יוצר DOM משלו כשנוצר מופע של <fancy-tabs>. את זה עושים בconstructor(). שנית, בגלל שאנחנו יוצרים שורש צל, כללי ה-CSS בתוך <style> יוגבלו ל-<fancy-tabs>.

קומפוזיציה ומשבצות

קומפוזיציה היא אחת התכונות הכי פחות מובנות של Shadow DOM, אבל היא כנראה החשובה ביותר.

בעולם של פיתוח אתרים, קומפוזיציה היא הדרך שבה אנחנו בונים אפליקציות, באופן הצהרתי מתוך HTML. אבני בניין שונות (<div>, <header>, <form>, <input>) משתלבות יחד כדי ליצור אפליקציות. חלק מהתגים האלה אפילו פועלים ביחד. קומפוזיציה היא הסיבה לכך שרכיבים מובנים כמו <select>, <details>, <form> ו-<video> הם כל כך גמישים. כל אחד מהתגים האלה מקבל HTML מסוים כצאצאים ומבצע איתם פעולה מיוחדת. לדוגמה, <select> יודע להציג את <option> ו-<optgroup> בתור ווידג'טים של תפריט נפתח ובחירה מרובה. הרכיב <details> מעבד את <summary> כחץ שניתן להרחבה. אפילו <video> יודע איך להתמודד עם ילדים מסוימים: רכיבי <source> לא מוצגים, אבל הם משפיעים על ההתנהגות של הסרטון. איזה קסם!

הסברים על המונחים: Light DOM לעומת Shadow DOM

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

Light DOM

תגי העיצוב שמשתמש ברכיב שלכם כותב. ה-DOM הזה נמצא מחוץ ל-Shadow DOM של הרכיב. אלה הצאצאים בפועל של הרכיב.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

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

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

עץ DOM שטוח

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

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

הרכיב <slot>

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

רכיבים יכולים "לחצות" את גבול ה-shadow DOM כש-<slot> מזמין אותם פנימה. הרכיבים האלה נקראים צמתים מבוזרים. מבחינה רעיונית, צמתים מבוזרים יכולים להיראות קצת מוזרים. משבצות לא מעבירות את ה-DOM באופן פיזי, אלא מעבדות אותו במיקום אחר בתוך ה-shadow DOM.

רכיב יכול להגדיר אפס מיקומים או יותר ב-shadow DOM שלו. המיקומים יכולים להיות ריקים או להכיל תוכן חלופי. אם המשתמש לא מספק תוכן light DOM, המיקום מציג את התוכן החלופי שלו.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

אפשר גם ליצור משבצות עם שם. מיקומים בעלי שם הם חורים ספציפיים ב-shadow DOM שהמשתמשים מפנים אליהם לפי שם.

דוגמה – המיקומים ב-shadow DOM של <fancy-tabs>:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

משתמשי הרכיב מצהירים על <fancy-tabs> כך:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

אם אתם תוהים, העץ השטוח נראה בערך כך:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

שימו לב שהרכיב שלנו יכול לטפל בהגדרות שונות, אבל עץ ה-DOM השטוח נשאר זהה. אנחנו יכולים גם לעבור מ-<button> ל-<h2>. הרכיב הזה נוצר כדי לטפל בסוגים שונים של רכיבי צאצא… בדיוק כמו <select>!

עיצוב

יש הרבה אפשרויות לעיצוב רכיבי אינטרנט. רכיב שמשתמש ב-shadow DOM יכול לקבל סגנון מהדף הראשי, להגדיר סגנונות משלו או לספק הוקים (בצורה של מאפיינים מותאמים אישית של CSS) כדי שהמשתמשים יוכלו לשנות את ברירות המחדל.

סגנונות שמוגדרים ברכיבים

התכונה הכי שימושית של shadow DOM היא CSS ברמת הרכיב:

  • סלקטורים ב-CSS מהדף החיצוני לא חלים בתוך הרכיב.
  • הסגנונות שמוגדרים בפנים לא זולגים החוצה. הם מוגדרים ברכיב המארח.

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

דוגמה – סגנונות שמוגדרים ב-shadow root הם מקומיים

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

גם גיליונות סגנונות מוגבלים לעץ הצל:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

תהיתם פעם איך הרכיב <select> מעבד ווידג'ט לבחירה מרובה (במקום תפריט נפתח) כשמוסיפים את המאפיין multiple:

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

הרכיב <select> יכול לעצב את עצמו באופן שונה על סמך המאפיינים שהצהרתם עליהם. רכיבי אינטרנט יכולים גם לעצב את עצמם באמצעות הסלקטור :host.

דוגמה – רכיב שמעצב את עצמו

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

אחת הבעיות ב-:host היא שלכללים בדף ההורה יש ספציפיות גבוהה יותר מאשר לכללי :host שמוגדרים ברכיב. כלומר, הסגנונות החיצוניים הם אלה שקובעים. כך המשתמשים יכולים לשנות את הסגנון ברמה העליונה מבחוץ. בנוסף, :host פועל רק בהקשר של שורש צל, כך שאי אפשר להשתמש בו מחוץ ל-shadow DOM.

הצורה הפונקציונלית של :host(<selector>) מאפשרת לכם לטרגט את המארח אם הוא תואם ל-<selector>. זו דרך מצוינת להכיל ברכיב התנהגויות שמגיבות לאינטראקציה של המשתמש, או לעצב צמתים פנימיים על סמך המארח.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

עיצוב לפי ההקשר

:host-context(<selector>) תואם לרכיב אם הוא או אחד מהרכיבים שמעליו תואמים ל-<selector>. שימוש נפוץ בזה הוא עיצוב לפי הסביבה של הרכיב. לדוגמה, אנשים רבים יוצרים עיצובים על ידי החלת מחלקה על <html> או <body>:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

הקוד :host-context(.darktheme) יעצב את <fancy-tabs> כשהוא צאצא של .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

אפשר להשתמש ב-:host-context() כדי ליצור עיצובים, אבל גישה טובה יותר היא ליצור הוקים של סגנון באמצעות מאפיינים מותאמים אישית של CSS.

עיצוב צמתים מבוזרים

::slotted(<compound-selector>) תואם לצמתים שמופצים ב-<slot>.

נניח שיצרנו רכיב של תג שם:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

ה-shadow DOM של הרכיב יכול לעצב את ה-<h2> וה-.title של המשתמש:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

אם אתם זוכרים, <slot> לא מזיזים את ה-DOM הבהיר של המשתמש. כשצמתים מופצים ל-<slot>, ה-<slot> מעבד את ה-DOM שלהם, אבל הצמתים נשארים פיזית במקום. הסגנונות שהוחלו לפני החלוקה ממשיכים לחול גם אחרי החלוקה. עם זאת, כשמפיצים את ה-DOM הבהיר, הוא יכול לקבל סגנונות נוספים (אלה שמוגדרים על ידי ה-shadow DOM).

דוגמה נוספת ומפורטת יותר מ-<fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

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

עיצוב רכיב מבחוץ

יש כמה דרכים לעצב רכיב מבחוץ. הדרך הכי קלה היא להשתמש בשם התג כבורר:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

סגנונות חיצוניים תמיד גוברים על סגנונות שמוגדרים ב-Shadow DOM. לדוגמה, אם המשתמש כותב את הסלקטור fancy-tabs { width: 500px; }, הוא יגבר על הכלל של הרכיב: :host { width: 650px;}.

עיצוב הרכיב עצמו לא מספיק. אבל מה קורה אם רוצים לעצב את החלקים הפנימיים של רכיב? לשם כך, אנחנו צריכים מאפיינים מותאמים אישית של CSS.

יצירת style hooks באמצעות מאפיינים מותאמים אישית של CSS

המשתמשים יכולים לשנות סגנונות פנימיים אם יוצר הרכיב מספק הוקים של סגנונות באמצעות מאפיינים מותאמים אישית של CSS. מבחינה רעיונית, זה דומה ל-<slot>. אתם יוצרים "סגנונות של פלייסהולדרים" שהמשתמשים יכולים לשנות.

דוגמה – <fancy-tabs> מאפשר למשתמשים לשנות את צבע הרקע:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

בתוך ה-shadow DOM שלו:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

במקרה הזה, הרכיב ישתמש ב-black כערך הרקע כי המשתמש סיפק אותו. אחרת, ברירת המחדל תהיה #9E9E9E.

נושאים מתקדמים

יצירת שורשי צל סגורים (כדאי להימנע מכך)

יש עוד סוג של shadow DOM שנקרא מצב "סגור". כשיוצרים עץ צל סגור, JavaScript חיצוני לא יכול לגשת ל-DOM הפנימי של הרכיב. זה דומה לאופן שבו רכיבים מקוריים כמו <video> פועלים. ל-JavaScript אין גישה ל-shadow DOM של <video> כי הדפדפן מטמיע אותו באמצעות shadow root במצב סגור.

דוגמה – יצירת עץ צל סגור:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

מצב סגור משפיע גם על ממשקי API אחרים:

  • ‫Element.assignedSlot / TextNode.assignedSlot מחזיר null
  • Event.composedPath() לאירועים שמשויכים לאלמנטים בתוך ה-shadow DOM, מחזיר []

הנה הסיכום שלי לסיבות שבגללן אף פעם לא כדאי ליצור רכיבי אינטרנט באמצעות {mode: 'closed'}:

  1. תחושת ביטחון מלאכותית. אין שום דבר שמונע מתוקף לפרוץ אל Element.prototype.attachShadow.

  2. מצב סגור מונע מהקוד של הרכיב המותאם אישית לגשת ל-shadow DOM שלו. זה כישלון מוחלט. במקום זאת, תצטרכו לשמור הפניה לשימוש מאוחר יותר אם תרצו להשתמש בדברים כמו querySelector(). זה מבטל לחלוטין את המטרה המקורית של המצב הסגור.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. מצב סגור הופך את הרכיב לפחות גמיש עבור משתמשי הקצה. כשמפתחים רכיבי אינטרנט, לפעמים שוכחים להוסיף תכונה מסוימת. אפשרות הגדרה. תרחיש לדוגמה שהמשתמש רוצה. דוגמה נפוצה היא שכחה לכלול ווי עיצוב מתאימים לצמתים פנימיים. במצב סגור, המשתמשים לא יכולים לשנות את ברירות המחדל ואת הסגנונות. היכולת לגשת לרכיבים הפנימיים של הרכיב עוזרת מאוד. בסופו של דבר, אם הרכיב לא יעשה את מה שהמשתמשים רוצים, הם יפצלו אותו, ימצאו רכיב אחר או ייצרו רכיב משלהם :(

עבודה עם מיקומי מודעות ב-JS

ה-API של Shadow DOM מספק כלים לעבודה עם מיקומים וצמתים מבוזרים. הם שימושיים כשיוצרים רכיב בהתאמה אישית.

אירוע slotchange

האירוע slotchange מופעל כשמשתנים הצמתים המבוזרים של מיקום מודעה. לדוגמה, אם המשתמש מוסיף או מסיר צאצאים מה-light DOM.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

כדי לעקוב אחרי סוגים אחרים של שינויים ב-light DOM, אפשר להגדיר MutationObserver ב-constructor של הרכיב.

אילו רכיבים מוצגים במיקום מודעה?

לפעמים כדאי לדעת אילו רכיבים משויכים למיקום מודעה. מפעילים את slot.assignedNodes() כדי לראות אילו רכיבים מוצגים במיקום המודעה. האפשרות {flatten: true} תחזיר גם את התוכן החלופי של מיקום מודעה (אם לא מופצים צמתים).

לדוגמה, נניח ש-shadow DOM נראה כך:

<slot><b>fallback content</b></slot>
שימושהתקשרותתוצאה
<my-component>component text</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

לאיזה מיקום מוקצה רכיב?

אפשר גם לענות על השאלה ההפוכה. המאפיין element.assignedSlot מציין לאיזה מיקום של רכיב הוקצה האלמנט.

מודל האירועים של Shadow DOM

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

האירועים שכן חוצים את גבול ה-shadow DOM הם:

  • אירועי התמקדות: blur, focus, focusin, focusout
  • אירועי עכבר: click,‏ dblclick,‏ mousedown,‏ mouseenter,‏ mousemove וכו'.
  • אירועי גלגלת: wheel
  • אירועי קלט: beforeinput, input
  • אירועי מקלדת: keydown, keyup
  • אירועי קומפוזיציה: compositionstart, compositionupdate, compositionend
  • ‫DragEvent: ‏dragstart, ‏drag, ‏dragend, ‏drop וכו'.

כדאי לדעת

אם עץ הצל פתוח, קריאה ל-event.composedPath() תחזיר מערך של צמתים שהאירוע עבר דרכם.

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

אירועי DOM מותאמים אישית שמופעלים בצמתים פנימיים בעץ צל לא יוצאים מגבולות הצל, אלא אם האירוע נוצר באמצעות הדגל composed: true:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

אם הערך הוא composed: false (ברירת המחדל), הצרכנים לא יוכלו להאזין לאירוע מחוץ ל-shadow root.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

טיפול במיקוד

אם אתם זוכרים את מודל האירועים של shadow DOM, אירועים שמופעלים בתוך shadow DOM מותאמים כך שייראה כאילו הם מגיעים מהרכיב המארח. לדוגמה, נניח שלחצתם על <input> בתוך שורש צל:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

האירוע focus ייראה כאילו הוא הגיע מ-<x-focus>, ולא מ-<input>. באופן דומה, document.activeElement יהיה <x-focus>. אם ה-shadow root נוצר באמצעות mode:'open' (ראו מצב סגור), תוכלו גם לגשת לצומת הפנימי שקיבל את המיקוד:

document.activeElement.shadowRoot.activeElement // only works with open mode.

אם יש כמה רמות של shadow DOM (למשל, רכיב בהתאמה אישית בתוך רכיב אחר בהתאמה אישית), צריך להיכנס באופן רקורסיבי לשורשי ה-shadow כדי למצוא את ה-activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

אפשרות נוספת למיקוד היא האפשרות delegatesFocus: true, שמרחיבה את התנהגות המיקוד של רכיבים בתוך עץ צל:

  • אם לוחצים על צומת בתוך shadow DOM והצומת הוא לא אזור שאפשר להתמקד בו, האזור הראשון שאפשר להתמקד בו מקבל את המיקוד.
  • כשצומת בתוך shadow DOM מקבל מיקוד, :focus חל על המארח בנוסף לרכיב הממוקד.

דוגמה – איך delegatesFocus: true משנה את אופן הפעולה של המיקוד

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

תוצאה

delegatesFocus: true behavior.

למעלה מוצגת התוצאה כשמתמקדים ב-<x-focus> (קליק של משתמש, מעבר באמצעות Tab, focus() וכו'), כשלוחצים על "טקסט Shadow DOM שניתן ללחוץ עליו" או כשמתמקדים ב-<input> הפנימי (כולל autofocus).

אם הייתם מגדירים את delegatesFocus: false, זה מה שהייתם רואים במקום:

‫delegatesFocus: false והקלט הפנימי ממוקד.
‫delegatesFocus: false וה-<input> הפנימי ממוקד.
delegatesFocus: false ו-x-focus
    מקבל פוקוס (למשל, יש לו tabindex='0').
‫delegatesFocus: false ו-<x-focus> מקבלים את המיקוד (למשל, יש להם tabindex="0").
‫delegatesFocus: false ולוחצים על &quot;טקסט של Shadow DOM שניתן ללחוץ עליו&quot; (או על אזור ריק אחר ב-Shadow DOM של הרכיב).
‫delegatesFocus: false ו-"Clickable Shadow DOM text" נלחצים (או שנלחץ אזור ריק אחר ב-Shadow DOM של הרכיב).

טיפים וטריקים

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

שימוש בהכלה של CSS

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

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

איפוס סגנונות שניתנים להעברה בירושה

סגנונות שניתנים להעברה בירושה (background,‏ color,‏ font,‏ line-height וכו') ממשיכים לעבור בירושה ב-Shadow DOM. כלומר, כברירת מחדל הם חודרים את הגבול של Shadow DOM. אם אתם רוצים להתחיל מחדש, השתמשו ב-all: initial; כדי לאפס סגנונות שניתן לרשת לערך הראשוני שלהם כשהם חוצים את גבול הצל.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

מציאת כל הרכיבים המותאמים אישית שבהם נעשה שימוש בדף

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

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

יצירת רכיבים מ-<template>

במקום לאכלס שורש צל באמצעות .innerHTML, אנחנו יכולים להשתמש ב-<template> הצהרתי. תבניות הן פלייסהולדר אידיאלי להצהרה על המבנה של רכיב אינטרנט.

אפשר לראות דוגמה במאמר Custom elements: building reusable web components.

היסטוריה ותמיכה בדפדפן

אם עקבתם אחרי רכיבי אינטרנט בשנתיים האחרונות, אתם יודעים ש-Chrome 35 ואילך ו-Opera כוללים גרסה ישנה יותר של Shadow DOM כבר זמן מה. ‫Blink ימשיך לתמוך בשתי הגרסאות במקביל למשך זמן מה. במפרט של v0 הייתה שיטה אחרת ליצירת שורש צל (element.createShadowRoot במקום element.attachShadow של v1). קריאה לשיטה הישנה יותר ממשיכה ליצור שורש צל עם סמנטיקה של v0, כך שקוד v0 קיים לא יישבר.

אם אתם מתעניינים במפרט v0 הישן, כדאי לעיין במאמרים של html5rocks: 1, 2, 3. יש גם השוואה מצוינת של ההבדלים בין shadow DOM v0 ל-v1.

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

Shadow DOM v1 שוחרר ב-Chrome 53 (סטטוס), ב-Opera 40, ב-Safari 10 וב-Firefox 63. Edge התחילו לפתח.

כדי לזהות את התכונה shadow DOM, בודקים אם קיים attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

עד שהתמיכה בדפדפנים תהיה זמינה באופן נרחב, ה-polyfills של shadydom ו-shadycss יספקו לכם את התכונות של גרסה 1. ‫Shady DOM מחקה את היקף ה-DOM של Shadow DOM, ו-shadycss מחקה את המאפיינים המותאמים אישית של CSS ואת היקף הסגנון שממשק ה-API המקורי מספק.

מתקינים את ה-polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

משתמשים ב-polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

בכתובת https://github.com/webcomponents/shadycss#usage מוסבר איך להגדיר את הסגנונות.

סיכום

בפעם הראשונה, יש לנו פרימיטיב API שמבצע הגדרת היקף נכונה של CSS, הגדרת היקף של DOM וכולל קומפוזיציה אמיתית. בשילוב עם ממשקי API אחרים של רכיבי אינטרנט, כמו רכיבים מותאמים אישית, Shadow DOM מאפשר ליצור רכיבים שבאמת מופרדים, בלי פריצות או שימוש ברכיבים ישנים כמו <iframe>.

אל תבינו אותי לא נכון. Shadow DOM הוא בהחלט נושא מורכב. אבל כדאי ללמוד איך להשתמש בה. תקדישו לזה קצת זמן. לומדים את הנושא ושואלים שאלות.

קריאה נוספת

שאלות נפוצות

אפשר להשתמש ב-Shadow DOM v1 כבר עכשיו?

כן, באמצעות polyfill. דפדפנים נתמכים

אילו אמצעי אבטחה מספק Shadow DOM?

‫Shadow DOM הוא לא אמצעי אבטחה. זהו כלי קל משקל להגדרת היקף של CSS והסתרת עצי DOM ברכיב. אם אתם רוצים גבול אבטחה אמיתי, השתמשו ב-<iframe>.

האם רכיב אינטרנט חייב להשתמש ב-Shadow DOM?

לא! אתם לא חייבים ליצור רכיבי אינטרנט שמשתמשים ב-shadow DOM. עם זאת, אם יוצרים רכיבים מותאמים אישית שמשתמשים ב-Shadow DOM, אפשר לנצל תכונות כמו היקף CSS, אנקפסולציה של DOM וקומפוזיציה.

מה ההבדל בין שורשי צל פתוחים וסגורים?

שורשי צל סגורים