Shadow DOM แบบประกาศ

วิธีใหม่ในการปรับใช้และใช้ Shadow DOM ใน HTML โดยตรง

Jason Miller
Jason Miller
Mason Freed
Mason Freed

Declarative Shadow DOM เป็นฟีเจอร์แพลตฟอร์มเว็บมาตรฐานที่ Chrome รองรับตั้งแต่เวอร์ชัน 90 เป็นต้นไป โปรดทราบว่ามีการเปลี่ยนแปลงข้อกำหนดของฟีเจอร์นี้ในปี 2023 (รวมถึงการเปลี่ยนชื่อ shadowroot เป็น shadowrootmode) และฟีเจอร์มาตรฐานเวอร์ชันล่าสุดของทุกส่วนมีการเผยแพร่ใน Chrome เวอร์ชัน 124

Shadow DOM เป็น 1 ใน 3 มาตรฐานของคอมโพเนนต์เว็บที่ปัดเศษด้วยเทมเพลต HTML และองค์ประกอบที่กำหนดเอง Shadow DOM มอบวิธีในการกำหนดขอบเขตรูปแบบ CSS ไปยังแผนผังย่อย DOM ที่เฉพาะเจาะจงและแยกต้นไม้ย่อยนั้นออกจากส่วนที่เหลือของเอกสาร เอลิเมนต์ <slot> ช่วยให้เราควบคุมตำแหน่งย่อยขององค์ประกอบที่กำหนดเองที่ควรแทรกภายใน Shadow Tree คุณลักษณะเหล่านี้รวมกันทำให้ระบบสามารถสร้างคอมโพเนนต์แบบใช้งานซ้ำได้ในตัว ซึ���งสามารถผสานเข้ากับแอปพลิเคชันที่มีอยู่ได้อย่างราบรื่นเช่นเดียวกับองค์ประกอบ HTML ในตัว

จนถึงตอนนี้ วิธีเดียวที่จะใช้ Shadow DOM ได้คือการสร้างรากเงาโดยใช้ JavaScript

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

API ที่จำเป็นเช่นตัวอย่างนี้ใช้ได้กับการแสดงผลฝั่งไคลเอ็นต์ โมดูล JavaScript เดียวกับที่กำหนดองค์ประกอบที่กำหนดเองของเราก็จะสร้าง Shadow Roots และตั้งค่าเนื้อหาด้วยเช่นกัน อย่างไรก็ตาม เว็บแอปพลิเคชันจำนวนมากต้องแสดงผลเนื้อหาฝั่งเซิร์ฟเวอร์หรือ HTML แบบคงที่ ณ เวลาบิลด์ นี่อาจเป็นส่วนสำคัญในการมอบประสบการณ์ที่สมเหตุสมผลแก่ผู้เข้าชมที่อาจเรียกใช้ JavaScript ไม่ได้

เหตุผลรองรับสำหรับการแสดงผลฝั่งเซิร์ฟเวอร์ (SSR) จะแตกต่างกันไปในแต่ละโปรเจ็กต์ บางเว็บไซต์ต้องใช้ HTML ที่แสดงผลโดยเซิร์ฟเวอร์ซึ่งทำงานได้อย่างสมบูรณ์เพื่อให้เป็นไปตามหลักเกณฑ์การช่วยเหลือพิเศษ บางเว็บไซต์เลือกที่จะมอบประสบการณ์การใช้งาน JavaScript พื้นฐานเพื่อรับประกันประสิทธิภาพที่ดีในการเชื่อมต่อหรืออุปกรณ์ที่ช้า

ที่ผ่านมา การใช้ Shadow DOM ร่วมกับการแสดงผลฝั่งเซิร์ฟเวอร์นั้นเป็นเรื่องยาก เนื่องจากไม่มีวิธีในตัวเพื่อแสดง Shadow Root ใน HTML ที่เซิร์ฟเวอร์สร้างขึ้น นอกจากนี้ ยังมีผลกระทบด้านประสิทธิภาพเมื่อแนบ Shadow Roots ไปยังองค์ประกอบ DOM ที่แสดงผลแล้วโดยไม่มีข้อมูลดังกล่าว ซึ่งอาจทำให้เกิดการเปลี่ยนเลย์เอาต์หลังจากโหลดหน้าเว็บ หรือแสดงแฟลชของเนื้อหาที่ไม่มีการจัดรูปแบบ ("FOUC") ชั่วคราวขณะโหลดสไตล์ชีตข��ง Shadow Root

