Développement Web

Angular SSR مع hydration تزايدي في الإنتاج

10 دقائق للقراءة

تقديم HTML ساكن من الخادم ثمّ إيقاظ المكوّنات من جانب العميل: هذا هو رهان الرسم من الخادم في Angular. الوعد جذّاب، لكنّ التنفيذ يُخيّب الآمال أحياناً — الفقرات الأولى تظهر بسرعة، ثمّ يصارع المتصفّح ليصبح تفاعليّاً. منذ Angular 18، replay الأحداث والـ hydration التزايديّ يسدّان هذه الفجوة في التجربة. هذا الدرس يُثبّت SSR على مشروع موجود، يُهيّئ الـ hydration الكامل، يضيف الـ replay، ثمّ ينتقل إلى hydration تزايديّ موجَّه بمُحرّكات hydrate on.

المتطلّبات

  • Node.js 20 LTS كحدّ أدنى (@angular/ssr الحديث يحتاجه لـ APIs Express الحديثة).
  • Angular CLI 18 أو أحدث (Angular 20+ مُوصى به للـ hydration التزايديّ المستقرّ).
  • مشروع Angular موجود أو مُنشَأ بـ ng new mon-app --ssr.
  • فهم أساسيّ للمكوّنات standalone و signals.
  • حوالي 50 دقيقة للمتابعة الكاملة، شاملاً استضافة للاختبار.

الخطوة 1 — إضافة SSR لمشروع موجود

إذا كان مشروعك مُنشَأ دون SSR، الأمر المخصّص يُجنّبك أيّ معالجة يدويّة. الـ schematic @angular/ssr يُضيف تبعيّات Express اللازمة، يُولّد نقطة الدخول للخادم، يُعدّل angular.json بهدف server، ويُسجّل bootstrap الخادم في main.server.ts. هذا الأمر الوحيد الذي عليك معرفته للانطلاق.

ng add @angular/ssr

عند انتهاء الأمر، افتح المشروع ولاحظ الملفّات الجديدة: src/main.server.ts (bootstrap من جانب Node)، src/server.ts (خادم Express يُقدّم التطبيق)، و src/app/app.config.server.ts (providers خاصّة بالخادم). للاختبار في وضع SSR حقيقيّ، شغّل ng build ثمّ npm run serve:ssr:<mon-projet> — الـ script مُولَّد تلقائيّاً ويُشغّل خادم Express المُجمَّع. افتح http://localhost:4000 وانظر إلى كود المصدر: تشاهد الآن HTML كاملاً، لا هيكلاً فارغاً. لاحظ أنّ ng serve وحده يبقى dev server للعميل فقط — يُسرّع التكرار لكنّه لا يُعيد SSR عند كلّ إعادة تحميل. هذه اللبنة الأولى: دون hydration، التطبيق يبقى ساكناً.

الخطوة 2 — تفعيل الـ hydration الكامل

الـ hydration هو الآليّة التي تُعيد ربط الـ DOM المرسوم من الخادم بمكوّنات Angular من جانب العميل، دون إعادة بناء الشجرة. بدونها، المتصفّح يرمي HTML الخادم ويُعيد بناء الصفحة، مُحدِثاً الوميض الأبيض الشهير ومُلغياً كلّ فائدة SSR. التفعيل يمرّ عبر provider واحد يُضاف إلى appConfig.

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),
  ],
};

بمجرّد وضع هذا الـ provider، أعد تحميل الصفحة وافحص الـ DOM في DevTools. تلاحظ سمات جديدة ngh="0" على العُقَد — Angular يستعملها كبصمة للعثور على كلّ مكوّن أثناء الـ hydration. الـ HTML يبقى ذلك المُرسَل من الخادم، لكنّ الأزرار تستجيب الآن. للتأكيد، انقر على أيّ عنصر تفاعليّ بأسرع ما يمكن بعد ظهور الـ markup: يجب أن يستجيب.

الخطوة 3 — تفعيل replay الأحداث

حتى مع الـ hydration الكامل، يوجد فاصل بين لحظة عرض HTML ولحظة تحوّل المكوّنات إلى تفاعليّة. مستخدم متعجّل قد ينقر في هذه النافذة، والحدث يضيع. الـ event replay، متاح منذ Angular 18 ومستقرّ منذ Angular 19، يحلّ هذه المشكلة بحفظ التفاعلات وإعادة تشغيلها بمجرّد انتهاء الـ hydration.

import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(withEventReplay()),
  ],
};

