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… בצל

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

יצירת DOM בצל

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

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

<!-- 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 בצל בדף הראשי, להגדיר לו סגנונות משלו או לספק וו hooks (בצורת מאפייני CSS מותאמים אישית) כדי למשתמשים לשנות את הגדרות ברירת המחדל.

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

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

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

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

עבודה עם משבצות ב-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?

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

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

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

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

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

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

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

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

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

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

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

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

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

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

פוליפיל

עד שתהיה תמיכה רחבה בדפדפנים, תוכלו להשתמש ב-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!
}

להוראות על שימוש ב-shim או על הגדרת היקף לסגנונות, אפשר לעיין במאמר 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 והרכבה.

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

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