Declarative Shadow DOM (DSD) จะนำข้อจำกัดนี้ออกและนำ Shadow DOM ไปยังเซิร์ฟเวอร์

การสร้างรูทของเงาประกาศ

รากเงาประกาศ ( Declarative Shadow Root) เป็นองค์ประกอบ <template> ที่มีแอตทริบิวต์ shadowrootmode ดังนี้

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

โปรแกรมแยกวิเคราะห์ HTML จะตรวจหาองค์ประกอบเทมเพลตที่มีแอตทริบิวต์ shadowrootmode และใช้เป็นรูทเงาขององค์ประกอบระดับบนสุดทันที การโหลดมาร์กอัป HTML ล้วนจากตัวอย่างด้านบนจะส่งผลให้เกิดแผนผัง DOM ต่อไปนี้

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

ตัวอย่างโค้ดนี้เป็นไปตามแบบแผนของแผงองค์ประกอบเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome สำหรับการแสดงเนื้อหา Shadow DOM เช่น อักขระ ↳ หมายถึงเนื้อหา Light DOM แบบสล็อตแมชชีน

ซึ่งทำให้เราได้รับประโยชน์จากการห่อหุ้ม DOM และการฉายภาพสล็อตใน HTML แบบคงที่ ไม่จำเป็นต้องใช้ JavaScript เพื่อสร้าง แผนผังทั้งต้น รวมถึง Shadow Root ด้วย

ส่วนประกอบของน้ำ

คุณสามารถใช้ Declarative Shadow DOM เพื่อห่อหุ้มรูปแบบหรือปรับแต่งตำแหน่งย่อยก็ได้ แต่จะมีประสิทธิภาพมากที่สุดเมื่อใช้ร่วมกับองค์ประกอบที่กำหนดเอง คอมโพเนนต์ที่สร้างขึ้นโดยใช้องค์ประกอบที่กำหนดเองจะได้รับการอัปเกรดจาก HTML แบบคงที่โดยอัตโนมัติ ด้วยการเปิดตัว Declarative Shadow DOM ตอนนี้ทำให้องค์ประกอบที่กำหนดเองมีรูทเงาก่อนที่จะอัปเกรดได้

องค์ประกอบที่กำหนดเองซึ่งอัปเกรดจาก HTML ที่มีรูทเงาประกาศ จะมีรากเงาดังกล่าวแนบมาด้วยแล้ว ซึ่งหมายความว่าองค์ประกอบจะมีพร็อพเพอร์ตี้ shadowRoot อยู่แล้วเมื่อมีการสร้างอินสแตนซ์ โดยไม่ต้องสร้างโค้ดอย่างชัดแจ้ง ควรตรวจสอบ this.shadowRoot ���พื่อหารูทเงาที่มีอยู่ในเครื่องมือสร้างขององค์ประกอบ หากมีค่าอยู่แล้ว HTML สำหรับค����โพเนนต์����้จะมี Declarative Shadow Root หากค่าเป็น Null แสดงว่าไม่มี Declarative Shadow Root ใน HTML หรือเบราว์เซอร์ไม่รองรับ 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>

องค์ประกอบที่กำหนดเองมีการใช้งานมาระยะหนึ่งแล้ว และจนถึงตอนนี้ก็ยังไม่มีเหตุผลที่ควรตรวจสอบรูทเงาที่มีอยู่ก่อนสร้างด้วย attachShadow() Declarative Shadow DOM มีการเปลี่ยนแปลงเล็กน้อยที่ช่วยให้คอมโพเนนต์ที่มีอยู่ทำงานได้แม้จะเป็นเช่นนี้ กล่าวคือการเรียกเมธอด attachShadow() ในองค์ประกอบที่มี Shadow Root แบบ Declarative ที่มีอยู่จะไม่เกิดข้อผิดพลาด แต่รูท Declarative Shadow Root จะถูกล้างและแสดงผล ซึ่งจะทำให้คอมโพเนนต์เก่าที่ไม่ได้สร้างสำหรับ Shadow DOM แบบมีการประกาศทำงานต่อไป เนื่องจากระบบจะเก็บรักษารูทแบบประกาศไว้จนกว่าจะมีการสร้างการแทนที่ที่จำเป็น

