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

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

סיכום

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

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

מבוא

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

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

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

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

הדגמה של fancy-tabs

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

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

מה זה 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>

כל טוב וטוב. ואז מה זה בצל DOM?

DOM... בצללים

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

יצירת DOM של צל

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

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

  • הדפדפן כבר מארח DOM פנימי של צללית לאלמנט (<textarea>, <input>).
  • לא הגיוני שהאלמנט יארח DOM של צל (<img>).

לדוגמה, זה לא עובד:

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

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

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

דוגמה – רכיב מותאם אישית מצרף לעצמו את ה-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>.

הרכב ומשבצות

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

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

מינוח: DOM אור לעומת DOM של צל

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

DOM בהיר

תגי העיצוב שהמשתמש כותב ברכיב שלכם. ה-DOM הזה נמצא מחוץ ל-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>

DOM של צל יוצר עצי DOM שונים יחד באמצעות הרכיב <slot>. משבצות הן placeholders בתוך הרכיב, שהמשתמשים יכולים למלא בתגי עיצוב משלהם. הגדרה של משבצת אחת או יותר מאפשרת להזמין תגי עיצוב חיצוניים לעיבוד ב-DOM של הצללית של הרכיב. למעשה, אתם אומרים "Render the user'smark of here" (עבדו את תגי העיצוב של המשתמש כאן).

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

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

<!-- 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>

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

דוגמה - המיקומים ב-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> עושה!

שינוי סגנון

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

סגנונות שהוגדרו על ידי רכיבים

התכונה השימושית ביותר ב-DOM של צללית היא CSS בהיקף הנדרש:

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

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

דוגמה - סגנונות המוגדרים בשורש צל הם מקומיים

#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>

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

<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 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() ליצירת נושאים, אבל גישה טובה עוד יותר היא ליצור קטעי hook לסגנון באמצעות מאפיינים מותאמים אישית של CSS.

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

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

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

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

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 של האור מופץ, הוא יכול לקבל סגנונות נוספים (כאלה שמוגדרים על ידי ה-DOM 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;
}

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

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

יצירת קטעי hook לסגנון באמצעות מאפיינים מותאמים אישית של CSS

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

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

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

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

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

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

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

יצירת שורשי צל סגור (יש להימנע)

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

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

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() לאירועים שמשויכים לרכיבים בתוך ה-DOM של הצללית, מחזירה []

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

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

  2. מצב סגור מונע מקוד של רכיב מותאם אישית לגשת ל-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

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

אירוע של שינוי מיקום

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

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

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

אילו רכיבים מעובדים ביחידת קיבולת (Slot)?

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

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

<slot><b>fallback content</b></slot>
Usageהתקשרותתוצאה
<my-component>טקסט רכיב</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

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

<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>

טיפול בהתמקדות

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

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

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

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

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

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

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

  • אם לוחצים על צומת ב-DOM של צללית והצומת הוא לא אזור שאפשר להתמקד בו, האזור הראשון שניתן להתמקד בו יתמקד.
  • כשצומת בתוך 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>

תוצאה

נציגים ממוקד: התנהגות אמיתית.

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

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

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

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

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

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

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

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

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

סגנונות שעברו בירושה (background, color, font, line-height וכו') ממשיכים בירושה ל-DOM של הצל. כלומר, הם מכניסים את גבולות ה-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>

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

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

אפשר לראות את הדוגמה בקטע "CustomElements: build reusable WebComponents (רכיבי אינטרנט לשימוש חוזר).

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

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

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

תמיכת דפדפן

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

כדי לזהות DOM של צל, צריך לבדוק אם יש attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

פוליפיל

עד שהתמיכה בדפדפן תהיה זמינה לכולם, הפוליגונים ל-shadydom ול-shadycss נותנים את התכונה v1. Shady DOM מחקה את היקף ה-DOM של Shadow DOM ושל shadycss polyfills מאפיינים מותאמים אישית של 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 אחרים של רכיבי אינטרנט, כמו אלמנטים מותאמים אישית, ה-DOM של צללית מאפשר לכתוב רכיבים מכווננים באמת ללא פריצות, ובלי להשתמש במזוודות ישנות יותר כמו <iframe>.

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

קריאה נוספת

שאלות נפוצות

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

עם polyfill, כן. ראו תמיכה בדפדפן.

אילו תכונות אבטחה יש ב-DOM DOM?

DOM של צל אינו תכונת אבטחה. זהו כלי פשוט לתחום ה-CSS ולהסתרת עצי DOM ברכיב. אם רוצים גבול אבטחה אמיתי, צריך להשתמש ב-<iframe>.

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

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

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

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