Shadow DOM phiên bản 1 – Thành phần web độc lập

DOM tối cho phép nhà phát triển web tạo DOM và CSS riêng biệt cho các thành phần web

Tóm tắt

Shadow DOM giúp loại bỏ sự phức tạp khi xây dựng ứng dụng web. Sự mỏng manh này bắt nguồn từ bản chất toàn cục của HTML, CSS và JS. Nhiều năm qua, chúng tôi đã phát minh ra rất nhiều công cụ để giải quyết các vấn đề. Ví dụ: khi bạn sử dụng một mã/lớp HTML mới, không thể biết liệu mã/lớp đó có xung đột với tên hiện có mà trang sử dụng hay không. Lỗi nhỏ xuất hiện, tính đặc thù của CSS trở thành một vấn đề lớn (!important tất cả mọi thứ!), bộ chọn kiểu vượt quá tầm kiểm soát và hiệu suất có thể bị ảnh hưởng. Và còn nhiều lợi ích khác.

DOM tối khắc phục CSS và DOM. Tính năng này giới thiệu kiểu trong phạm vi 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 bằng mã đánh dấu, ẩn thông tin triển khai và tạo các thành phần độc lập trong JavaScript thuần tuý.

Giới thiệu

Shadow DOM là một trong ba tiêu chuẩn của Thành phần web: Mẫu HTML, Shadow DOM và Phần tử tuỳ chỉnh. HTML Imports từng là một phần của danh sách nhưng hiện được coi là không dùng nữa.

Bạn không cần phải tạo các thành phần web sử dụng DOM tối. Nhưng khi làm vậy, bạn sẽ tận dụng được các lợi ích của nó (phạm vi CSS, đóng gói DOM, thành phần) và xây dựng phần tử tuỳ chỉnh có thể sử dụng lại, có khả năng thích ứng, có thể định cấu hình cao và có thể sử dụng lại nhiều lần. Nếu các phần tử tuỳ chỉnh là cách để tạo một HTML mới (bằng API JS), thì DOM tối là cách bạn cung cấp HTML và CSS của phần tử đó. Hai API này kết hợp với nhau để tạo thành một thành phần có HTML, CSS và JavaScript độc lập.

Shadow DOM được thiết kế dưới dạng một công cụ để xây dựng các ứng dụng dựa trên thành phần. Do đó, nó mang lại giải pháp cho các vấn đề thường gặp trong quá trình phát triển web:

  • DOM tách 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 trong phạm vi: CSS được xác định bên trong DOM bóng được đặt trong phạm vi của DOM bóng đó. Các quy tắc về kiểu không bị rò rỉ ra ngoài và kiểu trang không bị rò rỉ vào trong.
  • Composition: 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 trong phạm vi có nghĩa là bạn có thể sử dụng bộ chọn CSS đơn giản, tên mã nhận dạng/lớp chung chung hơn và không phải lo lắng về xung đột đặt tên.
  • Năng suất – Hãy nghĩ về các ứng dụng trong các đoạn 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ẽ đề cập đến 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, bạn sẽ thấy bản minh hoạ trực tiếp ngay bên dưới. Nếu không, hãy xem toàn bộ nguồn trên GitHub.

Xem nguồn trên GitHub

