Shadow DOM phiên bản 1 – Thành phần web tự chứa

Shadow DOM cho phép nhà phát triển web tạo DOM và CSS được chia ngăn cho các thành phần web

Tóm tắt

Shadow DOM loại bỏ sự dễ hỏng của việc xây dựng ứng dụng web. Sự dễ hỏng này là do bản chất toàn cầu của HTML, CSS và JS. Trong những năm qua, chúng tôi đã phát minh ra số lượng tools khổng lồ để né tránh các vấn đề này. Ví dụ: khi bạn sử dụng một mã/lớp HTML mới, hệ thống sẽ không có thông báo về việc mã/lớp này có xung đột với tên mà trang đang sử dụng hay không. Một số 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. Danh sách sẽ tiếp tục.

DOM tối sửa CSS và DOM. Hướng dẫn này giới thiệu các kiểu theo 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 có mã đánh dấu, ẩn chi tiết triển khai và tác giả các thành phần độc lập trong vanilla JavaScript.

Giới thiệu

DOM tối là một trong ba tiêu chuẩn về Thành phần web: Mẫu HTML, DOM tốiPhần tử tuỳ chỉnh. Các lệnh Nhập HTML từng nằm trong danh sách nhưng giờ đây được coi là không dùng nữa.

Bạn không cần phải soạn thảo các thành phần web sử dụng DOM bóng. Tuy nhiên, khi làm như vậy, bạn hãy tận dụng 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 các phần tử tuỳ chỉnh có thể tái sử dụng, có khả năng phục hồi, định cấu hình cao và cực kỳ sử dụng lại. Nếu các phần tử tuỳ chỉnh là cách để tạo HTML mới (với API JS), thì DOM bóng là cách bạn cung cấp HTML và CSS của nó. Hai API kết hợp để tạo thành 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ụ để tạo ứng dụng dựa trên thành phần. Do đó, công cụ này mang đến 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ó phạm vi: CSS được xác định bên trong shadow DOM thuộc phạm vi của nó. Quy tắc về kiểu không bị rò rỉ và kiểu trang không tràn lề.
  • Thành phần: Thiết kế một API dựa trên mã đánh dấu, mang tính khai báo cho thành phần của bạn.
  • Đơn giản hoá CSS – DOM có giới hạn có 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 chung hơn và không phải lo lắng về việc xung đột khi đặt tên.
  • Năng suất – Xem xét các ứng dụng trong các phần của DOM thay vì một trang lớn (toàn cầu).

Bản minh hoạ fancy-tabs

Trong 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 về API đó ngay bên dưới. Nếu không, hãy xem nguồn đầy đủ trên GitHub.

Xem nguồn trên GitHub

DOM bóng là gì?

Nền trên DOM

HTML hỗ trợ web vì nó dễ làm việc. Bằng cách khai báo một vài thẻ, bạn có thể tạo tác giả cho một trang trong vài giây có cả cấu trúc và phần trình bày. Tuy nhiên, bản thân HTML không hoàn toàn hữu ích. Con người rất dễ hiểu ngôn ngữ dựa trên văn bản, nhưng máy móc cần thông tin khác. Nhập Mô hình đối tượng tài liệu hoặc DOM.

Khi tải một trang web, trình duyệt sẽ thực hiện một loạt nội dung thú vị. Một trong những việc mà công cụ 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 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à bản trình bày trực tiếp cho trang của bạn. Không giống như HTML tĩnh mà chúng tôi tạo ra, các nút do trình duyệt tạo có chứa các thuộc tính, phương thức và nhất là... có thể bị các chương trình điều khiển! Đó là lý do tại sao chúng tôi 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ả đều tốt đẹp. Vậy shadow DOM là gì?

DOM... ẩn

DOM bóng chỉ là một DOM thông thường với hai điểm khác biệt: 1) cách nó được tạo/sử dụng và 2) cách nó hoạt động 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 các nút này làm phần tử con của một phần tử khác. Với shadow DOM, bạn sẽ tạo một cây DOM trong phạm vi được đính kèm với phần tử, nhưng tách biệt với các phần tử con thực tế của nó. Cây con trong phạm vi này được gọi là cây bóng. Phần tử đi kèm với lớp này là shadow host (máy chủ bóng đổ). Mọi phần tử bạn thêm vào bóng sẽ trở thành nội dung cục bộ đối với 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

Thư mục gốc bóng đổ là một mảnh tài liệu được đính kèm vào một phần tử “máy chủ lưu trữ”. Hành động đính kèm một gốc bóng (shadow) là cách các phần tử nhận được DOM bóng (shadow) của mình. Để 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 để lấp đầy thư mục gốc bóng đổ, nhưng bạn cũng có thể sử dụng các API DOM khác. Đây là trang 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ể xuất hiện 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>).
  • Việc phần tử lưu trữ DOM bóng (<img>) là không hợp lý.

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 bóng cho phần tử tuỳ chỉnh

