DOM bóng khai báo

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

Khai báo DOM tối là một tính năng của nền tảng web, hiện đang trong quá trình chuẩn hoá. Tính năng này được bật theo mặc định trong Chrome phiên bản 111.

Shadow DOM là một trong ba tiêu chuẩn về Thành phần web, được làm tròn theo mẫu HTMLPhần tử tuỳ chỉnh. DOM bóng cung cấp một cách để xác định phạm vi kiểu CSS đến một cây con DOM cụ thể và tách riêng 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 cách 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ác tính năng này kết hợp với nhau 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ư 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 gốc bóng (shadow) 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 sẽ hoạt động hiệu quả khi hiển thị phía máy khách: chính các mô-đun JavaScript xác định Phần tử tuỳ chỉnh của chúng ta cũng tạo Shadow Roots và đặt nội dung của chúng. 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 trong 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.

Mỗi dự án có những lý do yêu cầu Hiển thị phía máy chủ (SSR) khác nhau. Một số trang web phải cung cấp HTML do máy chủ kết xuất đầy đủ chức năng để đáp ứng các nguyên tắc về khả năng hỗ trợ tiếp cận. Một số trang web khác chọn cung cấp trải nghiệm không có JavaScript cơ sở để đả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 Hiển thị phía máy chủ vì không có cách thức tích hợp nào để thể hiện Gốc bóng trong HTML do máy chủ tạo. Ngoài ra, còn có những ảnh hưởng về hiệu suất khi đính kèm Shadow Roots vào các phần tử DOM đã được kết xuất mà không có chúng. Đ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ị một đèn flash của nội dung chưa định kiểu ("FOUC") trong khi tải các biểu định kiểu của Gốc bóng.

Declarative Shadow DOM (DSD) loại bỏ hạn chế này bằng cách chuyển Shadow DOM đến máy chủ.

Xây dựng một gốc bóng khai báo

Gốc bóng khai báo là 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 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 gốc bóng cho 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ử công cụ của Chrome cho nhà phát triển để hiển thị nội dung DOM bóng. Ví dụ: ký tự ↳ đại diện cho nội dung Light DOM được sắp xếp.

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

Lượng nước uống của thành phần

Bạn có thể sử dụng DOM bóng khai báo như một cách để đóng gói 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ẽ được tự động nâng cấp từ HTML tĩnh. Với sự ra mắt của DOM tối khai báo, giờ đây, Phần tử tuỳ chỉnh có thể có một gốc bóng trước khi nâng cấp.

Một Phần tử tuỳ chỉnh được nâng cấp từ HTML bao gồm Gốc bóng khai báo sẽ được đính kèm gốc bóng đó. Tức 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 phải tạo rõ ràng thuộc tính đó. Bạn nên kiểm tra this.shadowRoot để tìm mọi 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 Gốc bóng khai báo. Nếu giá trị là rỗng, tức là không có Declarative Shadow Root nà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>

Thành phần tuỳ chỉnh đã hoạt động được một thời gian và cho đến bây giờ, không có lý do gì để kiểm tra gốc bóng hiện có trước khi tạo gốc bằng attachShadow(). Declarative Shadow DOM 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ù: gọi phương thức attachShadow() trên phần tử hiện đang có Declarative Shadow Root sẽ không gửi ra lỗi. Thay vào đó, Declarative Shadow Root bị làm trống và trả về. Điều này cho phép các thành phần cũ chưa được tạo cho Shadow DOM khai báo tiếp tục hoạt động, vì gốc khai báo được lưu giữ cho đến khi tạo 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 cách thức rõ ràng để lấy thông tin tham chiếu đến Gốc bóng khai báo hiện có của một phần tử, cả mở và đóng. Bạn có thể dùng thuộc tính này để kiểm tra và sử dụng Gốc bóng khai báo bất kỳ, đồng thời vẫn quay lại attachShadow() trong trường hợp không được cung cấp.

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 bóng khai báo chỉ được liên kết với phần tử mẹ. Điều này có nghĩa là gốc bóng (shadow) luôn được bố trí với phần tử liên kết. Quyết định thiết kế này đảm bảo gốc bóng (shadow) có thể phát 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 ghi nhận và tạo bản ghi, vì việc thêm gốc bóng (shadow) vào một phần tử không đòi hỏi phải duy trì sổ đăng ký của các gốc bóng (shadow) hiện có.

Đánh đổi của việc liên kết gốc bóng với phần tử mẹ là bạn không thể khởi tạo 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 ít có khả năng quan trọng trong hầu hết các trường hợp sử dụng DOM tối 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ủ kết xuất thường chứa cấu trúc phần tử lặp lại, nhưng nội dung của các phần tử 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 Gốc bóng khai báo được tuần tự là hoàn toàn tĩnh, nên việc nâng cấp nhiều phần tử từ một Gốc bóng khai báo 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 qua mạng là 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 (shadow) dùng chung. Nếu DOM hỗ trợ tính năng tạo mẫu tích hợp, thì Roots 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 đổ cho một phần tử duy nhất.

Phát trực tiếp thật thú vị

Việc liên kết trực tiếp Nguồn gốc bóng khai báo với phần tử mẹ sẽ 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à đính kèm ngay khi gặp thẻ mở <template>. 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, để nó có thể được "phát trực tuyến": hiển thị ngay 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ỉ phân tích cú pháp

Khai báo DOM bóng là một tính năng của trình phân tích cú pháp HTML. Tức là một 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 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 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> 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ố điểm quan trọng cần cân nhắc 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 khi áp dụng Nguồn gốc bóng khai báo là truyền một tuỳ chọn includeShadowRoots mới đến DOMParser:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root here
</script>

Kết xuất máy chủ theo kiểu

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

Không hỗ trợ Biểu định kiểu có thể tạo không được hỗ trợ trong DOM tối khai báo. Nguyên nhân là do hiện không có cách nào để chuyển đổi tuần tự các biểu định kiểu có thể tạo trong HTML, cũng như 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 flash của nội dung không theo kiểu

Một vấn đề tiềm ẩn trong các trình duyệt chưa hỗ trợ Giao diện DOM tối khai báo là tránh "cài đặt nhanh 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 khi khai báo DOM bóng đổ, một kỹ thuật phổ biến để tránh FOUC là áp dụng quy tắc kiểu display:none cho 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 cách này, 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>

Nhờ sự ra mắt của DOM bóng khai báo, Phần tử tuỳ chỉnh có thể được hiển thị hoặc ghi nhận trong HTML sao cho nội dung bóng của chúng được đặt sẵn và sẵn sàng trước khi 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 "FOUC" của display:none sẽ ngăn nội dung của gốc bóng khai báo hiển thị. Tuy nhiên, việc xoá quy tắc đó sẽ khiến các trình duyệt không hỗ trợ Declarative Shadow DOM hiển thị nội dung không chính xác hoặc chưa được định kiểu cho đến khi Declarative Shadow DOM polyfill tải và chuyển đổi mẫu gốc bóng vào một gốc bóng thực.

Rất may là bạn có thể giải quyết vấn đề này 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 tối khai báo, phần tử <template shadowrootmode> sẽ được chuyển đổi ngay lập tức thành một gốc bóng, và không để lại phần tử <template> nào trong cây DOM. Các trình duyệt không hỗ trợ Khai báo DOM bóng sẽ giữ lại phần tử <template> mà chúng tôi có thể sử 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 các phần tử con khi chúng tuân theo phần tử <template shadowrootmode>. Khi đã xác định Phần tử tùy chỉnh, quy tắc sẽ không khớ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> 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

Shadow DOM khai báo đã có sẵn kể từ 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. Thuộc tính shadowrootmode và hành vi truyền trực tuyến mới hơn hiệ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 khả năng hỗ trợ trình duyệt bằng cách kiểm tra sự tồn tại của thuộc tính shadowRootMode trên nguyên mẫu HTMLTemplateElement:

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

Vải polyfill

Việc xây dựng polyfill được đơn giản hoá cho Declarative Shadow DOM 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 liên quan đến. Để 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 chúng thành Shadow Roots được đính kèm trên phần tử mẹ. Quá trình này có thể được thực hiện sau 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