สำหรับองค์ประกอบที่กำหนดเองที่สร้างขึ้นใหม่ พร็อพเพอร์ตี้ ElementInternals.shadowRoot ใหม่จะระบุวิธีที่ชัดเจนในการรับการอ้างอิงไปยัง Declarative Shadow Root ที่มีอยู่ขององค์ประกอบ ทั้งแบบเปิดและปิด ซึ่งสามารถใช้เพื่อตรวจสอบและใช้ Declarative Shadow Root ใดก็ได้ในขณะที่ยังคงกลับไปใช้ attachShadow() ในกรณีที่ไม่ได้ระบุ

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

1 เงาต่อราก

รากเงาประกาศที่เชื่อมโยงกับองค์ประกอบระดับบนสุดเท่านั้น ซึ่งหมายความว่ารากของเงาจะอยู่ในตำแหน่งเดียวกัน กับองค์ประกอบที่เกี่ยวข้องเสมอ การตัดสินใจออกแบบนี้ช่วยให้มั่นใจได้ว่ารากเงาสามารถสตรีมได้เช่นเดียวกับส่วนที่เหลือของเอกสาร HTML และยังสะดวกสำหรับการเขียนและการสร้าง เนื่องจากการเพิ่มรูทเงาลงในองค์ประกอบไม่จำเป็นต้องรักษารีจิสตรีของรูทเงาที่มีอยู่

ข้อดีข้อเสียของการเชื่อมโยงรากเงากับองค์ประกอบระดับบนคือ องค์ประกอบหลายรายการในการเริ่มต้นจากรากแสงเงา <template> เดียวกันไม่ได้ อย่างไรก็ตาม กรณีเช่นนี้มักจะไม่สำคัญในกรณีส่วนใหญ่ที่ใช้ Declarative Shadow DOM เนื่องจากเนื้อหาของรูทเงาแต่ละรากแทบจะไม่ตรงกันเสียทีเดียว แม้ว่า HTML ที่แสดงโดยเซิร์ฟเวอร์มักจะมีโครงสร้างขององค์ประกอบที่ซ้ำกัน แต่เนื้อหามักจะแตกต่างกัน เช่น ข้อความหรือแอตทริบิวต์มีความแตกต่างออกไปเล็กน้อย เนื่องจากเนื้อหาของ Declarative Shadow Root เป็นแบบคงที่ทั้งหมด การอัปเกรดองค์ประกอบหลายรายการจาก Declarative Shadow Root เดียวจะทำงานก็ต่อเมื่อองค์ประกอบนั้นเหมือนกัน สุดท้าย ผลกระทบของรากเงาที่คล้ายกันซ้ำๆ ต่อขนาดการโอนเครือข่ายค่อนข้างน้อยเนื่องจากผลกระทบของการบีบอัด

ในอนาคตคุณอาจกลับไปย้อนดูรูทของเงาที่แชร์อีกครั้งได้ หาก DOM ได้รับการสนับสนุนสำหรับการสร้างเทมเพลตในตัว รากเงาแบบประกาศอาจถือเป็นเทมเพลตที่มีการสร้างอินสแตนซ์เพื่อสร้างรากเงาสำหรับองค์ประกอบที่ระบุ การออกแบบ Shadow DOM แบบประกาศในปัจจุบันช่วยให้ความเป็นไปได้นี้��ะเกิดขึ้นในอนาคตโดยการจำกัดการเชื่อมโยงรากของเงาไว้ที่องค์ประกอบเดียว

สตรีมมิงนั้นเจ๋งมาก

การเชื่อมโยงรากเงาประกาศ ระบบจะตรวจพบ Declarative Shadow Roots ระหว่างการแยกวิเคราะห์ HTML และแนบทันทีเมื่อพบแท็ก <template> ที่กำลังเปิด HTML ที่แยกวิเคราะห์ภายใน <template> จะได้รับการแยกวิเคราะห์ไปยังรากของเงาโดยตรงเพื่อให้สามารถ "สตรีม": แสดงผลขณะที่ได้รับ

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

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

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

