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

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

סיכום

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

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

מבוא

Shadow DOM הוא אחד משלושת הסטנדרטים של רכיבי האינטרנט: HTML Templates (תבניות HTML), Shadow DOM וגם רכיבים מותאמים אישית. ייבוא HTML היו חלק מהרשימה, אבל עכשיו הם נחשבים הוצא משימוש.

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

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

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

יצירה מוזיקלית ואפשרויות קיבולת (Slot)

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

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

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

הרכב Shadow 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>

DOM של Shadow DOM

ה-DOM שמחבר של רכיב כותב. ה-DOM של Shadow הוא מקומי לרכיב שמגדיר את המבנה הפנימי, את היקף ה-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>

<משבצת> רכיב

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

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

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

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

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

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

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

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

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

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

עבודה עם יחידות קיבולת (Slot) ב-JS

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

אירוע משבצת שינוי

האירוע slotchange מופעל כשהצמתים המבוזרים של יחידת קיבולת (Slot) משתנים. עבור לדוגמה, אם המשתמש מוסיף או מסיר ילדים מה-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>טקסט רכיב</my-component> slot.assignedNodes(); [component text]
&lt;my-component&gt;&lt;/my-component&gt; slot.assignedNodes(); []
&lt;my-component&gt;&lt;/my-component&gt; slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

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

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

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

כשאירוע עולה בבועות מ-DOM של צללית, היעד שלו מותאם כדי לשמר את את הקפסולה (encapsulation) ש-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>

תוצאה

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

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

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

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

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

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

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

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

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

Shadow DOM v1 נשלח ב-Chrome 53 (סטטוס), Opera 40, Safari 10 ו-Firefox 63. קצה התחיל את תהליך הפיתוח.

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

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

פוליפיל

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

סיכום

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

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

קריאה נוספת

שאלות נפוצות

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

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

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

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

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

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

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

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