الآليّة دقيقة لكنّها شفّافة. Angular يُسجّل listeners على مستوى document منذ HTML المُقدَّم (عبر script inline صغير)، يلتقط كلّ نقرة وtsoumission وحدث لوحة مفاتيح، ثمّ يُعيد تشغيلها على المكوّنات بعد الـ hydration. الاختبار العمليّ: أبطئ تنفيذ JavaScript في DevTools (Performance → CPU 4x slowdown)، أعد التحميل، وحاول النقر فوراً على زرّ. بدون withEventReplay، النقرة تضيع. معها، تُكرَّم فور الإمكان.

الخطوة 4 — فهم أنماط الرسم

Angular 19 أدخل مفهوم render mode لكلّ route: كلّ URL يمكن تقديمه بـ pre-render ساكن، أو SSR صرف، أو نمط عميل فقط. هذه الدقّة تتجنّب دفع كلفة SSR على routes لا تحتاجه (لوحة إدارة داخليّة مثلاً) أو توليد HTML مسبقاً لـ routes تحريريّة.

// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '',         renderMode: RenderMode.Prerender },
  { path: 'blog/:id', renderMode: RenderMode.Server },
  { path: 'compte',   renderMode: RenderMode.Client },
];

ثلاثة أنماط، ثلاث حالات استعمال. Prerender يُولّد HTML ساكناً لحظة البناء: مثاليّ للصفحات العامّة بمحتوى مستقرّ (الرئيسيّة، المقالات). Server يرسم عند كلّ طلب: مفيد حين يعتمد المحتوى على سياق المستخدم (جلسة، cookies، تحديد جغرافيّ). Client يُعطّل SSR لهذا الـ route: مهمّ للمناطق المُصادَق عليها حيث لا فائدة من SEO. هذا الإعداد يُوصَل في app.config.server.ts عبر provideServerRendering(withRoutes(serverRoutes)).

الخطوة 5 — تفعيل الـ hydration التزايديّ

الـ hydration الكامل له حدّ: شجرة المكوّنات كاملة تُمرَّر للـ hydration دفعة واحدة فور التحميل. لصفحة غنيّة بعشرة مكوّنات تفاعليّة، هذا يمثّل احتمالاً عدّة مئات من الكيلوبايتات من JavaScript للتفسير والتنفيذ قبل أوّل تفاعل. الـ hydration التزايديّ، المستقرّ في Angular 21، يُغيّر المعادلة: كلّ قسم يبقى « ساكناً » حتى يُوقظه مُحرّك دقيق.

import {
  provideClientHydration,
  withEventReplay,
  withIncrementalHydration,
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(
      withEventReplay(),
      withIncrementalHydration(),
    ),
  ],
};

تفعيل الـ provider وحده لا يكفي: يجب أيضاً وَسم المناطق المؤجَّلة في القالب. هذا يتمّ عبر النحو الجديد @defer (hydrate on …)، الذي يجمع التأجيل والـ hydration في block واحد. شغّل البناء، افتح DevTools ولاحظ الشبكة: فقط المناطق المفيدة فوراً تُطلق تنزيل chunk JavaScript، الأخريات تبقى سلبيّة.

الخطوة 6 — وَسم المناطق بـ hydrate on

ستّة مُحرّكات متاحة لـ hydrate on: idle، viewport، interaction، hover، immediate، و timer(duration). الاختيار يعتمد على حرجيّة المكوّن والاستعمال المتوقّع. widget دردشة دعم نائم في أسفل الصفحة يلائم hydrate on viewport؛ carrousel صور في أعلى الصفحة يستحقّ hydrate on idle كي لا يحجب الـ thread الرئيسيّ عند الإقلاع.

<article>
  <h1>{{ post().title }}</h1>
  <p>{{ post().intro }}</p>

  @defer (hydrate on viewport) {
    <app-commentaires [postId]="post().id" />
  } @placeholder {
    <div class="skeleton-comments"></div>
  }

  @defer (hydrate on idle) {
    <app-recommandations />
  }

  @defer (hydrate on interaction) {
    <app-partage />
  } @placeholder {
    <button>مشاركة</button>
  }
</article>

الخادم يُنتج HTML كاملاً: التعليقات، التوصيات، وزرّ المشاركة كلّها مرسومة ساكنة. من جانب العميل، لا شيء يُمرَّر للـ hydration طالما المُحرّك لم يتفعّل. مكوّن المشاركة ينتظر نقرة ليصبح متجاوباً، ممّا يوفّر عشرات الكيلوبايتات من JS إن لم يلمس المستخدم الزرّ أبداً. قِس الأثر بـ Lighthouse: على مقال بعشرة مكوّنات، الـ Total Blocking Time قد ينخفض من 400 ms إلى أقلّ من 100 ms.

الخطوة 7 — إدارة الحالات غير القابلة للـ hydration (hydrate never)