DOM tối đặc biệt hữu ích khi tạo phần tử tuỳ chỉnh. Sử dụng DOM tối để tạo khu vực cho HTML, CSS và JS của một phần tử, từ đó tạo ra "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 nó, đó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 riêng khi tạo một thực thể của <fancy-tabs>. 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ó phạm vi là <fancy-tabs>.

Cấu trúc và khung giờ

Cấu trúc là một trong những tính năng khó được hiểu nhất của DOM bóng, nhưng có lẽ là quan trọng nhất.

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

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

Thành phần DOM bóng giới thiệu một loạt nguyên tắc cơ bản mới trong việc phát triển web. Trước khi tìm hiểu về cỏ dại, hãy chuẩn hoá một số thuật ngữ nên chúng ta đang sử dụng cùng một ngôn ngữ.

DOM sáng

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à phần tử con thực sự 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>

DOM tối

DOM mà tác giả thành phần ghi. DOM bóng nằm cục bộ 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 các chi tiết triển khai của bạn. 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 sử dụng thành phần tạo.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Cây DOM 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, kết xuất sản phẩm hoàn thiện. Cây được làm phẳng là những gì bạn cuối cùng sẽ thấy trong Công cụ cho nhà phát triển và những gì 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>

DOM bóng kết hợp các cây DOM khác nhau lại với nhau bằng phần tử <slot>. Vùng 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ị trí, bạn mời thẻ đánh dấu bên ngoài hiển thị trong DOM tối 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 của DOM bóng khi <slot> mời các phần tử đó tham gia. Các phần tử này được gọi là nút phân phối. Về mặt lý thuyết, các nút được phân phối có thể hơi kỳ lạ. Các vị trí không di chuyển DOM; chúng hiển thị nó ở một vị trí khác trong DOM tối.

Một thành phần có thể xác định không hoặc nhiều vị trí trong DOM bóng (shadow) của thành phần đó. Ô 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ùng này 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 vùng đượ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 vị trí 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 muốn biết, cây dẹt 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 đã 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 biên soạn để xử lý nhiều loại phần tử con... giống như <select> đã làm!

Định kiểu

Có nhiều tuỳ chọn để định kiểu cho các thành phần web. Trang chính có thể tạo kiểu cho một thành phần sử dụng DOM bóng, xác định kiểu riêng hoặc cung cấp hook (dưới dạng thuộc tính tuỳ chỉnh CSS) để người dùng ghi đè kiểu 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 bóng là CSS theo phạm vi:

  • Bộ chọn CSS từ trang 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 lề. Các chế độ này thuộc phạm vi của phần tử lưu trữ.

Bộ chọn CSS dùng bên trong DOM bóng 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 các 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ộ chọn CSS đơn giản hơn là một phương pháp hay nhất trong môi trường DOM bóng. Chúng cũng tốt cho hiệu suất.

Ví dụ – các kiểu được xác định trong gốc bóng là kiểu 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 nằm trong phạm vi cây bóng:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Đã bao giờ bạn thắc mắc cách 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:

<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 tự khác nhau dựa trên các thuộc tính 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ụ – chính kiểu thành phần

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Một điểm hạn chế của :host là các quy tắc trên trang gốc có tính đặc trưng cao hơn các quy tắc :host được xác định trong phần tử. Tức là phong cách bên ngoài sẽ chiến thắng. Việc 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 gốc bóng đổ, vì vậy, bạn không thể sử dụng bên ngoài DOM tối.

Hình thức chức năng của :host(<selector>) cho phép bạn nhắm mục tiêu máy chủ nếu 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 sự tương tác hoặc trạng thái của người dùng hoặc tạo kiểu cho các nút nội bộ dựa trên máy chủ.

