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

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

סיכום

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

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

מבוא

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

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

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

  • DOM מבודד: ה-DOM של רכיב הוא עצמאי (למשל, document.querySelector() לא יחזיר צמתים ב-Shadow 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>

זה בסדר גמור. אז מה זה בכלל shadow DOM?

DOM... בין האזורים הכהים

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

יצירת DOM של צללית

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

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

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

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

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

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

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

קומפוזיציה וחריצים

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

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

מונחים: DOM בהיר לעומת DOM בצל

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

Light DOM

הרכיב שמשמש את המשתמש לכתיבה של ה-Markup. ה-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 בצל, ומעבד את המוצר הסופי. העץ השטוח הוא מה שרואים בסופו של דבר ב-DevTools ומה שעבר עיבוד בדף.

<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>. סמלי ה-slot הם placeholders בתוך הרכיב, שהמשתמשים יכולים למלא ב-markup משלהם. הגדרת חריץ אחד או יותר מאפשרת להציג סימון מחוץ ל-DOM של הצל של הרכיב. בעיקרון, אתם אומרים "הצגת ה-Markup של המשתמש כאן".

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

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

ללא ספק, התכונה הכי שימושית של DOM בצל היא CSS ברמת ההיקף:

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

סלקטורים של CSS שמשמשים ב-Shadow 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>

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

ה-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 הצל).

דוגמה נוספת ומפורטת יותר מ-<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.

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

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

דוגמה<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.

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

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

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

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

אירוע slotchange

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

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

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

אילו רכיבים עוברים עיבוד (רינדור) במודול?

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

לדוגמה, נניח ש-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

כשאירוע מופיע בבועות שעולות מ-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, שמרחיבה את התנהגות המיקוד של רכיבים בתוך עץ צל:

  • אם לוחצים על צומת ב-Shadow 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>

תוצאה

delegatesFocus: התנהגות אמיתית.

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

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

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

אפשר לעיין בדוגמה בקטע רכיבים מותאמים אישית: פיתוח רכיבי אינטרנט לשימוש חוזר.

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

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

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

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

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

כדי לזהות את Shadow DOM בתכונה, בודקים אם הערך attachShadow קיים:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

פוליפיל

עד שהתמיכה בדפדפן תהיה זמינה לכולם, ה-polyfills של shadydom ו-shadycss מאפשר לכם ליהנות גם מפיצ'ר v1. ‏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 תוכלו למצוא הוראות להגדרת shim/היקף הסגנונות שלכם.

סיכום

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

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

קריאה נוספת

שאלות נפוצות

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

עם פוליפילם, כן. תמיכה בדפדפנים

אילו תכונות אבטחה מספק shadow DOM?

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

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

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

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

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