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

Gerald Monaco
Gerald Monaco

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

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

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

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

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

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

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

แต่ควรพิจารณาคอมโพเนนต์อื่นทั้งหมดว่าเป็นกล่องดำแทน ใช้เวลาพิจารณาว่าคอมโพเนนต์อื่นๆ (แม้กระทั่งภายในแอปพลิเคชันหรือไลบรารีเดียวกัน) อาจต้องโต้ตอบหรือปรับแต่งลักษณะการทำงานหรือลักษณะที่ปรากฏของคอมโพเนนต์ของคุณเมื่อใดและที่ไหน จากนั้นแสดงวิธีทํางานที่ปลอดภัยและมีเอกสารประกอบไว้ให้ใช้งาน ใช้ฟีเจอร์อย่างการฉีดข้อมูลตามลําดับชั้นเพื่อให้ 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 โดยตรงและเข้าถึง 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 ในด้านต่างๆ ของ Angular ได้รับการปรับปรุงใหม่ที่น่าสนใจมากมาย โดยมีเป้าหมายที่จะช่วยให้ผู้ใช้ได้รับประสบการณ์การใช้งานที่ยอดเยี่ยมได้ง่ายขึ้น เราหวังว่าเคล็ดลับก่อนหน้านี้จะเป็นประโยชน์ในการช่วยให้คุณใช้ประโยชน์จากแอปพลิเคชันและไลบรารีได้อย่างเต็มที่