Shadow DOM แบบประกาศ

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

การรองรับเบราว์เซอร์

  • Chrome: 111
  • Edge: 111
  • Firefox: 123
  • Safari: 16.4

แหล่งที่มา

Shadow DOM เป็นหนึ่งในมาตรฐาน Web Components 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 เดียวกันกับที่กําหนดองค์ประกอบที่กําหนดเองจะสร้างรูทเงาและตั้งค่าเนื้อหาด้วย อย่างไรก็ตาม เว็บแอปพลิเคชันจำนวนมากต้องแสดงผลเนื้อหาฝั่งเซิร์ฟเวอร์หรือเป็น HTML แบบคงที่ ณ เวลาที่สร้าง ซึ่งอาจเป็นส่วนสําคัญในการมอบประสบการณ์การใช้งานที่เหมาะสมแก่ผู้เข้าชมที่อาจไม่สามารถเรียกใช้ JavaScript ได้

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

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

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

วิธีสร้างรากเงาแบบประกาศ

รูทเงาแบบประกาศคือองค์ประกอบ <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 ในช่อง

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

ปริมาณน้ำที่ดื่ม

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

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

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

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 นอกจากนี้ ยังสะดวกสําหรับการเขียนและการสร้าง เนื่องจากการเพิ่ม Shadow Root ลงในองค์ประกอบไม่จําเป็นต้องดูแลรักษารีจิสทรีของ Shadow Root ที่มีอยู่

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

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

สตรีมมิงเจ๋ง

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

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

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

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

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

Shadow DOM แบบประกาศเป็นฟีเจอร์ของตัวแยกวิเคราะห์ HTML ซึ่งหมายความว่าระบบจะแยกวิเคราะห์และแนบรากเงาแบบประกาศสำหรับแท็ก <template> ที่มีแอตทริบิวต์ shadowrootmode เท่านั้นที่ปรากฏระหว่างการแยกวิเคราะห์ HTML กล่าวคือ รากเงาแบบประกาศสามารถสร้างได้ในระหว่างการแยกวิเคราะห์ 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

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

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

ระบบรองรับสไตล์ชีตในบรรทัดและภายนอกอย่างเต็มรูปแบบภายในรูทเงาแบบประกาศโดยใช้แท็ก <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>

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

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

วิธีหลีกเลี่ยงการแสดงเนื้อหาที่ไม่มีการจัดรูปแบบอย่างรวดเร็ว

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

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

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

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

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

แต่ปัญหานี้แก้ไขได้ใน 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

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

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 Root ที่แนบมากับองค์ประกอบหลัก กระบวนการนี้สามารถดำเนินการได้เมื่อเอกสารพร้อมแล้ว หรือทริกเกอร์โดยเหตุการณ์ที่เฉพาะเจาะจงมากขึ้น เช่น วงจรชีวิตขององค์ประกอบที่กำหนดเอง

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

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