بعض الأقسام ليست مُعدّة لتكون تفاعليّة: قسم ترويجيّ ساكن، توقيع في نهاية المقال، إشعار حقوق نشر. وَسمها بـ hydrate never يُشير لـ Angular بألّا يُنزّل JavaScript الخاصّ بها على العميل أبداً. الـ HTML يبقى، الـ SEO يستفيد، والـ bundle يُختصَر بالقدر نفسه.

@defer (hydrate never) {
  <app-footer-legal />
}

انتبه إلى الفخّ: مكوّن مُوسَم hydrate never لن يصبح متجاوباً أبداً، حتى لو احتوى قالبه bindings. تحقّق قبل النشر أنّ المكوّن لا يتوقّع مُدخَلاً ديناميكياً من الأب. هذه الحالة النموذجيّة للمكوّنات الساكنة كلّياً: قائمة تنقّل مرسومة مسبقاً بروابط صلبة، لوحة مساعدة تعرض نصاً ثابتاً.

الخطوة 8 — النشر خلف Node.js

SSR في Angular يُنتج خادم Express جاهز للاستعمال. للانتقال إلى الإنتاج، عدّة خيارات صالحة: VPS كلاسيكي مع systemd، حاوية Docker خلف reverse proxy Nginx، أو خدمة serverless مثل Cloud Run أو Vercel. إليك الإعداد الأدنى لنشر بـ Docker، يعمل جيّداً على VPS وأيضاً ضمن orchestration.

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
EXPOSE 4000
CMD ["node", "dist/mon-app/server/server.mjs"]

الـ image الناتجة تزن حوالي 180 MB على Alpine، يبقى مقبولاً لـ VPS متواضع. ميناء الاستماع الافتراضيّ هو 4000؛ ضع Nginx أمامه لإدارة SSL والضغط Brotli. للاختبار محليّاً، شغّل docker build -t mon-app . && docker run -p 4000:4000 mon-app، ثمّ curl -I http://localhost:4000 يجب أن يُرجع استجابة 200 مع HTML مُمَرَّر للـ hydration.

الخطوة 9 — قياس الأثر على Web Vitals

بمجرّد وضع SSR و hydration تزايديّ، قِس. بدون أرقام قبل/بعد، لن تعرف إن أثمر العمل. الثلاثيّ الواجب مراقبته: Largest Contentful Paint (LCP)، Interaction to Next Paint (INP)، و Cumulative Layout Shift (CLS). Lighthouse يحسبها، لكنّ الأداة الأكثر تمثيلاً تبقى تقرير CrUX (Chrome User Experience Report) الذي يُجمّع بيانات المستخدمين الحقيقيّين.

# تدقيق Lighthouse موبايل مع throttling
npx lighthouse https://votre-site.example \
  --preset=mobile \
  --throttling-method=devtools \
  --only-categories=performance \
  --output=json --output-path=./perf.json

# استخراج الأرقام المفتاحيّة
cat perf.json | jq '.audits["largest-contentful-paint"].displayValue, .audits.interactive.displayValue'

موقع تحريريّ مُهيَّأ جيّداً يبلغ نموذجياً LCP بـ 1.8 s على 4G و INP أقلّ من 200 ms. إذا تجاوزت أرقامك هذه القيم بكثير، افحص اتّجاهين: هل يستغرق الخادم وقتاً طويلاً للاستجابة (Time To First Byte) أم أنّ هناك JavaScript حرج كثير غير مُؤجَّل. الأمر ng build --stats-json مُقترِناً بـ source-map-explorer يكشف ما يبقى في الـ bundle الابتدائيّ.

الخطوة 10 — التحقّق من SEO والـ meta-data

المنفعة الرئيسيّة من SSR هي SEO. ولا يزال يجب أن تتلقّى محرّكات البحث HTML صحيحاً، بعنوان و meta-description ووسوم Open Graph وبيانات منظَّمة. الخدمة Meta في Angular تسمح بحقن هذه المعلومات ديناميكياً، والرسم من الخادم يُضمّنها في HTML الابتدائيّ.

