DOM bóng khai báo

Một cách mới để triển khai và sử dụng Shadow DOM trực tiếp trong HTML.

Khai báo Shadow DOM 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 quy cách của 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.

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

Cho đến nay, cách duy nhất để sử dụng Shadow DOM là xây dựng 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 gian xây dựng. Đây có thể là một phần quan trọng trong việc cung cấp 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 cho việc Hiển thị phía máy chủ (SSR) sẽ khác nhau theo từng 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. Ngoài ra, hiệu suất của phần tử này cũng ảnh hưởng đến hiệu suất khi đính kèm Shadow Roots vào các phần tử DOM đã hiển thị mà không có các phần tử này. Đ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ủ.

Xây dựng gốc đổ bóng 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à ngay lập tức được áp dụng làm bóng gốc 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ã mẫu này tuân theo các quy ước của bảng điều khiển Phần tử Chrome Công cụ cho nhà phát triển khi hiển thị nội dung DOM bóng. Ví dụ: ký tự ↳ đại diện cho nội dung Light DOM có khe cắm.

Điều này mang lại cho chúng tôi những lợi ích của việc đóng gói và chiếu vùng của Shadow DOM trong HTML tĩnh. 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 để đóng 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, một Phần tử tuỳ chỉnh hiện 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 Khai báo Shadow Root sẽ đính kèm 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 thực thể mà không cần mã của bạn tạo một cách rõ ràng. Bạn nên kiểm tra this.shadowRoot để tìm gốc đổ bóng 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, thì không có Khai báo Shadow Root (Gốc) khai báo 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 nào để kiểm tra gốc đổ bóng hiện có trước khi tạo một phần tử bằng attachShadow(). DOM bóng khai báo bao gồm một thay đổi nhỏ cho phép các thành phần hiện có hoạt động mặc dù vậy: việc gọi phương thức attachShadow() trên một phần tử có Gốc Khai báo hiện có sẽ không gây ra 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 bóng khai báo tiếp tục hoạt động, vì các gốc khai báo được duy trì cho đến khi cần tạo một thay thế bắt buộc.

Đối với các Phần tử tuỳ chỉnh mới tạo, thuộc tính ElementInternals.shadowRoot mới sẽ 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à kín. 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 này.

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 trên mỗi gốc

Gốc khai báo chỉ liên kết với phần tử mẹ. Tức 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 gốc bóng có thể truyền trực tuyến được 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ó.

Sự đánh đổi của việc liên kết gốc đổ bóng với phần tử mẹ là không thể khởi động nhiều phần tử qua cùng một gốc bóng khai báo <template>. Tuy nhiên, điều này khó có thể xảy ra trong hầu hết các trường hợp sử dụng Shadow DOM được khai báo, vì nội dung của mỗi gốc bóng (shadow) hiếm khi giống hệt 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 những phần tử này thường khác nhau (ví dụ: văn bản hoặc thuộc tính có biến thể nhỏ). Vì nội dung của một Gốc khai báo chuyển đổi tuần tự hoàn toàn 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 gốc đổ bóng tương tự lặp lại đối với kích thước truyền mạng là tương đối nhỏ do ảnh hưởng của quá trình 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 nhận được sự hỗ trợ cho 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à đính kèm 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ẻ mở <template>. HTML đã phân tích cú pháp trong <template> sẽ được phân tích cú pháp trực tiếp vào gốc bóng đổ, do đó có thể được "streamed": kết xuất 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ỉ có trình phân tích cú pháp

DOM bóng 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 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 hiện diệ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 tạo 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> 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ố cân nhắc quan trọng về bảo mật, bạn cũng không thể tạo Gốc đổ bóng khai báo bằ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 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ị trên máy chủ theo kiểu

Biểu định kiểu nội tuyến và bên ngoài được hỗ trợ đầy đủ trong Gốc khai báo gốc 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 duy nhất được dùng chung cho tất cả các gốc đổ bóng, giúp loại bỏ mức hao tổn bộ nhớ trùng lặp.

Biểu định kiểu có thể tạo không được hỗ trợ trong DOM bóng khai báo. Nguyên nhân là do hiện không có cách chuyển đổi tuần tự các biểu định kiểu có thể tạo trong HTML và không có cách nào để tham chiếu đến các biểu định kiểu đó khi điền adoptedStyleSheets.

Tránh ánh sáng lóe lên của nội dung không có kiểu

Một vấn đề có thể xảy ra trong các trình duyệt chưa hỗ trợ Khai báo Shadow DOM là cần tránh hiện tượng "flash của nội dung chưa định kiểu" (FOUC), trong đó nội dung thô được hiển thị cho Phần tử tuỳ chỉnh chưa được nâng cấp. Trướ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 sự ra mắt của DOM bóng khai báo, các Phần tử tuỳ chỉnh có thể được hiển thị hoặc được cấp quyền trong HTML để nội dung bóng của chúng được đặt đúng chỗ và sẵn sàng trước khi tải nội dung 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 của bóng đổ thành một gốc bóng đổ thực sự.

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> sẽ được chuyển đổi ngay lập tức 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 của nó khi chúng theo sau 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ó mặt 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 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');
}

Ống polyfill

Việc xây dựng một polyfill đơn giản cho DOM bóng 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 thời gian hoặc các đặc điểm chỉ dành cho trình phân tích cú pháp mà quá trình triển khai trình duyệt sẽ lo ngại. Để tạo polyfill Declarative Shadow DOM, chúng ta có thể quét DOM để tìm tất cả các phần tử <template shadowrootmode>, sau đó chuyển đổi các phần tử đó thành các Shadow Roots đí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