DOM tối 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, chỉ riêng HTML thì không hữu ích lắm. Con người dễ dàng hiểu được 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 (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 duyệt làm 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ột mô hình dữ liệu (đối tượng/nút). Trình duyệt giữ nguyên hệ phân cấp của HTML bằng cách tạo một cây gồm các nút sau: DOM. Điều thú vị về DOM là nó là một đại diện trực tiếp của trang của bạn. 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 ra chứa các thuộc tính, phương thức và quan trọng nhất là… có thể được thao tác bằng các chương trình! Đó là lý do chúng ta có thể tạo các phần tử DOM trực tiếp bằng JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

sẽ tạo ra mã đánh dấu HTML sau:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Tất cả những điều đó đều tốt. Vậy thì DOM tối là gì?

DOM… trong bóng tối

DOM tối cũng giống như DOM thông thường, chỉ có hai điểm khác biệt: 1) cách tạo/sử dụng và 2) cách hoạt động của DOM tối so với phần còn lại của trang. Thông thường, bạn sẽ tạo các nút DOM và thêm chúng làm phần tử con của một phần tử khác. Với DOM tối, bạn tạo một cây DOM trong phạm vi đượ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 trong phạm vi này được gọi là cây bóng. Phần tử được đính kèm là máy chủ bóng. Bất cứ thứ gì bạn thêm vào bóng đều trở thành cục bộ cho phần tử lưu trữ, bao gồm cả <style>. Đây là cách DOM tối đạt được phạm vi kiểu CSS.

Tạo DOM tối

Gốc bóng là một đoạn tài liệu được đính kèm vào phần tử "máy chủ". Thao tác đính kèm gốc tối là cách phần tử có được DOM tối. Để tạo DOM tối 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 để lấp đầy 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ó quyền 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 tối nội bộ của riêng mình cho phần tử (<textarea>, <input>).
  • Phần tử này không nên lưu trữ DOM tối (<img>).

Ví dụ: cách này không hiệu quả:

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

Tạo DOM tối cho một phần tử tuỳ chỉnh

Shadow DOM đặc biệt hữu ích khi tạo phần tử tuỳ chỉnh. Sử dụng DOM tối để 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 gắn DOM tối vào chính nó, đóng gói DOM/CSS của nó:

// 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. Đầu tiên là phần tử tuỳ chỉnh tạo DOM tối 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 gốc bóng, nên các quy tắc CSS bên trong <style> sẽ được đặt trong phạm vi của <fancy-tabs>.

Cấu trúc và vùng chứa

Cấu trúc là một trong những tính năng ít được hiểu nhất của DOM tối, nhưng đây được cho là tính năng quan trọng nhất.

Trong thế giới phát triển web, thành phần là cách chúng ta xây dựng ứng dụng, khai báo từ HTML. Các thành phần 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ẻ thậm chí còn hoạt động với nhau. Composition là lý do khiến các thành phần gốc như <select>, <details>, <form> và <video> rất linh hoạt. Mỗi thẻ này chấp nhận một số HTML nhất định làm phần tử con và thực hiện một số thao tác đặc biệt với các phần tử đó. Ví dụ: <select> biết cách hiển thị <option> và <optgroup> thành các tiện ích trình đơn thả xuống và nhiều lựa chọn. Phần tử <details> hiển thị <summary> dưới dạng 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 chúng ảnh hưởng đến hành vi của video. Thật kỳ diệu!

Thuật ngữ: DOM sáng và DOM tối

Cấu trúc DOM tối giới thiệu một loạt các nguyên tắc cơ bản mới trong 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 có cùng cách hiểu.

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 và đóng gói thông tin chi tiết về quá trình triển khai của bạn. Thành phần 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 phẳng

Kết quả của việc trình duyệt phân phối DOM sáng của người dùng vào DOM tối của bạn, hiển thị sản phẩm cuối cùng. Cây phẳng là những gì bạn thấy sau cùng trong Công cụ cho nhà phát triển và những gì đượ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 phần tử <slot>. Vị trí 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 vùng, bạn mời 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 tại đây".

