DOM bóng khai báo

Shadow DOM khai báo là một tính năng tiêu chuẩn của nền tảng web, được hỗ trợ trong Chrome từ phiên bản 90. Xin lưu ý rằng thông số kỹ thuật cho tính năng này đã thay đổi vào năm 2023 (bao gồm cả việc đổi tên shadowroot thành shadowrootmode) và các phiên bản chuẩn mới nhất của tất cả các phần của tính năng này đều đã có trong Chrome phiên bản 124.

Hỗ trợ trình duyệt

  • Chrome: 111.
  • Cạnh: 111.
  • Firefox: 123.
  • Safari: 16.4.

Nguồn

Shadow DOM là một trong ba tiêu chuẩn Thành phần web, cùng với mẫu HTMLPhần tử tuỳ chỉnh. Shadow DOM cung cấp một cách để xác định phạm vi của các kiểu CSS cho một cây con DOM cụ thể và tách biệt cây con đó với phần còn lại của tài liệu. Phần tử <slot> cung cấp cho chúng ta một cách để kiểm soát vị trí chèn thành phần con của một Phần tử tuỳ chỉnh trong Cây bóng. Những tính năng này kết hợp lại cho phép một hệ thống xây dựng các thành phần độc lập, có thể tái sử dụng và tích hợp liền mạch vào các ứng dụng hiện có giống như phần tử HTML tích hợp sẵn.

Cho đến nay, cách duy nhất để sử dụng Shadow DOM là tạo một gốc bóng bằng JavaScript:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

Một API bắt buộc như thế này hoạt động tốt cho việc hiển thị phía máy khách: các mô-đun JavaScript tương tự xác định Phần tử tuỳ chỉnh của chúng ta cũng tạo Shadow Roots và thiết lập nội dung. Tuy nhiên, nhiều ứng dụng web cần hiển thị nội dung phía máy chủ hoặc HTML tĩnh tại thời điểm tạo bản dựng. Đây có thể là một phần quan trọng để mang lại trải nghiệm hợp lý cho những khách truy cập có thể không chạy được JavaScript.

Lý do sử dụng tính năng Kết xuất phía máy chủ (SSR) sẽ khác nhau tuỳ theo dự án. Để đáp ứng các nguyên tắc về hỗ trợ tiếp cận, một số trang web phải cung cấp HTML do máy chủ hiển thị với đầy đủ chức năng. Trong khi đó, một số trang web khác chọn cung cấp trải nghiệm cơ sở không có JavaScript như một cách để đảm bảo hiệu suất tốt trên các kết nối hoặc thiết bị chậm.

Trước đây, rất khó để sử dụng Shadow DOM kết hợp với Kết xuất phía máy chủ vì không có cách tích hợp nào để thể hiện Shadow Roots trong HTML do máy chủ tạo. Việc đính kèm Shadow Root vào các phần tử DOM đã được kết xuất mà không có Shadow Root cũng có thể ảnh hưởng đến hiệu suất. Điều này có thể khiến bố cục thay đổi sau khi trang tải hoặc tạm thời hiển thị flash của nội dung chưa định kiểu ("FOUC") trong khi tải biểu định kiểu của Shadow Root.

Declarative Shadow DOM (DSD) loại bỏ giới hạn này, đưa Shadow DOM đến máy chủ.

Cách tạo một Shadow Root khai báo

Gốc khai báo là một phần tử <template> có thuộc tính shadowrootmode:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

Trình phân tích cú pháp HTML sẽ phát hiện một phần tử mẫu có thuộc tính shadowrootmode và áp dụng ngay phần tử đó làm gốc bóng của phần tử mẹ. Việc tải mã đánh dấu HTML thuần tuý từ các mẫu ở trên sẽ dẫn đến cây DOM sau:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

Mẫu mã này tuân theo các quy ước của bảng điều khiển Elements (Thành phần) trong Chrome DevTools để hiển thị nội dung Shadow DOM. Ví dụ: ký tự đại diện cho nội dung DOM sáng được phân vùng.

