DOM bóng 101

Dominic Cooney
Dominic Cooney

Giới thiệu

Thành phần web là một bộ tiêu chuẩn tiên tiến giúp:

  1. Cho phép tạo các tiện ích
  2. ...có thể được sử dụng lại một cách đáng tin cậy
  3. ...và sẽ không làm hỏng trang nếu phiên bản tiếp theo của thành phần thay đổi thông tin triển khai nội bộ.

Điều này có nghĩa là bạn phải quyết định khi nào sử dụng HTML/JavaScript và khi nào nên sử dụng Thành phần web? Không đâu! HTML và JavaScript có thể tạo ra nội dung trực quan tương tác. Tiện ích là các nội dung trực quan tương tác. Bạn nên tận dụng các kỹ năng HTML và JavaScript của mình khi phát triển một tiện ích. Các tiêu chuẩn về Thành phần web được thiết kế để giúp bạn làm được điều đó.

Tuy nhiên, có một vấn đề cơ bản khiến các tiện ích được xây dựng bằng HTML và JavaScript khó sử dụng: Cây DOM bên trong tiện ích không được đóng gói từ phần còn lại của trang. Việc thiếu đóng gói này nghĩa là biểu định kiểu tài liệu có thể vô tình áp dụng cho các phần bên trong tiện ích; JavaScript có thể vô tình sửa đổi các phần bên trong tiện ích; các mã nhận dạng của bạn có thể trùng lặp với mã nhận dạng bên trong tiện ích, v.v.

Thành phần web bao gồm ba phần:

  1. Mẫu
  2. DOM tối
  3. Phần tử tuỳ chỉnh

Shadow DOM giải quyết vấn đề đóng gói cây DOM. 4 phần của Thành phần web được thiết kế để hoạt động cùng nhau, nhưng bạn cũng có thể chọn phần cần sử dụng trong Thành phần web. Hướng dẫn này cho bạn biết cách sử dụng Shadow DOM.

Xin chào Thế giới bóng tối

Với DOM bóng, các phần tử có thể nhận được một loại nút mới liên kết với các phần tử đó. Loại nút mới này được gọi là gốc bóng đổ. Một phần tử liên kết với một gốc bóng được gọi là máy chủ bóng đổ. Nội dung của máy chủ bóng đổ không được kết xuất; thay vào đó, nội dung của gốc bóng đổ sẽ được kết xuất.

Ví dụ: nếu bạn có mã đánh dấu như sau:

<button>Hello, world!</button>
<script>
var host = document.querySelector('button');
var root = host.createShadowRoot();
root.textContent = 'こんにちは、影の世界!';
</script>

thì thay vì

<button id="ex1a">Hello, world!</button>
<script>
function remove(selector) {
  Array.prototype.forEach.call(
      document.querySelectorAll(selector),
      function (node) { node.parentNode.removeChild(node); });
}

if (!HTMLElement.prototype.createShadowRoot) {
  remove('#ex1a');
  document.write('<img src="SS1.png" alt="Screenshot of a button with \'Hello, world!\' on it.">');
}
</script>

trang của bạn trông giống như

<button id="ex1b">Hello, world!</button>
<script>
(function () {
  if (!HTMLElement.prototype.createShadowRoot) {
    remove('#ex1b');
    document.write('<img src="SS2.png" alt="Screenshot of a button with \'Hello, shadow world!\' in Japanese on it.">');
    return;
  }
  var host = document.querySelector('#ex1b');
  var root = host.createShadowRoot();
  root.textContent = 'こんにちは、影の世界!';
})();
</script>

Không chỉ vậy, nếu JavaScript trên trang hỏi xem textContent của nút là gì, thì nút này sẽ không nhận được "こんこちImages",影の世界!" mà sẽ nhận được "Hello, world!" vì cây con DOM bên dưới gốc bóng (shadow) đã được đóng gói.

Tách nội dung khỏi bản trình bày