Các phần tử được phép "vượt qua" ranh giới DOM tối khi <slot> mời chúng vào. Các phần tử này được gọi là nút phân tán. Về mặt lý thuyết, các nút phân tán có vẻ hơi kỳ lạ. Các khe không di chuyển DOM về mặt vật lý; chúng kết xuất DOM tại 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 hoặc nhiều vùng trong DOM tối của thành phần đó. Các vị trí 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 DOM sáng, thì vị trí sẽ kết xuất 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 vị trí được đặt tên. Vị trí đượ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 vùng trong DOM tối 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, thì cây 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 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 ra để 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 tạo kiểu theo trang chính, xác định kiểu riêng hoặc cung cấp các hook (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 DOM tối là CSS trong phạm vi:

  • 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ị rò rỉ ra ngoài. Các kiểu này được đặt trong phạm vi của phần tử lưu trữ.

Bộ chọn CSS được dùng bên trong DOM tối sẽ áp dụng cục bộ cho thành phần của bạn. Trên thực tế, điều này có nghĩa là chúng ta có thể sử dụng lại tên mã nhận dạng/lớp phổ biến mà không phải lo lắng về xung đột ở những nơi khác trên trang. Bạn nên sử dụng bộ chọn CSS đơn giản hơ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>

Biểu định kiểu cũng được đặt trong 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 về cách phần tử <select> hiển thị một 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ự tạo kiểu theo cách khác dựa trên các thuộc tính mà bạn khai báo trên đó. 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ự tạo 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ới :host là các quy tắc trong trang mẹ có tính cụ thể cao hơn so với các quy tắc :host được xác định trong phần tử. Tức là kiểu bên ngoài sẽ được ưu tiên. Điều này cho phép người dùng ghi đè kiểu cấp cao nhất của bạn từ bên ngoài. Ngoài ra, :host chỉ hoạt động trong ngữ cảnh của một gốc tối, vì vậy, bạn không thể sử dụng gốc này bên ngoài DOM tối.

Dạng hàm của :host(<selector>) cho phép bạn nhắm mục tiêu máy chủ lưu trữ nếu máy chủ đó khớp với <selector>. Đây là một cách hay để 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 tạo kiểu cho 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>

Tạo kiểu dựa trên bối 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 việc này là tạo giao diện dựa trên môi trường xung quanh của 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 nó là phần tử con 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 cách tiếp cận thậm chí còn tốt hơn là tạo các điểm nối kiểu bằng cách sử dụng các 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>) khớp với các nút được phân phối vào một <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 tối 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ớ, <slot> không di chuyển DOM ánh sáng của người dùng. Khi các nút được phân phối vào một <slot>, <slot> sẽ kết xuất DOM của các nút đó nhưng các nút vẫn giữ nguyên vị trí thực tế. Các kiểu được áp dụng trước khi phân phối sẽ tiếp tục được áp dụng sau khi phân phối. Tuy nhiên, khi DOM sáng được phân phối, nó có thể áp dụng thêm các kiểu (những kiểu do DOM tối xác định).

Một ví dụ khác, chi tiết hơn từ <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 vùng: một vùng được đặt tên cho tiêu đề thẻ và một vùng cho nội dung của 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ể làm 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.

Tạo 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 được ưu tiên hơn kiểu được xác định trong DOM tối. 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 chỉ giúp bạn đạt được một số kết quả nhất định. Nhưng điều gì sẽ xảy ra nếu bạn muốn tạo kiểu cho các thành phần 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 tuỳ chỉnh CSS.

Tạo hook kiểu bằng thuộc tính tuỳ chỉnh CSS

Người dùng có thể điều chỉnh kiểu nội bộ nếu tác giả của thành phần cung cấp các điểm nối kiểu bằng cách sử dụng các thuộc tính tuỳ chỉnh CSS. Về mặt lý thuyết, ý 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 tối:

: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 tối (shadow root) đóng (nên tránh)

Có một loại DOM bóng khác được gọi là chế độ "đóng". Khi bạn tạo một cây bóng tối đóng, 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 DOM bóng của <video> vì trình duyệt triển khai DOM bóng bằng cách sử dụng gốc bóng ở chế độ đóng.

Ví dụ – tạo cây bóng tối đóng:

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 chịu ảnh hưởng của chế độ đóng:

  • Element.assignedSlot/TextNode.assignedSlot trả về null
  • Event.composedPath() cho các sự kiện liên quan đến các phần tử bên trong DOM bóng, trả về []

