Shadow DOM cho phép các nhà phát triển web tạo DOM và CSS được phân chia cho các thành phần web
Tóm tắt
Shadow DOM giúp loại bỏ sự cố khi xây dựng ứng dụng web. Tính dễ hỏng đến từ bản chất toàn cục của HTML, CSS và JS. Trong nhiều năm, chúng tôi đã phát minh ra một số lượng
lớn
công cụ để giải quyết các vấn đề này. Ví dụ: khi bạn sử dụng một mã nhận dạng/lớp HTML mới, bạn không thể biết liệu mã nhận dạng/lớp đó có xung đột với tên hiện có mà trang sử dụng hay không.
Các lỗi nhỏ sẽ xuất hiện, tính đặc thù của CSS trở thành một vấn đề lớn (!important
mọi thứ!), bộ chọn kiểu sẽ không thể kiểm soát và hiệu suất có thể bị ảnh hưởng. Danh sách này còn dài lắm.
Shadow DOM khắc phục CSS và DOM. Phiên bản này giới thiệu kiểu có giới hạn cho nền tảng web. Nếu không có công cụ hoặc quy ước đặt tên, bạn có thể gói CSS với thẻ, ẩn thông tin chi tiết về việc triển khai và tác giả các thành phần tự chứa trong JavaScript cơ bản.
Giới thiệu
Shadow DOM là một trong ba tiêu chuẩn Thành phần web: Mẫu HTML, Shadow DOM và Phần tử tuỳ chỉnh. Lệnh nhập HTML từng có trong danh sách này nhưng hiện được coi là không dùng nữa.
Bạn không cần phải tạo thành phần web sử dụng DOM tối. Tuy nhiên, khi làm như vậy, bạn sẽ tận dụng được các lợi ích của CSS (phạm vi CSS, đóng gói DOM, thành phần) và tạo các phần tử tuỳ chỉnh có thể sử dụng lại, linh hoạt, có thể định cấu hình cao và cực kỳ có thể sử dụng lại. Nếu các phần tử tuỳ chỉnh là cách tạo HTML mới (bằng API JS), thì shadow DOM là cách bạn cung cấp HTML và CSS. Hai API này kết hợp với nhau để tạo một thành phần có HTML, CSS và JavaScript độc lập.
Shadow DOM được thiết kế như một công cụ để xây dựng ứng dụng dựa trên thành phần. Do đó, thư viện này mang đến các giải pháp cho các vấn đề thường gặp trong quá trình phát triển web:
- DOM riêng biệt: DOM của một thành phần là độc lập (ví dụ:
document.querySelector()
sẽ không trả về các nút trong DOM tối của thành phần). - CSS có giới hạn: CSS được xác định bên trong shadow DOM được giới hạn trong đó. Các quy tắc kiểu không bị rò rỉ và kiểu trang không bị tràn vào.
- Thành phần: Thiết kế một API khai báo, dựa trên mã đánh dấu cho thành phần của bạn.
- Đơn giản hoá CSS – DOM có giới hạn nghĩa là bạn có thể sử dụng bộ chọn CSS đơn giản, tên lớp/mã nhận dạng chung hơn và không phải lo lắng về xung đột tên.
- Hiệu suất – Hãy nghĩ đến các ứng dụng theo các phần của DOM thay vì một trang lớn (toàn cục).
Bản minh hoạ fancy-tabs
Trong suốt bài viết này, tôi sẽ tham khảo một thành phần minh hoạ (<fancy-tabs>
) và tham chiếu các đoạn mã từ thành phần đó. Nếu trình duyệt của bạn hỗ trợ các API này, bạn sẽ thấy một bản minh hoạ trực tiếp ngay bên dưới. Nếu không, hãy xem nguồn đầy đủ trên GitHub.
Shadow DOM là gì?
Thông tin cơ bản về DOM
HTML hỗ trợ web vì dễ sử dụng. Bằng cách khai báo một vài thẻ, bạn có thể tạo một trang có cả nội dung trình bày và cấu trúc chỉ trong vài giây. Tuy nhiên, bản thân HTML không hữu ích lắm. Con người dễ hiểu ngôn ngữ dựa trên văn bản, nhưng máy móc cần nhiều hơn thế. Nhập Mô hình đối tượng tài liệu (Document Object Model) hoặc DOM.
Khi tải một trang web, trình duyệt sẽ thực hiện một loạt thao tác thú vị. Một trong những việc mà trình này thực hiện là chuyển đổi HTML của tác giả thành một tài liệu trực tiếp. Về cơ bản, để hiểu cấu trúc của trang, trình duyệt sẽ phân tích cú pháp HTML (chuỗi văn bản tĩnh) thành mô hình dữ liệu (đối tượng/nút). Trình duyệt duy trì hệ phân cấp của HTML bằng cách tạo một cây gồm các nút này: DOM. Điều thú vị về DOM là nó là một bản trình bày trực tiếp của trang. Không giống như HTML tĩnh mà chúng ta tạo, các nút do trình duyệt tạo chứa các thuộc tính, phương thức và quan trọng nhất là có thể được các chương trình thao tác! Đó là lý do chúng ta có thể tạo trực tiếp các phần tử DOM bằng JavaScript:
const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);
tạo ra mã đánh dấu HTML sau:
<body>
<header>
<h1>Hello DOM</h1>
</header>
</body>
Mọi thứ đều ổn. Vậy thì shadow DOM là gì?
DOM… trong bóng tối
Shadow DOM chỉ là DOM thông thường với hai điểm khác biệt: 1) cách tạo/sử dụng và 2) cách hoạt động liên quan đến phần còn lại của trang. Thông thường, bạn tạo các nút DOM và thêm các nút đó làm phần tử con của một phần tử khác. Với DOM bóng đổ, bạn tạo một cây DOM có giới hạn được đính kèm vào phần tử, nhưng tách biệt với các phần tử con thực tế của phần tử đó. Cây con có phạm vi này được gọi là cây bóng. Phần tử được đính kèm vào là máy chủ bóng. Mọi nội dung bạn thêm vào bóng sẽ trở thành nội dung cục bộ của phần tử lưu trữ, bao gồm cả <style>
. Đây là cách shadow DOM đạt được phạm vi kiểu CSS.
Tạo DOM tối
Gốc bóng là một mảnh tài liệu được đính kèm vào phần tử "máy chủ".
Việc đính kèm một gốc bóng là cách phần tử nhận được DOM bóng. Để tạo DOM bóng cho một phần tử, hãy gọi 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
Tôi đang sử dụng .innerHTML
để điền vào gốc bóng, nhưng bạn cũng có thể sử dụng các API DOM khác. Đây là web. Chúng ta có lựa chọn.
Thông số kỹ thuật xác định danh sách các phần tử không thể lưu trữ cây bóng đổ. Có một số lý do khiến một phần tử có thể nằm trong danh sách:
- Trình duyệt đã lưu trữ DOM bóng nội bộ của riêng mình cho phần tử (
<textarea>
,<input>
). - Phần tử không nên lưu trữ một DOM tối (
<img>
).
Ví dụ: cách này không hoạt động:
document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.
Tạo DOM bóng cho một phần tử tuỳ chỉnh
Shadow DOM đặc biệt hữu ích khi tạo các phần tử tuỳ chỉnh. Sử dụng DOM bóng để phân chia HTML, CSS và JS của một phần tử, từ đó tạo ra một "thành phần web".
Ví dụ – một phần tử tuỳ chỉnh đính kèm DOM bóng đổ vào chính phần tử đó, đóng gói DOM/CSS của phần tử đó:
// 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>
`;
}
...
});
Có một vài điều thú vị đang diễn ra ở đây. Thứ nhất là phần tử tuỳ chỉnh tạo DOM bóng của riêng nó khi một thực thể của <fancy-tabs>
được tạo. Bạn có thể thực hiện việc này trong constructor()
. Thứ hai, vì chúng ta đang tạo một thư mục gốc bóng, nên các quy tắc CSS bên trong <style>
sẽ nằm trong phạm vi <fancy-tabs>
.
Cấu trúc và khe
Cấu trúc là một trong những tính năng ít được hiểu rõ nhất của shadow DOM, nhưng có thể coi là tính năng quan trọng nhất.
Trong thế giới phát triển web, thành phần kết hợp là cách chúng ta tạo ứng dụng, khai báo từ HTML. Các khối xây dựng khác nhau (<div>
, <header>
, <form>
, <input>
) kết hợp với nhau để tạo thành ứng dụng. Một số thẻ này thậm chí còn hoạt động với nhau. Cấu trúc là lý do các phần tử gốc như <select>
, <details>
, <form>
và <video>
rất linh hoạt. Mỗi thẻ trong số đó chấp nhận một số HTML nhất định làm thẻ con và thực hiện một số thao tác đặc biệt với các thẻ con đó. Ví dụ: <select>
biết cách hiển thị <option>
và <optgroup>
thành các tiện ích thả xuống và nhiều lựa chọn. Phần tử <details>
hiển thị <summary>
dưới dạng một mũi tên có thể mở rộng. Ngay cả <video>
cũng biết cách xử lý một số phần tử con nhất định: các phần tử <source>
không được kết xuất nhưng ảnh hưởng đến hành vi của video.
Thật kỳ diệu!
Thuật ngữ: DOM sáng so với DOM tối
Cấu trúc Shadow DOM giới thiệu một loạt các nguyên tắc cơ bản mới trong quá trình phát triển web. Trước khi đi sâu vào chi tiết, hãy chuẩn hoá một số thuật ngữ để chúng ta nói cùng một ngôn ngữ.
Light DOM
Mã đánh dấu mà người dùng thành phần của bạn viết. DOM này nằm bên ngoài DOM tối của thành phần. Đây là các phần tử con thực tế của phần tử.
<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 mà tác giả thành phần viết. Shadow DOM là cục bộ đối với thành phần và xác định cấu trúc nội bộ, CSS trong phạm vi của thành phần đó, đồng thời đóng gói chi tiết triển khai. Tệp này cũng có thể xác định cách hiển thị mã đánh dấu do người dùng thành phần của bạn tạo.
#shadow-root
<style>...</style>
<slot name="icon"></slot>
<span id="wrapper">
<slot>Button</slot>
</span>
Cây DOM được làm phẳng
Kết quả của trình duyệt phân phối DOM sáng của người dùng vào DOM bóng của bạn, hiển thị sản phẩm cuối cùng. Cây được làm phẳng là nội dung cuối cùng bạn thấy trong DevTools và nội dung được hiển thị trên trang.
<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>
Phần tử <slot>
Shadow DOM kết hợp nhiều cây DOM với nhau bằng cách sử dụng phần tử <slot>
.
Khe là phần giữ chỗ bên trong thành phần mà người dùng có thể điền bằng mã đánh dấu của riêng họ. Bằng cách xác định một hoặc nhiều khe, bạn mời các mã đánh dấu bên ngoài hiển thị trong DOM bóng của thành phần. Về cơ bản, bạn đang nói "Hiển thị mã đánh dấu của người dùng ở đây".
Các phần tử được phép "vượt qua" ranh giới shadow DOM khi <slot>
mời các phần tử đó vào. Các phần tử này được gọi là nút phân tán. Về mặt khái niệm, các nút phân tán có vẻ hơi kỳ lạ. Các khe không di chuyển DOM theo cách thực tế; mà kết xuất DOM ở một vị trí khác bên trong DOM tối.
Một thành phần có thể xác định không có hoặc nhiều khe trong DOM bóng. Các khung có thể trống hoặc cung cấp nội dung dự phòng. Nếu người dùng không cung cấp nội dung light DOM, thì khung sẽ hiển thị nội dung dự phòng.
<!-- 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>
Bạn cũng có thể tạo khung giờ được đặt tên. Khe được đặt tên là các lỗ cụ thể trong DOM bóng mà người dùng tham chiếu theo tên.
Ví dụ – các khe trong DOM bóng của <fancy-tabs>
:
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title"></slot> <!-- named slot -->
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
Người dùng thành phần khai báo <fancy-tabs>
như sau:
<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>
Và nếu bạn thắc mắc, cây được làm phẳng sẽ có dạng như sau:
<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>
Lưu ý rằng thành phần của chúng ta có thể xử lý nhiều cấu hình, nhưng cây DOM được làm phẳng vẫn giữ nguyên. Chúng ta cũng có thể chuyển từ <button>
sang <h2>
. Thành phần này được tạo để xử lý nhiều loại thành phần con… giống như <select>
!
Định kiểu
Có nhiều lựa chọn để tạo kiểu cho các thành phần web. Một thành phần sử dụng DOM bóng có thể được trang chính tạo kiểu, xác định kiểu riêng hoặc cung cấp các móc (dưới dạng thuộc tính tuỳ chỉnh CSS) để người dùng ghi đè các giá trị mặc định.
Kiểu do thành phần xác định
Tính năng hữu ích nhất của shadow DOM là CSS có giới hạn:
- Bộ chọn CSS từ trang bên ngoài không áp dụng bên trong thành phần của bạn.
- Các kiểu được xác định bên trong không bị tràn ra ngoài. Các phần tử này nằm trong phạm vi của phần tử lưu trữ.
Các bộ chọn CSS được sử dụng bên trong shadow DOM áp dụng cục bộ cho thành phần của bạn. Trong thực tế, điều này có nghĩa là chúng ta có thể sử dụng lại tên lớp/mã nhận dạng chung mà không lo ngại về xung đột ở nơi khác trên trang. Bộ chọn CSS đơn giản hơn là phương pháp hay nhất bên trong Shadow DOM. Các thành phần này cũng giúp tăng hiệu suất.
Ví dụ – các kiểu được xác định trong gốc bóng là cục bộ
#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>
Tệp định kiểu cũng thuộc phạm vi của cây bóng:
#shadow-root
<link rel="stylesheet" href="styles.css">
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
Bạn có bao giờ thắc mắc phần tử <select>
hiển thị tiện ích nhiều lựa chọn (thay vì trình đơn thả xuống) khi bạn thêm thuộc tính multiple
không:
<select multiple>
<option>Do</option>
<option selected>Re</option>
<option>Mi</option>
<option>Fa</option>
<option>So</option>
</select>
<select>
có thể tạo kiểu cho chính nó theo cách khác nhau dựa trên các thuộc tính mà bạn khai báo trên đó. Các thành phần web cũng có thể tự tạo kiểu bằng cách sử dụng bộ chọn :host
.
Ví dụ – một thành phần tự định kiểu
<style>
:host {
display: block; /* by default, custom elements are display: inline */
contain: content; /* CSS containment FTW. */
}
</style>
Một điểm cần lưu ý về :host
là các quy tắc trong trang mẹ có mức độ cụ thể cao hơn các quy tắc :host
được xác định trong phần tử. Tức là kiểu bên ngoài sẽ thắng. Điều này cho phép người dùng ghi đè kiểu ở cấp cao nhất từ bên ngoài. Ngoài ra, :host
chỉ hoạt động trong ngữ cảnh của một gốc bóng nên bạn không thể sử dụng nó bên ngoài DOM bóng.
Biểu thức hàm của :host(<selector>)
cho phép bạn nhắm mục tiêu máy chủ nếu biểu thức đó khớp với <selector>
. Đây là một cách tuyệt vời để thành phần của bạn đóng gói các hành vi phản ứng với hoạt động tương tác của người dùng hoặc trạng thái hoặc kiểu của các nút nội bộ dựa trên máy chủ lưu trữ.
<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>
Định kiểu dựa trên ngữ cảnh
:host-context(<selector>)
khớp với thành phần nếu thành phần đó hoặc bất kỳ thành phần cấp trên nào của thành phần đó khớp với <selector>
. Một cách sử dụng phổ biến cho tính năng này là tạo giao diện dựa trên môi trường xung quanh của một thành phần. Ví dụ: nhiều người tạo giao diện bằng cách áp dụng một lớp cho <html>
hoặc <body>
:
<body class="darktheme">
<fancy-tabs>
...
</fancy-tabs>
</body>
:host-context(.darktheme)
sẽ tạo kiểu cho <fancy-tabs>
khi thành phần này là thành phần con cháu của .darktheme
:
:host-context(.darktheme) {
color: white;
background: black;
}
:host-context()
có thể hữu ích cho việc tạo giao diện, nhưng một phương pháp tốt hơn nữa là tạo các móc kiểu bằng cách sử dụng thuộc tính tuỳ chỉnh CSS.
Tạo kiểu cho các nút được phân phối
::slotted(<compound-selector>)
so khớp các nút được phân phối vào <slot>
.
Giả sử chúng ta đã tạo một thành phần huy hiệu tên:
<name-badge>
<h2>Eric Bidelman</h2>
<span class="title">
Digital Jedi, <span class="company">Google</span>
</span>
</name-badge>
DOM bóng của thành phần có thể tạo kiểu cho <h2>
và .title
của người dùng:
<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>
Nếu bạn còn nhớ trước đây, <slot>
không di chuyển DOM sáng của người dùng. Khi các nút được phân phối vào <slot>
, <slot>
sẽ hiển thị DOM của các nút đó nhưng các nút vẫn ở nguyên vị trí. Các kiểu áp dụng trước khi phân phối sẽ tiếp tục áp dụng sau khi phân phối. Tuy nhiên, khi được phân phối, DOM sáng có thể sử dụng các kiểu bổ sung (do DOM tối xác định).
Một ví dụ khác, chi tiết hơn về <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>
`;
Trong ví dụ này, có hai khe: một khe được đặt tên cho tiêu đề thẻ và một khe cho nội dung bảng điều khiển thẻ. Khi người dùng chọn một thẻ, chúng ta sẽ in đậm lựa chọn của họ và hiển thị bảng điều khiển của thẻ đó. Bạn có thể thực hiện việc này bằng cách chọn các nút phân phối có thuộc tính selected
. JS của phần tử tuỳ chỉnh (không hiển thị ở đây) sẽ thêm thuộc tính đó vào đúng thời điểm.
Định kiểu cho một thành phần từ bên ngoài
Có một số cách để tạo kiểu cho một thành phần từ bên ngoài. Cách dễ nhất là sử dụng tên thẻ làm bộ chọn:
fancy-tabs {
width: 500px;
color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
box-shadow: 0 3px 3px #ccc;
}
Kiểu bên ngoài luôn thắng kiểu được xác định trong shadow DOM. Ví dụ: nếu người dùng viết bộ chọn fancy-tabs { width: 500px; }
, thì bộ chọn này sẽ thay thế quy tắc của thành phần: :host { width: 650px;}
.
Việc tạo kiểu cho chính thành phần này sẽ chỉ giúp bạn đi được một đoạn đường. Nhưng điều gì sẽ xảy ra nếu bạn muốn tạo kiểu cho nội dung bên trong của một thành phần? Để làm được điều đó, chúng ta cần các thuộc tính CSS tùy chỉnh.
Tạo các móc kiểu bằng thuộc tính tuỳ chỉnh CSS
Người dùng có thể điều chỉnh các kiểu nội bộ nếu tác giả của thành phần cung cấp các móc định kiểu bằng cách sử dụng thuộc tính tuỳ chỉnh CSS. Về mặt khái niệm, ý tưởng này tương tự như <slot>
. Bạn tạo "phần giữ chỗ kiểu" để người dùng ghi đè.
Ví dụ – <fancy-tabs>
cho phép người dùng ghi đè màu nền:
<!-- main page -->
<style>
fancy-tabs {
margin-bottom: 32px;
--fancy-tabs-bg: black;
}
</style>
<fancy-tabs background>...</fancy-tabs>
Bên trong DOM bóng:
:host([background]) {
background: var(--fancy-tabs-bg, #9E9E9E);
border-radius: 10px;
padding: 10px;
}
Trong trường hợp này, thành phần sẽ sử dụng black
làm giá trị nền vì người dùng đã cung cấp giá trị đó. Nếu không, giá trị mặc định sẽ là #9E9E9E
.
Chủ đề nâng cao
Tạo gốc bóng kín (nên tránh)
Còn một phiên bản khác của DOM bóng đổ có tên là chế độ "đóng". Khi bạn tạo một cây bóng đổ kín, JavaScript bên ngoài sẽ không thể truy cập vào DOM nội bộ của thành phần. Điều này tương tự như cách hoạt động của các phần tử gốc như <video>
.
JavaScript không thể truy cập vào shadow DOM của <video>
vì trình duyệt triển khai shadow DOM bằng một gốc bóng ở chế độ đóng.
Ví dụ – tạo cây bóng kín:
const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div
Các API khác cũng bị ảnh hưởng bởi chế độ đóng:
Element.assignedSlot
/TextNode.assignedSlot
trả vềnull
Event.composedPath()
cho các sự kiện liên kết với các phần tử bên trong DOM bóng, trả về []
Sau đây là phần tóm tắt của tôi về lý do bạn không bao giờ nên tạo thành phần web bằng {mode: 'closed'}
:
Cảm giác an toàn giả tạo. Không có gì ngăn kẻ tấn công xâm nhập
Element.prototype.attachShadow
.Chế độ đóng ngăn mã phần tử tuỳ chỉnh truy cập vào DOM bóng của chính phần tử đó. Đó là một thất bại hoàn toàn. Thay vào đó, bạn sẽ phải lưu trữ một tệp tham chiếu cho sau này nếu muốn sử dụng các tính năng như
querySelector()
. Điều này hoàn toàn làm mất đi mục đích ban đầu của chế độ đóng!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'); } ... });
Chế độ khép kín khiến thành phần của bạn kém linh hoạt hơn đối với người dùng cuối. Khi bạn tạo thành phần web, sẽ có lúc bạn quên thêm một tính năng. Một tuỳ chọn cấu hình. Trường hợp sử dụng mà người dùng muốn. Một ví dụ phổ biến là quên thêm các móc định kiểu đầy đủ cho các nút nội bộ. Với chế độ đóng, người dùng không thể ghi đè các giá trị mặc định và điều chỉnh kiểu. Việc có thể truy cập vào nội bộ của thành phần sẽ rất hữu ích. Cuối cùng, người dùng sẽ phân nhánh thành phần của bạn, tìm một thành phần khác hoặc tạo thành phần của riêng họ nếu thành phần đó không làm được những gì họ muốn :(
Làm việc với các khe trong JS
API shadow DOM cung cấp các tiện ích để xử lý các khe và nút phân phối. Các thuộc tính này rất hữu ích khi bạn tạo một phần tử tuỳ chỉnh.
sự kiện slotchange
Sự kiện slotchange
sẽ kích hoạt khi các nút phân phối của một khe thay đổi. Ví dụ: nếu người dùng thêm/xoá các phần tử con khỏi DOM ánh sáng.
const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
console.log('light dom children changed!');
});
Để theo dõi các loại thay đổi khác đối với DOM sáng, bạn có thể thiết lập MutationObserver
trong hàm khởi tạo của phần tử.
Những phần tử nào đang hiển thị trong một khe?
Đôi khi, bạn nên biết những phần tử nào được liên kết với một khe. Gọi slot.assignedNodes()
để tìm phần tử mà khe đang hiển thị. Tuỳ chọn {flatten: true}
cũng sẽ trả về nội dung dự phòng của một khe (nếu không có nút nào đang được phân phối).
Ví dụ: giả sử DOM bóng của bạn có dạng như sau:
<slot><b>fallback content</b></slot>
Cách sử dụng | Gọi | Kết quả |
---|---|---|
<my-component>nội dung thành phần</my-component> | slot.assignedNodes(); |
[component text] |
<my-component></my-component> | slot.assignedNodes(); |
[] |
<my-component></my-component> | slot.assignedNodes({flatten: true}); |
[<b>fallback content</b>] |
Phần tử được gán cho khe nào?
Bạn cũng có thể trả lời câu hỏi ngược lại. element.assignedSlot
cho bạn biết phần tử của bạn được chỉ định cho khe thành phần nào.
Mô hình sự kiện Shadow DOM
Khi một sự kiện nổi lên từ shadow DOM, mục tiêu của sự kiện đó sẽ được điều chỉnh để duy trì tính năng đóng gói mà shadow DOM cung cấp. Tức là các sự kiện được nhắm mục tiêu lại để trông giống như chúng đến từ thành phần thay vì các phần tử nội bộ trong DOM bóng. Một số sự kiện thậm chí không truyền ra khỏi shadow DOM.
Các sự kiện có vượt qua ranh giới bóng là:
- Sự kiện lấy tiêu điểm:
blur
,focus
,focusin
,focusout
- Sự kiện chuột:
click
,dblclick
,mousedown
,mouseenter
,mousemove
, v.v. - Sự kiện con lăn:
wheel
- Sự kiện đầu vào:
beforeinput
,input
- Sự kiện bàn phím:
keydown
,keyup
- Sự kiện thành phần:
compositionstart
,compositionupdate
,compositionend
- DragEvent:
dragstart
,drag
,dragend
,drop
, v.v.
Mẹo
Nếu cây bóng mở, thì việc gọi event.composedPath()
sẽ trả về một mảng các nút mà sự kiện đã đi qua.
Sử dụng sự kiện tuỳ chỉnh
Các sự kiện DOM tuỳ chỉnh được kích hoạt trên các nút nội bộ trong cây bóng không vượt ra khỏi ranh giới bóng trừ phi sự kiện được tạo bằng cờ 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}));
}
Nếu là composed: false
(mặc định), thì các trình tiêu thụ sẽ không thể nghe sự kiện bên ngoài gốc bóng của bạn.
<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>
Xử lý tiêu điểm
Nếu bạn nhớ trong mô hình sự kiện của shadow DOM, các sự kiện được kích hoạt bên trong shadow DOM sẽ được điều chỉnh để trông giống như chúng đến từ phần tử lưu trữ.
Ví dụ: giả sử bạn nhấp vào <input>
bên trong một gốc bóng:
<x-focus>
#shadow-root
<input type="text" placeholder="Input inside shadow dom">
Sự kiện focus
sẽ có vẻ như đến từ <x-focus>
, chứ không phải <input>
.
Tương tự, document.activeElement
sẽ là <x-focus>
. Nếu gốc bóng được tạo bằng mode:'open'
(xem chế độ đóng), bạn cũng có thể truy cập vào nút nội bộ đã lấy tiêu điểm:
document.activeElement.shadowRoot.activeElement // only works with open mode.
Nếu có nhiều cấp độ DOM tối đang hoạt động (ví dụ: một phần tử tuỳ chỉnh trong một phần tử tuỳ chỉnh khác), bạn cần lồng nhau vào các gốc bóng để tìm activeElement
:
function deepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement;
}
return a;
}
Một tuỳ chọn khác cho tiêu điểm là tuỳ chọn delegatesFocus: true
, giúp mở rộng hành vi tiêu điểm của phần tử trong cây bóng:
- Nếu bạn nhấp vào một nút bên trong shadow DOM và nút đó không phải là khu vực có thể lấy tiêu điểm, thì khu vực có thể lấy tiêu điểm đầu tiên sẽ được lấy tiêu điểm.
- Khi một nút bên trong shadow DOM nhận được tiêu điểm,
:focus
sẽ áp dụng cho máy chủ lưu trữ ngoài phần tử được lấy tiêu điểm.
Ví dụ – cách delegatesFocus: true
thay đổi hành vi của tiêu điểm
<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>
Kết quả
Trên đây là kết quả khi <x-focus>
được lấy làm tiêu điểm (người dùng nhấp, chuyển sang thẻ, focus()
, v.v.), Người dùng nhấp vào "Văn bản Shadow DOM có thể nhấp" hoặc tập trung vào <input>
nội bộ (bao gồm cả autofocus
).
Nếu đặt delegatesFocus: false
, bạn sẽ thấy nội dung sau:
Mẹo và thủ thuật
Trong nhiều năm, tôi đã học được một vài điều về cách tạo thành phần web. Tôi nghĩ bạn sẽ thấy một số mẹo sau đây hữu ích khi tạo thành phần và gỡ lỗi shadow DOM.
Sử dụng tính năng chứa CSS
Thông thường, bố cục/kiểu/vẽ của một thành phần web khá độc lập. Sử dụng tính năng đóng gói CSS trong :host
để cải thiện hiệu suất:
<style>
:host {
display: block;
contain: content; /* Boom. CSS containment FTW. */
}
</style>
Đặt lại kiểu có thể kế thừa
Các kiểu có thể kế thừa (background
, color
, font
, line-height
, v.v.) tiếp tục kế thừa trong DOM bóng. Tức là các phần tử này xuyên qua ranh giới DOM tối theo mặc định. Nếu bạn muốn bắt đầu lại, hãy sử dụng all: initial;
để đặt lại các kiểu có thể kế thừa về giá trị ban đầu khi chúng vượt qua ranh giới bóng.
<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>
Tìm tất cả các phần tử tuỳ chỉnh mà một trang sử dụng
Đôi khi, việc tìm các phần tử tuỳ chỉnh được sử dụng trên trang sẽ rất hữu ích. Để làm như vậy, bạn cần duyệt qua DOM bóng của tất cả các phần tử được sử dụng trên trang theo đệ quy.
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('*'));
Tạo phần tử từ <template>
Thay vì điền một thư mục gốc bóng bằng .innerHTML
, chúng ta có thể sử dụng <template>
khai báo. Mẫu là phần giữ chỗ lý tưởng để khai báo cấu trúc của một thành phần web.
Xem ví dụ trong phần "Phần tử tuỳ chỉnh: xây dựng thành phần web có thể sử dụng lại".
Nhật ký và hỗ trợ trình duyệt
Nếu đã theo dõi các thành phần web trong vài năm qua, bạn sẽ biết rằng Chrome 35+/Opera đã phân phối một phiên bản cũ của shadow DOM trong một thời gian. Blink sẽ tiếp tục hỗ trợ song song cả hai phiên bản trong một thời gian. Thông số kỹ thuật v0 cung cấp một phương thức khác để tạo gốc bóng (element.createShadowRoot
thay vì element.attachShadow
của v1). Việc gọi phương thức cũ sẽ tiếp tục tạo gốc bóng bằng ngữ nghĩa v0, vì vậy, mã v0 hiện có sẽ không bị lỗi.
Nếu bạn quan tâm đến thông số kỹ thuật cũ v0, hãy xem các bài viết trên html5rocks: 1, 2, 3. Ngoài ra, còn có một bảng so sánh tuyệt vời về những điểm khác biệt giữa shadow DOM phiên bản 0 và phiên bản 1.
Hỗ trợ trình duyệt
Shadow DOM phiên bản 1 được cung cấp trong Chrome 53 (trạng thái), Opera 40, Safari 10 và Firefox 63. Edge đã bắt đầu phát triển.
Để phát hiện tính năng shadow DOM, hãy kiểm tra xem attachShadow
có tồn tại hay không:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
Polyfill
Cho đến khi trình duyệt hỗ trợ rộng rãi, các trình bổ trợ polyfill shadydom và shadycss sẽ cung cấp cho bạn tính năng v1. Shady DOM bắt chước phạm vi DOM của Shadow DOM và các polyfill shadycss các thuộc tính tuỳ chỉnh CSS và phạm vi kiểu mà API gốc cung cấp.
Cài đặt polyfill:
bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss
Sử dụng polyfill:
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!
}
Hãy xem https://github.com/webcomponents/shadycss#usage để biết hướng dẫn về cách chèn/định phạm vi cho kiểu.
Kết luận
Lần đầu tiên, chúng ta có một API gốc thực hiện việc xác định phạm vi CSS, xác định phạm vi DOM và có thành phần kết hợp thực sự. Khi kết hợp với các API thành phần web khác như phần tử tuỳ chỉnh, shadow DOM cung cấp một cách để tạo các thành phần được đóng gói thực sự mà không cần hack hoặc sử dụng các gói cũ như <iframe>
.
Đừng hiểu lầm ý tôi. Shadow DOM chắc chắn là một khái niệm phức tạp! Nhưng đây là một công cụ mạnh mẽ đáng để tìm hiểu. Hãy dành thời gian để làm quen với ứng dụng. Hãy tìm hiểu và đặt câu hỏi!
Tài liệu đọc thêm
- Sự khác biệt giữa Shadow DOM phiên bản 1 và phiên bản 0
- "Giới thiệu Shadow DOM API dựa trên khe" từ Blog WebKit.
- Thành phần web và tương lai của CSS mô-đun của Philip Walton
- "Các phần tử tuỳ chỉnh: tạo thành phần web có thể sử dụng lại" trong phần WebFundamentals của Google.
- Thông số kỹ thuật của Shadow DOM phiên bản 1
- Thông số kỹ thuật của phần tử tuỳ chỉnh phiên bản 1
Câu hỏi thường gặp
Tôi có thể sử dụng Shadow DOM phiên bản 1 ngay hôm nay không?
Có, với polyfill. Xem phần Hỗ trợ trình duyệt.
DOM bóng cung cấp những tính năng bảo mật nào?
Shadow DOM không phải là một tính năng bảo mật. Đây là một công cụ nhẹ để xác định phạm vi CSS và ẩn các cây DOM trong thành phần. Nếu bạn muốn có một ranh giới bảo mật thực sự, hãy sử dụng <iframe>
.
Thành phần web có phải sử dụng shadow DOM không?
Không đâu. Bạn không cần tạo các thành phần web sử dụng DOM bóng. Tuy nhiên, việc tạo các phần tử tuỳ chỉnh sử dụng Shadow DOM có nghĩa là bạn có thể tận dụng các tính năng như phạm vi CSS, đóng gói DOM và thành phần.
Đâu là sự khác biệt giữa gốc bóng mở và gốc bóng kín?
Xem phần Rễ bóng đổ kín.