เข้าถึง DOM อย่างปลอดภัยด้วย Angular SSR

เจอรัลด์ โมนาโก
Gerald Monaco

ในปีที่ผ่านมา Angular ได้รับฟีเจอร์ใหม่ๆ มากมาย เช่น ปริมาณน้ำที่ดื่มและการแสดงผลล่าช้า เพื่อช่วยให้นักพัฒนาแอปปรับปรุง Core Web Vitals และมอบประสบการณ์ที่ยอดเยี่ยมแก่ผู้ใช้ปลายทาง การวิจัยฟีเจอร์เพิ่มเติมที่เกี่ยวข้องกับการแสดงผลฝั่งเซิร์ฟเวอร์ซึ่งต่อยอดมาจากฟังก์ชันการทำงานนี้ก็กำลังอยู่ระหว่างการวิจัยด้วย เช่น การสตรีมและการดื่มน้ำบางส่วน

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

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

หลีกเลี่ยงการจัดการ DOM ด้วยตนเอง

แน่นอนว่าวิธีที่ดีที่สุดในการหลีกเลี่ยงปัญหาการจัดการ DOM ด้วยตนเองคือการหลีกเลี่ยงปัญหานี้โดยสิ้นเชิงหากทำได้ Angular มี API และรูปแบบในตัวที่สามารถจัดการส่วนส่วนใหญ่ของ DOM ได้ คุณควรใช้ API เหล่านี้แทนการเข้าถึง DOM โดยตรง

เปลี่ยนแปลงองค์ประกอบ DOM ของคอมโพเนนต์เอง

เมื่อเขียนคอมโพเนนต์หรือคำสั่ง คุณอาจต้องแก้ไของค์ประกอบโฮสต์ (กล่าวคือ องค์ประกอบ DOM ที่ตรงกับ selector ของคอมโพเนนต์หรือคำสั่ง) เพื่อเพิ่มคลาส สไตล์ หรือแอตทริบิวต์ แทนที่จะกำหนดเป้าหมายหรือแนะนำองค์ประกอบ Wrapper คุณอาจจะอยากเข้าถึง ElementRef เพื่อเปลี่ยนแปลงองค์ประกอบ DOM ที่สำคัญ คุณควรใช้การเชื่อมโยงโฮสต์เพื่อเชื่อมโยงค่ากับนิพจน์อย่างประกาศแทน ดังนี้

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

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

ในบางกรณี จะต้องคำนวณคีย์แบบไดนามิก นอกจากนี้คุณยังสามารถเชื่อมโยงกับสัญญาณหรือฟังก์ชันที่ส่งคืนชุดหรือแมปของค่าได้ด้วย ดังนี้

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

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

เปลี่ยนแปลงองค์ประกอบ DOM ภายนอกเทมเพลต

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

แต่คอมโพเนนต์อื่นๆ ควรถือว่าคอมโพเนนต์อื่นๆ ทั้งหมดเป็นกล่องดำ ใช้เวลาพิจารณาว่าเมื่อใดและที่ที่คอมโพเนนต์อื่นๆ (แม้ภายในแอปพลิเคชันหรือไลบรารีเดียวกัน) อาจต้องโต้ตอบหรือปรับแต่งลักษณะหรือรูปลักษณ์ของคอมโพเนนต์ จากนั้นจึงแสดงวิธีดำเนินการที่ปลอดภัยและมีการบันทึกไว้ ใช้ฟีเจอร์อย่างเช่น การแทรกทรัพยากร Dependency ตามลำดับชั้น เพื่อสร้าง API ให้กับแผนผังย่อยเมื่อพร็อพเพอร์ตี้ @Input และ @Output แบบง่ายนั้นไม่เพียงพอ

ก่อนหน้านี้ การใช้งานฟีเจอร์ต่างๆ เช่น กล่องโต้ตอบโมดัลหรือเคล็ดลับเครื่องมือด้วยการเพิ่มองค์ประกอบที่ส่วนท้ายของ <body> หรือองค์ประกอบโฮสต์อื่นๆ แล้วย้ายหรือฉายภาพเนื้อหาไปที่นั่น อย่างไรก็ตาม ทุกวันนี้คุณอาจแสดงผลองค์ประกอบ <dialog> แบบง่ายๆ ในเทมเพลตแทนได้

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

เลื่อนการจัดการ DOM ด้วยตนเอง

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

เรียกใช้ JavaScript เฉพาะเบราว์เซอร์

ในบางกรณีคุณจะมีไลบรารีหรือ API ที่ใช้งานได้ในเบราว์เซอร์เท่านั้น (เช่น ไลบรารีแผนภูมิ การใช้งาน IntersectionObserver บางอย่าง ฯลฯ) แทนที่จะต้องตรวจสอบแบบมีเงื่อนไขว่าคุณกำลังทำงานในเบราว์เซอร์อยู่หรือตัดการทำงานบนเซิร์ฟเวอร์ คุณใช้ afterNextRender เพียงอย่างเดียว:

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

ใช้เลย์เอาต์ที่กำหนดเอง

บางครั้งคุณอาจต้องอ่านหรือเขียนไปยัง DOM เพื่อทำเลย์เอาต์ที่เบราว์เซอร์เป้าหมายยังไม่รองรับ เช่น การกำหนดตำแหน่งเคล็ดลับเครื่องมือ afterRender คือตัวเลือกที่ยอดเยี่ยมสำหรับขั้นตอนนี้ เนื่องจากคุณมั่นใจได้ว่า DOM อยู่ในสถานะที่สม่ำเสมอ afterRender และ afterNextRender ยอมรับค่า phase ที่เป็น EarlyRead, Read หรือ Write การอ่านเลย์เอาต์ของ DOM หลังจากที่เขียนจะเป็นการบังคับให้เบราว์เซอร์คำนวณเลย์เอาต์แบบพร้อมกันอีกครั้ง ซึ่งอาจส่งผลกระทบต่อประสิทธิภาพอย่างมาก (ดูการกดเลย์เอาต์) ดังนั้นสิ่งสำคัญก็คือต้องแบ่งตรรกะของคุณออกเป็นช่วงๆ เป็นระยะที่ถูกต้อง

ตัวอย่างเช่น คอมโพเนนต์เคล็ดลับเครื่องมือที่ต้องการแสดงเคล็ดลับเครื่องมือโดยสัมพัทธ์กับอีกองค์ประกอบหนึ่งในหน้านั้นน่าจะใช้ 2 เฟส ระบบจะใช้เฟส EarlyRead เพื่อหาขนาดและตำแหน่งขององค์ประกอบก่อน ดังนี้

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

จากนั้นเฟส Write จะใช้ค่าที่อ่านแล้วก่อนหน้านี้เพื่อเปลี่ยนตำแหน่งเคล็ดลับเครื่องมือ

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

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

บทสรุป

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