Điều này mang lại cho chúng ta các lợi ích của tính năng đóng gói và chiếu khe của Shadow DOM trong HTML tĩnh. Bạn không cần JavaScript để tạo toàn bộ cây, bao gồm cả Shadow Root.

Uống nước thành phần

DOM bóng khai báo có thể được sử dụng riêng như một cách để gói các kiểu hoặc tuỳ chỉnh vị trí con, nhưng cách này hiệu quả nhất khi được sử dụng cùng với Phần tử tuỳ chỉnh. Các thành phần được tạo bằng Phần tử tuỳ chỉnh sẽ tự động được nâng cấp từ HTML tĩnh. Với sự ra mắt của DOM bóng khai báo, giờ đây Phần tử tuỳ chỉnh có thể có gốc bóng trước khi được nâng cấp.

Một Phần tử tuỳ chỉnh đang được nâng cấp từ HTML có chứa Gốc khai báo sẽ có sẵn gốc đổ bóng đó. Điều này có nghĩa là phần tử sẽ có sẵn thuộc tính shadowRoot khi được tạo bản sao mà không cần mã của bạn tạo một thuộc tính. Tốt nhất là bạn nên kiểm tra this.shadowRoot để tìm bất kỳ gốc bóng nào hiện có trong hàm khởi tạo của phần tử. Nếu đã có giá trị thì HTML cho thành phần này sẽ bao gồm cả Dọc gốc khai báo (Khai báo). Nếu giá trị là rỗng, tức là không có Khai báo Shadow Root trong HTML hoặc trình duyệt không hỗ trợ Declarative Shadow DOM.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

Phần tử tuỳ chỉnh đã xuất hiện được một thời gian và cho đến bây giờ, không có lý do gì để kiểm tra một gốc bóng (shadow) hiện có trước khi tạo một thành phần bằng attachShadow(). Mặc dù vậy, Shadow DOM khai báo có một thay đổi nhỏ cho phép các thành phần hiện có hoạt động: việc gọi phương thức attachShadow() trên một phần tử có Gốc bóng Khai báo hiện có sẽ không gửi lỗi. Thay vào đó, Gốc đổ bóng khai báo sẽ được làm trống và được trả về. Điều này cho phép các thành phần cũ không được tạo cho DOM tối khai báo tiếp tục hoạt động, vì gốc khai báo được bảo toàn cho đến khi cần tạo một thay thế bắt buộc.

Đối với Phần tử tuỳ chỉnh mới tạo, thuộc tính ElementInternals.shadowRoot mới cung cấp một cách thức rõ ràng để tham chiếu đến Gốc khai báo hiện có của một phần tử, cả mở và đóng. Bạn có thể dùng tính năng này để kiểm tra và sử dụng bất kỳ Gốc khai báo nào, đồng thời vẫn quay lại dùng attachShadow() trong trường hợp không cung cấp thuộc tính gốc.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

Một bóng đổ cho mỗi gốc

Gốc khai báo chỉ liên kết với phần tử mẹ. Điều này có nghĩa là gốc bóng đổ luôn được đặt cùng với phần tử liên quan. Quyết định thiết kế này đảm bảo rằng các gốc bóng có thể truyền trực tuyến như phần còn lại của tài liệu HTML. Việc này cũng thuận tiện cho việc biên soạn và tạo, vì việc thêm gốc bóng vào một phần tử không yêu cầu duy trì sổ đăng ký các gốc bóng hiện có.