Sau đây là thông tin 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'}:

  1. Cảm giác an toàn giả tạo. Không có gì ngăn được kẻ tấn công chiếm quyền kiểm soát Element.prototype.attachShadow.

  2. Chế độ đóng ngăn mã phần tử tuỳ chỉnh truy cập vào DOM bóng của chính nó. Đó là một thất bại hoàn toàn. Thay vào đó, bạn sẽ phải lưu trữ tệp đối chiếu để dùng sau nếu muốn sử dụng những thành phần 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');
        }
        ...
    });
    
  3. Chế độ đóng khiến thành phần của bạn trở nên kém linh hoạt hơn đối với người dùng cuối. Khi bạn xây dựng các 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 hook tạo kiểu phù hợp cho các nút nội bộ. Với chế độ đóng, người dùng không thể ghi đè các chế độ mặc định và điều chỉnh kiểu. Việc có thể truy cập vào nội dung bên trong 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 vùng quảng cáo trong JS

API DOM tối cung cấp các tiện ích để làm việc với các vùng và nút phân phối. Những thuộc tính này rất hữu ích khi tạo một phần tử tuỳ chỉnh.

sự kiện slotchange

Sự kiện slotchange kích hoạt khi các nút được phân phối của một vùng thay đổi. Ví dụ: nếu người dùng thêm/xoá phần tử con khỏi DOM nhẹ.

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 nhẹ, bạn có thể thiết lập một MutationObserver trong hàm khởi tạo của phần tử.

Những phần tử nào đang được hiển thị trong một vùng quảng cáo?

Đôi khi, bạn nên biết những phần tử nào được liên kết với một vùng. Gọi slot.assignedNodes() để tìm những phần tử mà vùng quảng cáo đang hiển thị. Lựa chọn {flatten: true} cũng sẽ trả về nội dung dự phòng của một vùng (nếu không có nút nào được phân phối).

Ví dụ: giả sử DOM tối của bạn có dạng như sau:

<slot><b>fallback content</b></slot>
Cách sử dụngCuộc gọiKết quả
<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>]

Phần tử được gán cho vị trí 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 vị trí nào trong thành phần.

Mô hình sự kiện Shadow DOM

Khi một sự kiện nổi lên từ DOM tối, 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à DOM tối 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 chứ không phải các phần tử nội bộ trong DOM tối. Một số sự kiện thậm chí không truyền ra khỏi DOM bóng.

Các sự kiện 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 cuộn: wheel
  • Sự kiện đầu vào: beforeinput, input
  • Sự kiện bàn phím: keydown, keyup
  • Sự kiện kết hợp: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop, v.v.

Mẹo

Nếu cây bóng mở, 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 tối sẽ không nổi lên khỏi ranh giới tối 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 composed: false (mặc định), người dùng 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ớ lại mô hình sự kiện của DOM bóng, thì các sự kiện được kích hoạt bên trong DOM bóng 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 một <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), thì bạn cũng có thể truy cập vào nút nội bộ đã được 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 (giả sử một phần tử tuỳ chỉnh trong một phần tử tuỳ chỉnh khác), bạn cần truy sâu vào các gốc tối để tìm activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Một lựa chọn khác cho tiêu điểm là lựa chọn delegatesFocus: true, giúp mở rộng hành vi lấy 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 DOM bóng và nút đó không phải là vùng có thể làm tâm điểm, thì vùng có thể làm tâm điểm đầu tiên sẽ trở thành tâm điểm.
  • Khi một nút bên trong DOM tối được lấy 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 lấy 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ả

delegatesFocus: true behavior.

Trên đây là kết quả khi <x-focus> được lấy tiêu điểm (người dùng nhấp, nhấn phím tab, focus(), v.v.), "Văn bản DOM bóng có thể nhấp" được nhấp hoặc <input> nội bộ được lấy tiêu điểm (bao gồm cả autofocus).

