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