import { Component, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Component({
  selector: 'app-article',
  template: `<h1>{{ article().title }}</h1>`,
})
export class ArticleComponent {
  private title = inject(Title);
  private meta = inject(Meta);

  ngOnInit() {
    const a = this.article();
    this.title.setTitle(a.title);
    this.meta.updateTag({ name: 'description', content: a.excerpt });
    this.meta.updateTag({ property: 'og:title', content: a.title });
  }
}

للتأكّد من أنّ هذه الوسوم تصل فعلاً في HTML المُقدَّم، استعمل curl مع user-agent لـ bot: curl -A "Googlebot" https://votre-site.example/article/42 | grep -E "og:|description". يجب أن ترى وسومك <meta> في الـ markup الخام. إذا ظهرت فقط في الـ DOM المرسوم (مرئيّة عبر DevTools لكن غائبة من source view)، فهذا يعني أنّ الرسم من الخادم لا يُطلق hooksك — تحقّق من أنّ الـ meta مُعرَّفة قبل نهاية مرحلة الخادم، مثالياً في resolver أو effect متزامن.

أخطاء متكرّرة

الخطأ السبب الحلّ
وميض أبيض عند التحميل hydration غائب، DOM الخادم مرميّ أضف provideClientHydration()
NG0500 hydration mismatch HTML الخادم مختلف عن HTML العميل (تاريخ، random، فُلك) استعمل afterNextRender() للقيم غير الحتميّة
cookies غير مقروءة من جانب الخادم API متصفّح مُستدعى أثناء الرسم من الخادم تحقّق من isPlatformBrowser(platformId) قبل الوصول
Bundle ابتدائيّ ضخم مكوّنات مستوردة ثابتاً حوّل إلى @defer (hydrate on …)
Meta غائبة من source view setter منفَّذ متأخراً جدّاً من جانب الخادم انقل إلى resolver route أو effect متزامن
خطأ ERR_HTTP_HEADERS_SENT كود Express يستجيب عدّة مرّات تحقّق من server.ts بحثاً عن middlewares مُكرَّرة

التكيّف مع الاتّصالات المحدودة

SSR مع hydration تزايديّ يأخذ كلّ معناه على شبكات موبايل قليلة الأداء. الـ HTML يظهر في بضع مئات من الميلّي ثوان حتى على 3G، حيث SPA كلاسيكيّ يفرض ثانيتين إلى ثلاث من JavaScript للتفسير. لمدوّنة تُقرأ غالباً من الموبايل، SSR ليس رفاهيّة، بل عامل حاسم للتحويل. اجمع ضغط Brotli من جانب الخادم (ربح 20 إلى 30% على HTML)، صور WebP أو AVIF مع loading="lazy"، و hydration تزايديّ على المناطق غير المرئيّة: مقال تحريريّ من 2000 كلمة يمكنه بلوغ LCP تحت ثانيتين حتى على اتّصال 3G محاكى.

أسئلة شائعة

SSR أم pre-render ساكن: ماذا أختار؟
إذا كان محتواك يتغيّر نادراً (مدوّنة، توثيق)، فضّل الـ pre-render بـ RenderMode.Prerender: الـ HTML يُولَّد عند البناء، يُستضاف على CDN، ويُكلّف جزءاً يسيراً من كلفة VPS Node. إذا كان المحتوى يعتمد على المستخدم أو يتغيّر عند كلّ طلب، احتفظ بـ RenderMode.Server.

هل يجب تفعيل withEventReplay() دائماً؟
نعم، في 99% من الحالات. كلفة الـ bundle ضئيلة (بضعة كيلوبايتات) والمكسب في UX كبير على اتّصالات بطيئة. السبب الوحيد للاستغناء عنها سيكون bug عدم توافق مع مكتبة طرف ثالث، وستعرفه بسرعة عبر اختبارات E2E.

أيّ مستضيف لـ SSR Angular في الإنتاج؟
كلّ مستضيف يدعم Node.js 20+ يصلح: VPS Linux (Hetzner، OVH، Hostinger)، حاويات (Fly.io، Railway، Render)، serverless (Cloud Run، Vercel). لحركة مرور معتدلة، VPS بـ 4-6 يورو/شهر يكفي ويُبقي تحكّماً كاملاً على خادم Express.

هل يشتغل الـ hydration التزايديّ بدون @defer؟
لا. الآليّة تعتمد كلّياً على blocks @defer مع مُحرّكات hydrate on …. بدون هذه العلامات في القالب، الـ hydration يبقى كاملاً (دفعة واحدة)، حتى مع withIncrementalHydration() مُفعَّلاً.

كيف أُنقّح خطأ NG0500 hydration mismatch؟
هذا الخطأ يُشير إلى أنّ HTML المرسوم من الخادم يختلف عن HTML المتوقَّع من العميل. الأسباب الشائعة: Date.now() في قالب، قيمة عشوائيّة، قراءة لـ window غير محميّة. رسالة الـ console تُشير إلى عُقدة الـ DOM المعنيّة — افحص المكوّن المقابل واستعمل afterNextRender() للقيم غير الحتميّة. كحلّ أخير، أضف السمة ngSkipHydration على المكوّن لتعطيل الـ hydration لهذه الشجرة، ريثما تُصلح الكود.

للاستزادة

المراجع

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité