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 tiện ích
  2. …có thể được sử dụng lại một cách đáng tin cậy
  3. …và không làm hỏng các 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 thời điểm sử dụng HTML/JavaScript và thời điểm sử dụng Thành phần web không? Không đâu! HTML và JavaScript có thể tạo nội dung hình ảnh tương tác. Tiện ích là các thành phần hình ảnh có tính tương tác. Bạn nên tận dụng các kỹ năng HTML và JavaScript khi phát triển 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 điều đó.

Tuy nhiên, có một vấn đề cơ bản khiến các tiện ích được tạo 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 tính năng đóng gói này có nghĩa là tệp kiểu tài liệu của bạn có thể vô tình áp dụng cho các phần bên trong tiện ích; JavaScript của bạn có thể vô tình sửa đổi các phần bên trong tiện ích; 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 sử dụng phần nào của Thành phần web. Hướng dẫn này sẽ cho bạn biết cách sử dụng Shadow DOM.

Xin chào, Shadow World

Với Shadow DOM, các phần tử có thể nhận được một loại nút mới liên kết với chúng. Loại nút mới này được gọi là gốc bóng. Phần tử có 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 hiển thị; thay vào đó, nội dung của gốc bóng đượ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 sẽ trông như thế

<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 textContent của nút là gì, thì nó sẽ không nhận được "こんにちは、影の世界!" mà là "Hello, world!" vì cây con DOM trong gốc bóng đượ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 cách 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 như sau:

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

Sau đây là mã đánh dấu. Đây là nội dung bạn sẽ viết hôm nay. Không sử dụng Shadow DOM:

<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 thiếu tính nă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 để tạo kiểu hoặc viết tập lệnh, thì chúng ta sẽ phải trải qua nhiều tổn thất.

Chúng ta có thể tránh gặp phải khoảng thời gian không vui.

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

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

  • Đó là 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ự 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 một 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à nội dung duy nhất được kết xuất. Vì chúng ta đã di chuyển các phần tử DOM trình bày vào bên trong một phần tử <template>, nên các phần tử này không được hiển thị, nhưng bạn có thể truy cập các phần tử này từ JavaScript. Chúng ta sẽ thực hiện việc đó ngay để điền sẵ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ẽ hiển thị lại. Nếu nhấp chuột phải vào thẻ tên để kiểm tra phần tử, bạn sẽ thấy phần tử đó dễ đánh dấu theo ngữ nghĩa:

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

Điều này cho thấy rằng bằng cách sử dụng Shadow DOM, chúng ta đã ẩn chi tiết trình bày của thẻ tên khỏi tài liệu. Thông tin chi tiết về bản trình bày được đóng gói trong Shadow DOM.

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

Giờ đây, thẻ tên của chúng ta sẽ ẩn thông tin chi tiết về bản trình bày khỏi trang, nhưng thực tế là không tách biệt bản trình bày khỏi nội dung, vì mặc dù nội dung (tên "Bob") có trong trang, nhưng tên được hiển thị là tên mà 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 ta cần thực hiện việc này ở hai nơi và chúng có thể không đồng bộ.

