دسترسی ایمن به DOM با Angular SSR

جرالد موناکو
Gerald Monaco

در طول سال گذشته، Angular بسیاری از ویژگی‌های جدید مانند هیدراتاسیون و نمایش‌های معوق را به دست آورده است تا به توسعه‌دهندگان کمک کند تا Core Web Vitals خود را بهبود بخشند و تجربه‌ای عالی را برای کاربران نهایی خود تضمین کنند. تحقیقات در مورد ویژگی‌های اضافی مرتبط با رندر سمت سرور که بر اساس این عملکرد ساخته شده‌اند، مانند پخش جریانی و هیدراتاسیون جزئی نیز در حال انجام است.

متأسفانه، یک الگو وجود دارد که ممکن است برنامه یا کتابخانه شما را از استفاده کامل از همه این ویژگی‌های جدید و آینده باز دارد: دستکاری دستی ساختار DOM زیربنایی. Angular مستلزم آن است که ساختار DOM از زمانی که یک جزء توسط سرور سریال می‌شود، تا زمانی که در مرورگر هیدراته شود، ثابت بماند. استفاده از APIهای ElementRef ، Renderer2 ، یا DOM برای اضافه کردن، جابجایی یا حذف دستی گره‌ها از DOM قبل از هیدراتاسیون می‌تواند ناسازگاری‌هایی را ایجاد کند که از کارکرد این ویژگی‌ها جلوگیری می‌کند.

با این حال، همه دستکاری‌ها و دسترسی‌های دستی DOM مشکل‌ساز نیستند و گاهی اوقات ضروری است. کلید استفاده ایمن از DOM این است که تا حد امکان نیاز خود را به آن به حداقل برسانید و سپس استفاده از آن را تا حد امکان به تعویق بیندازید. دستورالعمل‌های زیر توضیح می‌دهند که چگونه می‌توانید این کار را انجام دهید و اجزای Angular واقعا جهانی و مقاوم در برابر آینده بسازید که می‌توانند از تمام ویژگی‌های جدید و آینده Angular استفاده کامل کنند.

از دستکاری دستی DOM خودداری کنید

بهترین راه برای جلوگیری از مشکلاتی که دستکاری DOM به صورت دستی ایجاد می کند این است که تا جایی که ممکن است به طور کامل از آن اجتناب کنید. Angular دارای APIها و الگوهای داخلی است که می تواند بیشتر جنبه های DOM را دستکاری کند: شما باید ترجیح دهید به جای دسترسی مستقیم به DOM از آنها استفاده کنید.

عنصر DOM خود یک جزء را تغییر دهید

هنگام نوشتن یک کامپوننت یا دستورالعمل، ممکن است لازم باشد عنصر میزبان (یعنی عنصر DOM که با انتخابگر مؤلفه یا دستورالعمل مطابقت دارد) را تغییر دهید تا مثلاً یک کلاس، سبک یا ویژگی را به جای هدف قرار دادن یا معرفی یک ویژگی اضافه کنید. عنصر لفاف وسوسه انگیز است که فقط به 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 برای دسترسی به عناصری که معمولاً در دسترس نیستند، مانند مواردی که متعلق به سایر مؤلفه های والد یا فرزند هستند، استفاده کنید. با این حال، این مستعد خطا است، کپسولاسیون را نقض می‌کند و تغییر یا ارتقای آن اجزا را در آینده دشوار می‌کند.

در عوض، جزء شما باید هر جزء دیگر را یک جعبه سیاه در نظر بگیرد. زمانی را به این فکر کنید که چه زمانی و کجا سایر اجزا (حتی در همان برنامه یا کتابخانه) ممکن است نیاز به تعامل یا سفارشی کردن رفتار یا ظاهر مؤلفه شما داشته باشند، و سپس روشی مطمئن و مستند برای انجام این کار را در معرض دید قرار دهید. وقتی ویژگی‌های ساده @Input و @Output کافی نیستند، از ویژگی‌هایی مانند تزریق وابستگی سلسله مراتبی برای در دسترس قرار دادن API برای زیردرخت استفاده کنید.

از لحاظ تاریخی، پیاده‌سازی ویژگی‌هایی مانند دیالوگ‌های مدال یا راهنمای ابزار با افزودن یک عنصر به انتهای <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 متعهد کرد.

جاوا اسکریپت فقط مرورگر را اجرا کنید

در برخی موارد شما یک کتابخانه یا 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 پس از نوشتن، مرورگر را مجبور می‌کند تا به طور همزمان طرح‌بندی را مجدداً محاسبه کند، که می‌تواند به طور جدی بر عملکرد تأثیر بگذارد (نگاه کنید به: layout thrashing ). بنابراین مهم است که منطق خود را با دقت به مراحل صحیح تقسیم کنید.

برای مثال، یک مؤلفه راهنمای ابزار که می‌خواهد یک راهنمای ابزار را نسبت به عنصر دیگری در صفحه نمایش دهد، احتمالاً از دو فاز استفاده می‌کند. مرحله 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 در افق وجود دارد که هدف آن آسان‌تر کردن ارائه یک تجربه عالی برای کاربرانتان است. ما امیدواریم که نکات قبلی برای کمک به شما در استفاده کامل از آنها در برنامه ها و کتابخانه های خود مفید واقع شود!