Nếu bạn đặt delegatesFocus: false, thì đây là những gì bạn sẽ thấy:

delegatesFocus: false và dữ liệu đầu vào nội bộ được lấy tiêu điểm.
delegatesFocus: false và <input> nội bộ được lấy tiêu điểm.
delegatesFocus: false và x-focus
    nhận tiêu điểm (ví dụ: có tabindex='0').
delegatesFocus: false và <x-focus> được lấy tiêu điểm (ví dụ: có tabindex="0").
delegatesFocus: false và &quot;Văn bản DOM tối có thể nhấp&quot; được nhấp vào (hoặc vùng trống khác trong DOM tối của phần tử được nhấp vào).
delegatesFocus: false và "Văn bản DOM tối có thể nhấp" được nhấp vào (hoặc một vùng trống khác trong DOM tối của phần tử được nhấp vào).

Mẹo và thủ thuật

Qua 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 này hữu ích cho việc tạo thành phần và gỡ lỗi DOM tối.

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 CSS containment trong :host để cải thiện hiệu suất:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Đang đặ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à theo mặc định, các sự kiện này sẽ xuyên qua ranh giới DOM tối. Nếu bạn muốn bắt đầu từ đầu, 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 các kiểu này 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, bạn nên tìm các phần tử tuỳ chỉnh được dùng trên trang. Để làm như vậy, bạn cần duyệt qua DOM tối của tất cả các phần tử được sử dụng trên trang theo cách đệ 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 sẵn một 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.

Hãy xem ví dụ trong bài viết "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à dịch 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 trở lên/Opera đã cung cấp phiên bản cũ của DOM tối trong một thời gian. Blink sẽ tiếp tục hỗ trợ cả hai phiên bản song song 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ũ hơn sẽ tiếp tục tạo gốc bóng có ngữ nghĩa v0, vì vậy, mã v0 hiện có sẽ không bị hỏng.

Nếu bạn quan tâm đến thông số kỹ thuật v0 cũ, hãy xem các bài viết trên html5rocks: 1, 2, 3. Ngoài ra, còn có một bài so sánh rất hay về sự 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 DOM tối, 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, polyfill shadydom và shadycss sẽ cung cấp cho bạn tính năng v1. Shady DOM mô phỏng phạm vi DOM của Shadow DOM và 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 điều chỉnh/phạm vi kiểu của bạn.

Kết luận

Lần đầu tiên, chúng ta có một API gốc thực hiện đúng phạm vi CSS, phạm vi DOM và có thành phần thực sự. Kết hợp với các API thành phần web khác như các phần tử tuỳ chỉnh, DOM bóng 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 thủ thuật hoặc sử dụng các hành lý cũ hơn như <iframe>.

Đừng hiểu lầm tôi. Shadow DOM chắc chắn là một thứ phức tạp! Nhưng đây là một công cụ rất đáng để tìm hiểu. Hãy dành chút thời gian để tìm hiểu. Hãy tìm hiểu và đặt câu hỏi!

Tài liệu đọc thêm

Câu hỏi thường gặp

Tôi có thể sử dụng Shadow DOM phiên bản 1 ngay bây giờ không?

Có, nếu dùng polyfill. Xem phần Hỗ trợ trình duyệt.

Shadow DOM 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ụ gọn nhẹ để xác định phạm vi CSS và ẩn cây DOM trong thành phần. Nếu bạn muốn có ranh giới bảo mật thực sự, hãy sử dụng <iframe>.

Thành phần web có bắt buộc phải sử dụng DOM tối không?

Không đâu. Bạn không cần phải tạo các thành phần web sử dụng DOM tối. 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.

Sự khác biệt giữa gốc bóng mở và gốc bóng đóng là gì?

Xem Gốc bóng tối đã đóng.