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

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