Bây giờ, chúng ta sẽ xem xét sử dụng Shadow DOM để tách nội dung khỏi bản trình bày. Giả sử chúng ta có thẻ tên này:

<style>
.ex2a.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.ex2a .boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.ex2a .name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="ex2a outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Đây là mục đánh dấu. Đây là những gì bạn sẽ viết hôm nay. Mô hình này không sử dụng DOM bóng:

<style>
.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Vì cây DOM không đóng gói nên toàn bộ cấu trúc của thẻ tên sẽ hiển thị với tài liệu. Nếu các phần tử khác trên trang vô tình sử dụng cùng một tên lớp để định kiểu hoặc viết tập lệnh, thì chúng ta sẽ gặp khó khăn.

Chúng ta có thể tránh việc này.

Bước 1: Ẩn thông tin về bản trình bày

Về mặt ngữ nghĩa, có thể chúng ta chỉ quan tâm đến việc:

  • Đó là một thẻ tên.
  • Tên là "Bob".

Trước tiên, chúng ta viết mã đánh dấu gần với ngữ nghĩa thực sự hơn mà chúng ta muốn:

<div id="nameTag">Bob</div>

Sau đó, chúng ta đặt tất cả các kiểu và div dùng để trình bày vào phần tử <template>:

<div id="nameTag">Bob</div>
<template id="nameTagTemplate">
<span class="unchanged"><style>
.outer {
  border: 2px solid brown;

  … same as above …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div></span>
</template>

Tại thời điểm này, "Bob" là tính năng duy nhất được hiển thị. Vì chúng tôi đã di chuyển các phần tử DOM trình bày bên trong phần tử <template>, nên các phần tử này sẽ không hiển thị nhưng chúng có thể truy cập được qua JavaScript. Chúng tôi làm điều đó ngay bây giờ để điền thư mục gốc bóng đổ:

<script>
var shadow = document.querySelector('#nameTag').createShadowRoot();
var template = document.querySelector('#nameTagTemplate');
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

Bây giờ, chúng ta đã thiết lập gốc bóng, thẻ tên sẽ được hiển thị lại. Nếu bạn nhấp chuột phải vào thẻ tên rồi kiểm tra phần tử mà bạn thấy rằng đó là mã đánh dấu ngữ nghĩa hấp dẫn:

<div id="nameTag">Bob</div>

Điều này chứng minh rằng bằng cách sử dụng Shadow DOM, chúng tôi đã ẩn thông tin trình bày của thẻ tên khỏi tài liệu. Thông tin trình bày được gói gọn trong DOM tối.

Bước 2: Tách biệt nội dung khỏi bản trình bày

Thẻ tên hiện ẩn thông tin chi tiết về bản trình bày khỏi trang, nhưng không thực sự tách riêng bản trình bày khỏi nội dung, vì mặc dù nội dung (tên "Bob") nằm trên trang, nhưng tên hiển thị lại là tên chúng ta sao chép vào thư mục gốc bóng đổ. Nếu muốn thay đổi tên trên thẻ tên, chúng tôi cần thực hiện ở hai vị trí và chúng có thể không đồng bộ.

Các phần tử HTML có cấu trúc — ví dụ: bạn có thể đặt một nút vào bên trong một bảng. Thành phần là những gì chúng ta cần ở đây: Thẻ tên phải là cấu trúc của nền đỏ, văn bản "Xin chào!" và nội dung trên thẻ tên.

Bạn, tác giả thành phần, xác định cách cấu trúc hoạt động với tiện ích của bạn bằng cách sử dụng một phần tử mới có tên là <content>. Thao tác này sẽ tạo một điểm chèn trong bản trình bày tiện ích và điểm chèn sẽ chọn nội dung từ máy chủ bóng để hiển thị tại thời điểm đó.

Nếu chúng ta thay đổi mã đánh dấu trong DOM bóng thành như sau:

<span class="unchanged"><template id="nameTagTemplate">
<style>
  …
</style></span>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    <content></content>
  </div>
</div>
<span class="unchanged"></template></span>

Khi thẻ tên được kết xuất, nội dung của máy chủ bóng đổ được chiếu vào vị trí mà phần tử <content> xuất hiện.

Giờ đây, cấu trúc của tài liệu trở nên đơn giản hơn vì tên tài liệu chỉ nằm ở một nơi duy nhất. Nếu trang của bạn cần cập nhật tên của người dùng, bạn chỉ cần viết:

document.querySelector('#nameTag').textContent = 'Shellie';

và thế là xong. Quá trình kết xuất thẻ tên sẽ được trình duyệt tự động cập nhật vì chúng ta đang chiếu nội dung của thẻ tên vào đúng vị trí bằng <content>.

<div id="ex2b">

Giờ đây, chúng ta đã tách biệt nội dung và bản trình bày. Nội dung nằm trong tài liệu; bản trình bày nằm trong Shadow DOM. Các hình ảnh này sẽ tự động được trình duyệt đồng bộ hoá khi cần kết xuất nội dung nào đó.

Bước 3: Lợi nhuận

Bằng cách phân tách nội dung và bản trình bày, chúng ta có thể đơn giản hoá mã thao tác nội dung — trong ví dụ về thẻ tên, mã đó chỉ cần xử lý một cấu trúc đơn giản chứa một <div> thay vì nhiều <div>.

Giờ đây, nếu thay đổi cách trình bày, chúng ta không cần thay đổi mã nào!

Ví dụ: giả sử chúng tôi muốn bản địa hoá thẻ tên của mình. Đây vẫn là một thẻ tên nên nội dung ngữ nghĩa trong tài liệu sẽ không thay đổi:

<div id="nameTag">Bob</div>

Mã thiết lập gốc bóng (shadow) vẫn giữ nguyên. Chỉ những nội dung được đưa vào gốc bóng đổ sẽ thay đổi:

<template id="nameTagTemplate">
<style>
.outer {
  border: 2px solid pink;
  border-radius: 1em;
  background: url(sakura.jpg);
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
  font-family: sans-serif;
  font-weight: bold;
}
.name {
  font-size: 45pt;
  font-weight: normal;
  margin-top: 0.8em;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="name">
    <content></content>
  </div>
  と申します。
</div>
</template>

Đây là một cải tiến lớn so với tình hình trên web hiện nay, vì mã cập nhật tên của bạn có thể phụ thuộc vào cấu trúc của thành phần đơn giản và nhất quán. Mã cập nhật tên của bạn không cần biết cấu trúc dùng để kết xuất. Nếu chúng tôi xem xét nội dung hiển thị, thì tên sẽ xuất hiện ở thứ hai bằng tiếng Anh (sau "Xin chào! Tôi tên là"), nhưng tên tiếng Nhật đầu tiên là (trước "Thanks申 {/5}す"). Sự khác biệt đó về mặt ngữ nghĩa không có nghĩa khi cập nhật tên đang hiển thị, vì vậy, mã cập nhật tên không cần phải biết chi tiết đó.

Tín dụng bổ sung: Chiếu nâng cao

Trong ví dụ trên, phần tử <content> chọn tất cả nội dung trong máy chủ đổ bóng. Bằng cách sử dụng thuộc tính select, bạn có thể kiểm soát dự án của một phần tử nội dung. Bạn cũng có thể sử dụng nhiều phần tử nội dung.

Ví dụ: nếu bạn có tài liệu chứa nội dung sau:

<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>

và một gốc bóng (shadow) sử dụng bộ chọn CSS để chọn nội dung cụ thể:

<div style="background: purple; padding: 1em;">
  <div style="color: red;">
    <content **select=".first"**></content>
  </div>
  <div style="color: yellow;">
    <content **select="div"**></content>
  </div>
  <div style="color: blue;">
    <content **select=".email">**</content>
  </div>
</div>

Phần tử <div class="email"> được cả phần tử <content select="div"><content select=".email"> so khớp. Địa chỉ email của Bob xuất hiện bao nhiêu lần và có màu gì?

Câu trả lời là địa chỉ email của Bob xuất hiện một lần và có màu vàng.

Lý do là vì những người tấn công vào Shadow DOM biết rằng việc xây dựng cây nội dung thực sự hiển thị trên màn hình giống như một bữa tiệc lớn. Phần tử nội dung là lời mời cho phép nội dung từ tài liệu vào bữa tiệc kết xuất DOM bóng ở hậu trường. Những lời mời này được gửi theo thứ tự; việc ai nhận được lời mời phụ thuộc vào người nhận lời mời (tức là thuộc tính select). Sau khi được mời, nội dung sẽ luôn chấp nhận lời mời (ai không chấp nhận?!) và sau khi được mời, nội dung sẽ tiếp tục được gửi đi. Nếu lời mời tiếp theo được gửi lại đến địa chỉ đó thì không có ai ở nhà và lời mời sẽ không đến bữa tiệc của bạn.

Trong ví dụ trên, <div class="email"> khớp với cả bộ chọn div và bộ chọn .email, nhưng vì thành phần nội dung có bộ chọn div xuất hiện ở phần trước của tài liệu, nên <div class="email"> thuộc về bên màu vàng và không có ai có mặt để tham dự bữa tiệc màu xanh dương. (Đó có thể là lý do nó có màu xanh lam, mặc dù sự khổ nạn rất thích được ở bên bạn, nên bạn sẽ chẳng bao giờ biết được.)

Nếu nội dung nào đó được mời tham gia không bên, thì nội dung đó sẽ không được hiển thị. Đó là những gì đã xảy ra với văn bản "Hello, world" trong ví dụ đầu tiên. Điều này hữu ích khi bạn muốn đạt được cách hiển thị hoàn toàn khác: Viết mô hình ngữ nghĩa trong tài liệu. Đây là mô hình mà tập lệnh trên trang có thể truy cập được, nhưng ẩn mô hình đó để mục đích kết xuất và kết nối mô hình đó với một mô hình hiển thị thực sự khác trong DOM bóng bằng JavaScript.

Ví dụ: HTML có bộ chọn ngày đẹp mắt. Nếu viết <input type="date">, bạn sẽ nhận được một lịch bật lên gọn gàng. Nhưng điều gì sẽ xảy ra nếu bạn muốn để người dùng chọn một phạm vi ngày cho kỳ nghỉ trên đảo món tráng miệng của họ (bạn biết đấy... với những chiếc võng làm từ dây nho đỏ). Bạn thiết lập tài liệu theo cách sau:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

nhưng tạo DOM bóng sử dụng bảng để tạo lịch mượt mà sẽ làm nổi bật phạm vi ngày, v.v. Khi người dùng nhấp vào các ngày trong lịch, thành phần sẽ cập nhật trạng thái trong dữ liệu đầu vào startDate và endDate; khi người dùng gửi biểu mẫu, giá trị của các phần tử đầu vào đó sẽ được gửi.

Tại sao tôi lại đưa nhãn vào tài liệu nếu chúng không được hiển thị? Lý do là nếu người dùng xem biểu mẫu bằng trình duyệt không hỗ trợ Shadow DOM, thì biểu mẫu vẫn sử dụng được, tuy nhiên biểu mẫu không đẹp. Người dùng sẽ thấy một số dòng mã như:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

Bạn vượt qua Shadow DOM 101

Đó là những điều cơ bản của Shadow DOM — bạn vượt qua Shadow DOM 101! Bạn có thể làm được nhiều việc hơn với Shadow DOM, chẳng hạn như có thể sử dụng nhiều bóng đổ trên một máy chủ bóng hoặc bóng lồng để đóng gói hoặc thiết kế trang bằng Khung hiển thị hướng mô hình (MDV) và DOM bóng. Và Thành phần web không chỉ là DOM tối.

Chúng tôi giải thích những điều đó trong các bài đăng sau.