<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ày nếu thành phần đó hoặc bất kỳ đối tượng 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 là tuỳ chỉnh giao diện dựa trên hoạt động xung quanh của một thành phần. Ví dụ: nhiều người thực hiện 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 là thành phần con của .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() có thể hữu ích cho việc tuỳ chỉnh giao diện, nhưng một cách tiếp cận còn tốt hơn nữa là tạo hook kiểu bằ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 <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>.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 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 chúng nhưng các nút thực tế vẫn được giữ nguyên. Các kiểu được á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ể thực hiện các kiểu bổ sung (các 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ó 2 khe: một khe được đặt tên cho tiêu đề thẻ và một khe 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 tôi 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 được phân phối có thuộc tính selected. JS của phần tử tuỳ chỉnh (không xuất hiện ở đâ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 ưu tiên kiểu được xác định trong DOM bóng (shadow DOM). Ví dụ: nếu người dùng ghi bộ chọn fancy-tabs { width: 500px; }, thì bộ chọn đó sẽ được ưu tiên hơn quy tắc của thành phần: :host { width: 650px;}.

Bạn chỉ có thể định kiểu cho thành phần. Nhưng điều gì sẽ xảy ra nếu bạn muốn tạo kiểu cho phần bên trong của một thành phần? Để làm được điều đó, chúng tôi 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ể chỉnh sửa kiểu nội bộ nếu tác giả của thành phần cung cấp hook tạo kiểu bằng cách sử dụng các thuộc tính tuỳ chỉnh của 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 này 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ị này sẽ mặc định là #9E9E9E.

Chủ đề nâng cao

Tạo gốc bóng đóng (nên tránh)

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

Ví dụ – tạo cây bóng đó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() đối với các sự kiện liên kết với các phần tử bên trong DOM bóng, sẽ trả về []

Sau đây là thông tin tóm tắt của tôi về lý do bạn không nên tạo các 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 kẻ tấn công xâm nhập Element.prototype.attachShadow.

  2. Chế độ đóng ngăn mã phần tử tuỳ chỉnh của bạn truy cập vào DOM bóng của chính nó. Bạn chưa hoàn tất. Thay vào đó, bạn sẽ phải lưu trữ tệp tham chiếu để sử dụng sau nếu muốn sử dụng những nội dung như querySelector(). Điều này hoàn toàn huỷ bỏ mục đích ban đầu của chế độ khép kín!

        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 kém linh hoạt hơn đối với người dùng cuối. Khi xây dựng các thành phần web, đôi khi bạn sẽ quên thêm một tính năng. Một chế độ 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 đưa đầy đủ hook định kiểu vào các nút nội bộ. Ở chế độ đóng, người dùng không có cách nào để ghi đè chế độ mặc định và tinh chỉnh kiểu. Việc có thể truy cập vào bên trong thành phần này là cực kỳ hữu ích. Cuối cùng, người dùng sẽ phát triển thành phần của bạn, tìm một thành phần khác hoặc tự tạo thành phần của riêng họ nếu thành phần đó không làm được điều họ muốn :(

Làm việc với vị trí trong JS

API DOM tối cung cấp các tiện ích để làm việc với các khe và nút được phân phối. Các phần tử này rất hữu ích khi biên soạn một phần tử tuỳ chỉnh.

sự kiện thay đổi vị trí

Sự kiện slotchange kích hoạt khi các nút đã phân phối của một vị trí thay đổi. Ví dụ: nếu người dùng thêm/xoá phần tử con khỏi DOM 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 thành phần nào đang được hiển thị trong một vùng?

Đôi khi, việc biết những phần tử nào được liên kết với một vùng cũng rất hữu ích. Gọi slot.assignedNodes() để tìm các phần tử mà vị trí đang kết xuất. Tuỳ 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 đang được phân phối).

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

<slot><b>fallback content</b></slot>
Cách sử dụngGọiKết quả
<my-component>văn bản 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>]

Một phần tử được chỉ định cho vị trí nào?

Bạn cũng có thể trả lời câu hỏi ngược. element.assignedSlot cho bạn biết bạn sẽ chỉ định phần tử cho ô thành phần nào.

Mô hình sự kiện DOM bóng

Khi một sự kiện xuất hiện từ DOM tối, mục tiêu của sự kiện đó sẽ được điều chỉnh để duy trì hoạt động đóng gói mà DOM tối cung cấp. Điều này nghĩa 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 tối. Một số sự kiện thậm chí không truyền ra khỏi DOM tối.

Các sự kiện vượt qua ranh giới bóng là:

  • Sự kiện trọng tâm: blur, focus, focusin, focusout
  • Sự kiện với chuột: click, dblclick, mousedown, mouseenter, mousemove, v.v.
  • Sự kiện bánh xe: wheel
  • Nhập sự kiện: beforeinput, input
  • Sự kiện trên bàn phím: keydown, keyup
  • Sự kiện sáng tác: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop, v.v.

Mẹo

Nếu cây bóng đổ đang mở, việc gọi event.composedPath() sẽ trả về một mảng các nút mà sự kiện đã di chuyển 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 đổ sẽ không thoát ra khỏi ranh giới bóng, trừ phi sự kiện được tạo bằng cách sử dụ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ể theo dõi sự kiện bên ngoài gốc bóng (shadow) 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 tối, các sự kiện được kích hoạt bên trong DOM tối 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 có vẻ như đến từ <x-focus>, không phải từ <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 (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 đi sâu 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, mở rộng hành vi lấy nét của các phần tử trong cây bóng đổ:

  • Nếu bạn nhấp vào một nút bên trong DOM tối và nút đó không phải là vùng có thể lấy tiêu điểm, thì vùng có thể lấy tiêu điểm đầu tiên sẽ được lấy làm tâm điểm.
  • Khi một nút bên trong DOM tối 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 lấy nét

<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ả

loyaltyFocus: hành vi đúng.

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

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

actionsFocus: giá trị false và giá trị đầu vào nội bộ được lấy làm tâm điểm.
delegatesFocus: false<input> nội bộ được lấy làm tâm điểm.
arrowsFocus: lấy tiêu điểm false (sai) và x-focus (ví dụ: nó có tabindex=&#39;0&#39;).
delegatesFocus: false<x-focus> được lấy làm tâm điểm (ví dụ: có tabindex="0").
arrowsFocus: false và &quot;Văn bản DOM bóng đổ có thể nhấp&quot; (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 bóng có thể nhấp" được nhấp vào (hoặc vùng trống khác trong DOM bóng của phần tử được nhấp vào).

Mẹo và thủ thuật

Trong những năm qua, tôi đã học được đôi điều về việc tạo các thành phần web. Tôi cho rằng một số mẹo trong số này sẽ hữu ích cho việc tạo các thành phần và gỡ lỗi DOM tối.

Sử dụng vùng chứa CSS

Thông thường, bố cục/kiểu/sơn của một thành phần web là khá độc lập. Sử dụng vùng chứa CSS trong :host để đạt hiệu quả tốt nhất:

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

Đặt lại kiểu kế thừa

Các kiểu kế thừa (background, color, font, line-height, v.v.) tiếp tục kế thừa trong DOM tối. Điều này nghĩa là chúng xuyên qua ranh giới DOM bóng theo mặc định. Nếu bạn muốn bắt đầu với một phương tiện chặn mới, hãy sử dụng all: initial; để đặt lại các kiểu 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ả 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 đã dùng trên trang. Để làm như vậy, bạn cần truyền tải định kỳ DOM bóng của tất cả phần tử được sử dụng trên trang.

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ừ một <template>

Thay vì điền sẵn một thư mục gốc bóng đổ bằng .innerHTML, chúng ta có thể 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 phần "Phần tử tuỳ chỉnh: xây dựng thành phần web có thể sử dụng lại".

Hỗ trợ trình duyệt và nhật ký

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 đã vận chuyển một phiên bản DOM tối cũ hơn 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 một gốc bóng đổ với 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ũ của phiên bản 0, hãy xem các bài viết về html5rock: 1, 2, 3. Bạn cũng có thể so sánh tuyệt vời về sự khác biệt giữa bóng DOM v0 và v1.

Hỗ trợ trình duyệt

Shadow DOM phiên bản 1 được vận chuyển trong Chrome 53 (trạng thái), Opera 40, Safari 10 và Firefox 63. Edge đã bắt đầu phát triển.

Để làm nổi bật tính năng phát hiện DOM bóng, hãy kiểm tra sự tồn tại của attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Vải polyfill

Cho đến khi trình duyệt được hỗ trợ rộng rãi, các polyfill shadydomshadycss sẽ cung cấp cho bạn tính năng v1. Shady DOM bắt chước phạm vi của DOM của Shadow DOM và polyfills polyfills thuộc tính khả năng nhầm của CSS và phạm vi kiểu mà API gốc cung cấp.

Cài đặt polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Sử dụng đoạn mã 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 truy cập https://github.com/webcomponents/shadycss#usage để biết hướng dẫn về cách chỉnh sửa/Phạm vi cho các kiểu của bạn.

Kết luận

Lần đầu tiên, chúng tôi có một API gốc có phạm vi CSS, phạm vi DOM phù hợp và có thành phần 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 cách thức để tạo các thành phần được đóng gói thực sự mà không cần tấn công hoặc sử dụng các hành lý cũ hơn như <iframe>.

Đừng hiểu sai ý tôi. DOM bóng chắc chắn là một con thú phức tạp! Tuy nhiên, đây là một quái vật đáng học hỏi. Hãy dành chút thời gian cho công cụ này. 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

Hiện tôi có thể sử dụng Shadow DOM phiên bản 1 không?

Có. Xem phần Hỗ trợ trình duyệt.

DOM tối cung cấp những tính năng bảo mật nào?

DOM tối 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á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>.

Một thành phần web có phải sử dụng DOM bóng không?

Không đâu. Bạn không 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 DOM bóng có nghĩa là bạn có thể tận dụng các tính năng như phạm vi của CSS, đóng gói DOM và thành phần.

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

Xem phần Gốc bóng đóng.