โปรแกรมแยกวิเคราะห์เท่านั้น

Declarative Shadow DOM เป็นฟีเจอร์ของโปรแกรมแยกวิเคราะห์ HTML ซึ่งหมายความว่าระบบจะแยกวิเคราะห์และแนบกับแท็ก <template> ที่มีแอตทริบิวต์ shadowrootmode ในระหว่างการแยกวิเคราะห์ HTML เท่านั้นซึ่งเป็น Declarative Shadow Root กล่าวคือ รูทเงาเงาประกาศ ( Declarative Shadow Roots) อาจสร้างขึ้นระหว่างการแยกวิเคราะห์ HTML เริ่มต้น

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

การตั้งค่าแอตทริบิวต์ shadowrootmode ขององค์ประกอบ <template> จะไม่ดำเนินการใดๆ และเทมเพลตจะยังคงเป็นองค์ประกอบเทมเพลตธรรมดา

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

นอกจากนี้ คุณยังสร้าง Declarative Shadow Roots โดยใช้ API การแยกวิเคราะห์ส่วนย่อยไม่ได้ เช่น innerHTML หรือ insertAdjacentHTML() เพื่อหลีกเลี่ยงการพิจารณาด้านความปลอดภัยที่สำคัญบางประการ วิธีเดียวในการแยกวิเคราะห์ HTML โดยใช้ Declarative Shadow Roots คือการใช้ setHTMLUnsafe() หรือ 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>

การแสดงผลเซิร์ฟเวอร์อย่างมีสไตล์

สไตล์ชีต��บบแทรกในบรรทัดและภายนอกได้รับการสนับสน����อ��่��ง��������ร��์ภายใน Declarative Shadow Roots โดยใช้แท็ก <style> และ <link> มาตรฐาน:

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

รูปแบบที่ระบุด้วยวิธีนี้ยังได้รับการเพิ่มประสิทธิภาพสูงเช่นกัน หากมีสไตล์ชีตเดียวกันอยู่ใน Declarative Shadow Roots ���ลายรายการ ระบบจะโหลดและแยกวิเคราะห์เพียงครั้งเดียว เบราว์เซอร์จะใช้ CSSStyleSheet แบบสำรองเดี่ยวที่แชร์กับรูทเงาทั้งหมด ซึ่งช่วยขจัดส่วนเกินของหน่วยความจำที่ซ้ำกัน

สไตล์ชีตที่สร้างได้ไม่ได้รับการสนับสนุนใน Declarative Shadow DOM นั่นเป็นเพราะปัจจุบันยังไม่มีวิธีทำให้สไตล์ชีตที่สร้างได้เรียงตามลำดับใน HTML และไม่มีวิธีเรียกสไตล์ชีตเมื่อป้อนข้อมูล adoptedStyleSheets

หลีกเลี่ยงการใช้เนื้อหาที่ไม่จัดสไตล์แฟลช

ปัญหาหนึ่งที่อาจเกิดขึ้นในเบราว์เซอร์ที่ยังไม่รองรับ Declarative Shadow DOM คือการหลีกเลี่ยง "เนื้อหาที่ไม่กำหนดรูปแบบ" (FOUC) ซึ่งแสดงเนื้อหาดิบสำหรับองค์ประกอบที่กำหนดเองที่ยังไม่ได้อัปเกรด ก่อนที่จะใช้ Declarative Shadow DOM เทคนิคทั่วไปอย่างหนึ่งในการหลีกเลี่ยง FOUC ก็คือการใช้กฎรูปแบบ display:none กับองค์ประกอบที่กำหนดเองซึ่งยังไม่ได้โหลด เนื่องจากองค์ประกอบเหล่านี้ไม่ได้แนบรากของเงาและป้อนข้อมูลไว้ ด้วยวิธีนี้ เนื้อหาจะไม่แสดงจนกว่าจะ "พร้อม"

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

ด้วยการเปิดตัว Declarative Shadow DOM ทำให้สามารถแสดงผลหรือเขียนองค์ประกอบที่กำหนดเองเป็น HTML เพื่อให้เนื้อหาเงาอยู่ในที่และพร้อมใช้งานก่อนที่จะโหลดการใช้งานคอมโพเนนต์ฝั่งไคลเอ็นต์

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

ในกรณีนี้ กฎ "FOUC" ของ display:none จะป้องกันไม่ให้เนื้อหารูทของเงาประกาศ อย่างไรก็ตาม การนำกฎดังกล่าวออกจะทำให้เบราว์เซอร์ที่ไม่มีการสนับสนุน Shadow DOM แสดงเนื้อหาที่ไม่ถูกต้องหรือไม่ได้จัดรูปแบบจนกว่า polyfill เงาของ Declarative Shadow จะโหลดและแปลงเทมเพลตรากของเงาเป็นรากของเงาจริง

ซึ่งคุณแก้ไขได้ใน CSS โดยการปรับเปลี่ยนกฎรูปแบบ FOUC ในเบราว์เซอร์ที่รองรับ Declarative Shadow DOM ระบบจะแปลงองค์ประกอบ <template shadowrootmode> เป็นรูทเงาทันที โดยไม่เหลือองค์ประกอบ <template> ในแผนผัง DOM เบราว์เซอร์ที่ไม่รองรับ Declarative Shadow DOM จะเก็บรักษาองค์ประกอบ <template> ซึ่งเราสามารถใช้เพื่อป้องกัน FOUC ดังต่อไปนี้

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

กฎ "FOUC" ที่แก้ไขแล้วจะซ่อนรายการย่อยเมื่อติดตามองค์ประกอบ <template shadowrootmode> แทนที่จะซ่อนองค์ประกอบที่กำหนดเองซึ่งยังไม่ได้กำหนดไว้ เมื่อกำหนดองค์ประกอบที่กำหนดเองแล้ว กฎจะไม่จับคู่อีกต่อไป ระบบจะไม่สนใจกฎในเบราว์เซอร์ที่รองรับ Declarative Shadow DOM เนื่องจากระบบนำระดับย่อย <template shadowrootmode> ออกระหว่างการแยกวิเคราะห์ HTML

การตรวจหาฟีเจอร์และการสนับสนุนเบราว์เซอร์

Declarative Shadow DOM พร้อมใช้งานตั้งแต่ Chrome 90 และ Edge 91 แต่ใช้แอตทริบิวต์ที่ไม่ใช่มาตรฐานเวอร์ชันเก่าชื่อ shadowroot แทนแอตทริบิวต์ shadowrootmode มาตรฐาน แอตทริบิวต์ shadowrootmode และลักษณะการทำงานของสตรีมมิงที่ใหม่กว่ามีอยู่ใน Chrome 111 และ Edge 111

ในฐานะ API แพลตฟอร์มเว็บใหม่ Declarative Shadow DOM ยังไม่มีการสนับสนุนอย่างแพร่หลายในทุกเบราว์เซอร์ คุณตรวจหาการรองรับเบราว์เซอร์ได้โดยการตรวจหาการมีอยู่ของพร็อพเพอร์ตี้ shadowRootMode ในต้นแบบของ HTMLTemplateElement ดังนี้

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

ใยโพลีเอสเตอร์

การสร้าง Polyfill แบบง่ายสำหรับ Declarative Shadow DOM นั้นค่อนข้างตรงไปตรงมา เนื่องจาก Polyfill ไม่จำเป็นต้องจำลองความหมายของช่วงเวลาหรือลักษณะของโปรแกรมแยกวิเคราะห์เฉพาะที่เกี่ยวข้องกับการใช้งานเบราว์เซอร์ ในการ polyfill Declarative Shadow DOM เราจะสแกน DOM เพื่อค้นหาองค์ประกอบ <template shadowrootmode> ทั้งหมด จากนั้นแปลงให้เป็น Shadow Roots ที่แนบในองค์ประกอบระดับบนสุด ขั้นตอนนี้ทำได้เมื่อเอกสารพร้อมแล้ว หรือมีการทริกเกอร์โดยเหตุการณ์ที่เฉพาะเจาะจงมากขึ้น เช่น วงจรองค์ประกอบที่กำหนดเอง

(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);

อ่านเพิ่มเติม