Điểm đánh đổi khi liên kết gốc bóng với phần tử mẹ là không thể khởi chạy nhiều phần tử từ cùng một Gốc bóng khai báo <template>. Tuy nhiên, điều này có thể không quan trọng trong hầu hết các trường hợp sử dụng Shadow DOM khai báo, vì nội dung của mỗi gốc bóng hiếm khi giống nhau. Mặc dù HTML do máy chủ hiển thị thường chứa các cấu trúc phần tử lặp lại, nhưng nội dung của các cấu trúc này thường khác nhau – ví dụ: có sự khác biệt nhỏ về văn bản hoặc thuộc tính. Vì nội dung của một Gốc khai báo tuần tự hoàn toàn là tĩnh, nên việc nâng cấp nhiều phần tử từ một Gốc khai báo duy nhất sẽ chỉ hoạt động nếu các phần tử đó giống hệt nhau. Cuối cùng, tác động của các thư mục gốc bóng tương tự lặp lại đối với kích thước truyền tải mạng tương đối nhỏ do ảnh hưởng của việc nén.

Trong tương lai, bạn có thể truy cập lại vào gốc bóng đổ chung. Nếu DOM được hỗ trợ cho việc tạo mẫu tích hợp sẵn, thì Gốc đổ bóng khai báo có thể được coi là các mẫu được tạo thực thể để tạo gốc đổ bóng cho một phần tử nhất định. Thiết kế DOM tối khai báo hiện tại cho phép khả năng này tồn tại trong tương lai bằng cách giới hạn liên kết gốc bóng với một phần tử duy nhất.

Thật thú vị khi phát trực tuyến

Việc liên kết trực tiếp Gốc đổ bóng khai báo với phần tử mẹ giúp đơn giản hoá quá trình nâng cấp và gắn chúng vào phần tử đó. Gốc bóng khai báo được phát hiện trong quá trình phân tích cú pháp HTML và được đính kèm ngay khi gặp thẻ <template> mở. HTML được phân tích cú pháp trong <template> được phân tích cú pháp trực tiếp vào gốc bóng, vì vậy, HTML có thể được "truyền trực tuyến": hiển thị khi nhận được.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

Chỉ trình phân tích cú pháp

DOM tối khai báo là một tính năng của trình phân tích cú pháp HTML. Điều này có nghĩa là Gốc bóng khi khai báo sẽ chỉ được phân tích cú pháp và đính kèm cho các thẻ <template> có thuộc tính shadowrootmode xuất hiện trong quá trình phân tích cú pháp HTML. Nói cách khác, Gốc đổ bóng khai báo có thể được xây dựng trong quá trình phân tích cú pháp HTML ban đầu:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

Việc đặt thuộc tính shadowrootmode của phần tử <template> sẽ không có tác dụng gì và mẫu vẫn là một phần tử mẫu thông thường:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

Để tránh một số vấn đề bảo mật quan trọng, bạn cũng không thể tạo Gốc bóng phản chiếu khai báo bằng cách sử dụng các API phân tích cú pháp mảnh như innerHTML hoặc insertAdjacentHTML(). Cách duy nhất để phân tích cú pháp HTML có áp dụng phần gốc bóng đổ khai báo là sử dụng setHTMLUnsafe() hoặc parseHTMLUnsafe():

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

Hiển thị phía máy chủ có kiểu

Biểu định kiểu cùng dòng và bên ngoài được hỗ trợ đầy đủ trong Gốc khai báo (declarative Roots) bằng cách sử dụng thẻ <style><link> tiêu chuẩn:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

Các kiểu được chỉ định theo cách này cũng được tối ưu hoá cao: nếu cùng một biểu định kiểu hiện diện trong nhiều gốc bóng khai báo, thì biểu định kiểu đó chỉ được tải và phân tích cú pháp một lần. Trình duyệt sử dụng một CSSStyleSheet sao lưu được chia sẻ bởi tất cả các gốc bóng, loại bỏ hao tổn bộ nhớ trùng lặp.

Trang kiểu có thể tạo không được hỗ trợ trong Shadow DOM khai báo. Lý do là hiện tại, không có cách nào để chuyển đổi tuần tự các kiểu trang có thể tạo trong HTML và không có cách nào để tham chiếu đến các kiểu trang đó khi điền adoptedStyleSheets.

Cách tránh hiện nội dung chưa được định kiểu