Các phần tử HTML có tính chất kết hợp – ví dụ: bạn có thể đặt một nút bên trong một bảng. Thành phần ("composition") là những gì chúng ta cần ở đây: Thẻ tên phải là thành phần của nền màu đỏ, văn bản "Hi!" ("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 hoạt động của thành phần kết hợp với tiện ích bằng cách sử dụng một phần tử mới có tên là <content>. Thao tác này tạo một điểm chèn trong bản trình bày của 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 điểm đó.

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

<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 sẽ đượ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 chỉ nằm ở một nơi — tài liệu. Nếu trang của bạn cần cập nhật tên người dùng, bạn chỉ cần viết:

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

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

<div id="ex2b">

Bây giờ, 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. Trình duyệt sẽ tự động đồng bộ hóa các định dạng này khi đến lúc hiển thị nội dung nào đó.

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

Bằng cách tách nội dung và nội dung trình bày, chúng ta có thể đơn giản hoá mã thao tác với 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 cấu trúc.

Bây giờ, nếu thay đổi nội dung trình bày, chúng ta không cần thay đổi bất kỳ mã nào!

Ví dụ: giả sử chúng ta muốn bản địa hoá thẻ tên. Đây vẫn là thẻ tên, vì vậy, 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 vẫn giữ nguyên. Những thay đổi đượ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 điểm cải tiến lớn so với tình hình hiện tại trên web, vì mã cập nhật tê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 ta xem xét nội dung được hiển thị, tên này sẽ xuất hiện thứ hai bằng tiếng Anh (sau “Hi! Tên tôi là”), nhưng trước tiên là tiếng Nhật (trước “と申します”). Sự khác biệt đó không có ý nghĩa về ngữ nghĩa từ quan điểm cập nhật tên đang hiển thị, vì vậy, mã cập nhật tên không cần biết về chi tiết đó.

Điểm thưởng: Chiếu nâng cao

Trong ví dụ trên, phần tử <content> chọn lọc tất cả nội dung từ máy chủ lưu trữ bóng. Bằng cách sử dụng thuộc tính select, bạn có thể kiểm soát nội dung mà một phần tử nội dung hiển thị. 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 thông tin 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 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"> khớp với cả phần tử <content select="div"><content select=".email">. Đị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à những người đã từng hack Shadow DOM đều biết rằng việc tạo cây của nội dung thực sự được 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 nhóm hiển thị Shadow DOM ở hậu trường. Những lời mời này được phân phối theo thứ tự; người nhận lời mời phụ thuộc vào người được gửi lời mời (tức là thuộc tính select). Nội dung, sau khi được mời, luôn chấp nhận lời mời (ai mà không chấp nhận chứ?!) và bắt đầu chạy. Nếu lời mời tiếp theo được gửi đến địa chỉ đó một lần nữa thì nghĩa là không có ai ở nhà và lời mời đó sẽ không đến nhóm 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ì phần tử nội dung có bộ chọn div xuất hiện trước trong tài liệu, nên <div class="email"> sẽ chuyển đến nhóm màu vàng và không ai có thể chuyển đến nhóm màu xanh dương. (Đó có thể là lý do khiến nó có màu xanh dương, mặc dù sự bất hạnh luôn cần có bạn đồng hành, nên bạn không bao giờ biết được.)

Nếu một phần tử được mời đến không có bên, thì phần tử đó 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 rất hữu ích khi bạn muốn đạt được một kết xuất hoàn toàn khác: Viết mô hình ngữ nghĩa trong tài liệu, đây là nội dung mà các tập lệnh trong trang có thể truy cập, nhưng ẩn nội dung đó cho mục đích kết xuất và kết nối với một mô hình kết xuất thực sự khác trong Shadow DOM bằng JavaScript.

Ví dụ: HTML có một bộ chọn ngày rất đẹp. Nếu viết <input type="date">, bạn sẽ thấy một lịch bật lên gọn gàng. Nhưng nếu bạn muốn cho phép người dùng chọn một khoảng ngày cho kỳ nghỉ trên đảo để thưởng thức món tráng miệng (bạn biết đấy… với võng làm từ câ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 Shadow DOM sử dụng bảng để tạo một lịch trình tinh tế, 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 này sẽ cập nhật trạng thái trong các mục nhập startDate và endDate; khi người dùng gửi biểu mẫu, các giá trị từ các phần tử đầu vào đó sẽ được gửi.

Tại sao tôi lại đưa các nhãn vào tài liệu nếu các nhãn đó sẽ không 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 có thể sử dụng được, chỉ là không đẹp mắt. Người dùng thấy một số thông tin 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 đã hoàn thành khoá học Shadow DOM 101

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

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