Một vấn đề tiềm ẩn trong các trình duyệt chưa hỗ trợ DOM tối khai báo là tránh "nội dung không được định kiểu" (FOUC), trong đó nội dung thô sẽ hiển thị cho các Phần tử tuỳ chỉnh chưa được nâng cấp. Trước khi có DOM bóng đổ khai báo, một kỹ thuật phổ biến để tránh FOUC là áp dụng quy tắc kiểu display:none cho các Phần tử tuỳ chỉnh chưa được tải, vì các phần tử này chưa được đính kèm và điền sẵn gốc bóng đổ. Theo đó, nội dung sẽ không xuất hiện cho đến khi "sẵn sàng":

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

Với việc ra mắt DOM bóng đổ khai báo, các phần tử tuỳ chỉnh có thể được hiển thị hoặc tạo bằng HTML để nội dung bóng đổ của chúng được đặt đúng vị trí và sẵn sàng trước khi tải quá trình triển khai thành phần phía máy khách:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

Trong trường hợp này, quy tắc display:none "FOUC" sẽ ngăn nội dung của gốc bóng khai báo xuất hiện. Tuy nhiên, việc xoá quy tắc đó sẽ khiến các trình duyệt không hỗ trợ Khai báo Shadow DOM hiển thị nội dung không chính xác hoặc chưa định kiểu cho đến khi polyfill trong Khai báo Shadow DOM tải và chuyển đổi mẫu gốc bóng thành một gốc bóng đổ thực.

Rất may là việc này có thể được giải quyết trong CSS bằng cách sửa đổi quy tắc kiểu FOUC. Trong các trình duyệt hỗ trợ DOM bóng khai báo, phần tử <template shadowrootmode> ngay lập tức được chuyển đổi thành gốc bóng, không để lại phần tử <template> trong cây DOM. Những trình duyệt không hỗ trợ DOM tối khai báo giữ nguyên phần tử <template> mà chúng ta có thể dùng để ngăn chặn FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

Thay vì ẩn Phần tử tuỳ chỉnh chưa được xác định, quy tắc "FOUC" đã sửa đổi sẽ ẩn phần tử con khi theo phần tử <template shadowrootmode>. Khi Phần tử tùy chỉnh được xác định, quy tắc không còn phù hợp nữa. Quy tắc này sẽ bị bỏ qua trong các trình duyệt hỗ trợ DOM tối khai báo vì thành phần con <template shadowrootmode> sẽ bị xoá trong quá trình phân tích cú pháp HTML.

Phát hiện tính năng và hỗ trợ trình duyệt

DOM bóng khai báo đã có từ phiên bản Chrome 90 và Edge 91, nhưng nó sử dụng một thuộc tính cũ không theo chuẩn có tên là shadowroot thay vì thuộc tính shadowrootmode đã chuẩn hoá. Thuộc tính shadowrootmode và hành vi truyền trực tuyến mới hơn có trong Chrome 111 và Edge 111.

Là một API nền tảng web mới, Declarative Shadow DOM chưa được hỗ trợ rộng rãi trên tất cả các trình duyệt. Bạn có thể phát hiện sự hỗ trợ của trình duyệt bằng cách kiểm tra xem có thuộc tính shadowRootMode trên nguyên mẫu của HTMLTemplateElement hay không:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

Polyfill

Việc tạo một polyfill đơn giản cho DOM tối khai báo tương đối đơn giản, vì polyfill không cần phải sao chép hoàn hảo ngữ nghĩa về thời gian hoặc các đặc điểm chỉ dành cho trình phân tích cú pháp mà việc triển khai trình duyệt quan tâm đến. Để polyfill DOM bóng đổ khai báo, chúng ta có thể quét DOM để tìm tất cả phần tử <template shadowrootmode>, sau đó chuyển đổi các phần tử đó thành Shadow Root đính kèm trên phần tử mẹ. Quá trình này có thể được thực hiện khi tài liệu đã sẵn sàng hoặc được kích hoạt bởi các sự kiện cụ thể hơn như vòng đời của Phần tử tuỳ chỉnh.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

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