كيف تجعل موقعك الساكن ديناميكياً: استراتيجيات متقدمة للمطورين

دقائق القراءة: 12

غالباً ما يُنظر إلى المواقع الساكنة (Static Sites) على أنها الخيار الأمثل للمشاريع الصغيرة والمستقرة التي لا تتطلب ميزات متقدمة أو تفاعلاً مباشراً مع المستخدمين. ولكن، هل يمكننا حقاً الاستفادة من مزايا الأداء الفائقة التي توفرها هذه المواقع، وفي الوقت نفسه جعلها ديناميكية، شخصية، وتفاعلية؟

عندما أذكر مصطلح “الموقع الساكن” للمطورين الذين لم يتعاملوا بعد مع مولدات المواقع الساكنة (Static Site Generators)، غالباً ما يظهر عليهم الامتعاض. يبدو أن هذا المصطلح الشائع يعمل ضدي، فهو لا يصف بدقة ما تحصل عليه حقاً إذا قررت استخدام مولد موقع ساكن (مثل Gatsby أو Gridsome). لذلك، أشرح لهم كيف يعمل كل شيء، بما في ذلك عمليات إعادة البناء التلقائية عند تغيير المحتوى أو التنفيذ.

دائماً ما يكون لديهم نفس التعليق: “إنه أمر رائع، ولكن إنشاء الموقع مسبقاً لن ينجح في السيناريوهات الديناميكية مثل التجارة الإلكترونية أو التخصيص. وبالتالي فهو جيد فقط للمشاريع الصغيرة.” وهذا ببساطة ليس صحيحاً. سأوضح لك لماذا.

مخطط زمني يوضح كيفية تحويل الموقع الساكن إلى ديناميكي

طريقتان رئيسيتان لجعل موقعك الساكن ديناميكياً

هناك طريقتان أساسيتان لتحويل موقعك الساكن إلى موقع ديناميكي:

  • خلال عملية الإنشاء المسبق للموقع (pre-rendering).
  • من خلال تفاعلات المستخدمين على الموقع بعد النشر.

لشرح ذلك بشكل أفضل، سأستخدم مثالاً واقعياً. في الآونة الأخيرة، واجهت مهمة إنشاء موقع ويب لحفل زفاف. أعلم أن هناك الآلاف من القوالب البسيطة لذلك، ولكن لكوني أعمل في مجال تكنولوجيا المعلومات، يتوقع الناس ضمنياً أن يكون الموقع متطوراً وحديثاً. لذلك، قررت أن أقبل التحدي.

لجزء التنفيذ، قررت استخدام مولد المواقع الساكنة Gridsome نظراً لتفضيل Vue.js على React. سأستخدم نظام إدارة محتوى لارأسي (Headless CMS) لتخزين المحتوى، ووظيفتين بلا خادم (Serverless Functions) للتعامل مع تفاعل المستخدمين.

المحتوى الديناميكي أثناء الإنشاء المسبق للموقع

أقوم بجمع كل المعلومات التي أعرفها قبل بناء الموقع. أعرف من أريد دعوته، ومتى سيقام الحدث، ومن سأتزوج. تماماً كما تعرف المنتجات التي تريد بيعها أو الخدمات التي تريد تقديمها على موقعك. مع أخذ ذلك في الاعتبار، قمت بإنشاء مجموعة من نماذج المحتوى (Content Models) لموقعي:

  • Invitee (المدعو)
  • Accommodation (الإقامة)
  • Section (قسم)
  • Timeline item (عنصر الجدول الزمني)

وهكذا تبدو هذه النماذج في تصميم الموقع الفعلي:

تصميم موقع الزفاف يوضح الصفحات المخصصة للمدعوين

لأنني أعرف جميع المدعوين، استخدمت المحتوى من نظام إدارة المحتوى اللارأسي لإنشاء صفحة منفصلة لكل مدعو تلقائياً (تحقق من تسمية Custom URL في الصورة). ونتيجة لذلك، في وقت البناء، تعرف المكونات سياق المدعو. تخيل إمكانيات التخصيص – يمكنني حتى إرجاع خطأ 404 (404) لبعض أقاربي الأقل تفضيلاً! لقد استخدمت هذه الميزة بالفعل لعرض تحيات شخصية وعناصر جدول زمني ذات صلة فقط.

تحية شخصية مع عناصر جدول زمني ذات صلة في صفحة مدعو

إذا كنت تقوم ببناء موقع للتجارة الإلكترونية، يمكنك تنفيذ صفحة منتج تعرض قائمة بالمنتجات المشابهة. وربما تربط أيضاً بالخدمات ذات الصلة بالمنتج التي تقدمها شركتك. أنت تعرف جميع التفاصيل الضرورية في وقت البناء. هذا يفتح آفاقاً واسعة للتخصيص دون التضحية بأداء الموقع الساكن.

نمذجة المحتوى: المفتاح للمواقع المبنية مسبقاً

لقد حددت ثلاثة نماذج محتوى لموقعي، ولكن عادة ما يكون العدد أكبر من ذلك بكثير. تتمثل إحدى الطرق الجيدة لنمذجة المحتوى في إلقاء نظرة على المخططات الهيكلية (wireframes) لموقعك المستقبلي. لا يتعلق الأمر فقط بكيفية وضع البيانات في نظام إدارة المحتوى، بل تحتاج إلى التفكير في:

  • كيف سيتم عرض المحتوى واستهلاكه؟ على سبيل المثال، المنتجات والفئات. في معظم الحالات، ستجدها في علاقة N:N (متعدد لمتعدد)، ولكنني أهدف هنا إلى جانب التنفيذ. فكر في مدى تعقيد استعلامات البيانات. قد يساعد تعديل نماذج المحتوى لتمثيل بنية الموقع الفعلية بشكل أفضل في التنفيذ. في مثالي، ترتبط عناصر الجدول الزمني بالمدعوين (1:N)، مما يسمح بتنفيذ بسيط بينما تظل إدارة المحتوى واضحة ومباشرة، مثل إعادة تنظيم ترتيب العناصر.
  • مثال على ربط عناصر الجدول الزمني بالمدعوين في نظام إدارة المحتوى

  • كيف يرتبط المحتوى بعناصر المحتوى الأخرى؟ ما هي العلاقة بين المنتجات، حزم المنتجات، الفئات، العروض الخاصة، أو الخصومات؟ ستساعدك الإجابات على هذه الأسئلة في اختيار الأداة المناسبة لربط عناصر المحتوى مثل التصنيف (Taxonomy) أو العناصر المرتبطة (Linked items).
  • كيف سيتم إنشاء المحتوى؟ هل سيفهم المحررون بنية المحتوى التي وضعتها؟ أيضاً، في معظم الحالات، لا يحصلون على صلاحية الوصول إلى المشاريع بأكملها، بل فقط إلى الأجزاء ذات الصلة بهم. هل تسمح بنيتك بمستوى كافٍ من دقة الأذونات؟ هل نماذج المحتوى الخاصة بك مقيدة بما يكفي لتجنب مشاكل المحتوى المفقود على الموقع المباشر؟

هناك الكثير لنمذجة المحتوى. إذا كنت مهتماً، ألقِ نظرة على هذه السلسلة الرائعة حول نمذجة المحتوى التي كتبها Michael Kinkaid.

المكونات الديناميكية: التفاعل مع الزوار

باستخدام نماذج المحتوى الصحيحة، يمكننا إنشاء الموقع الساكن. حسناً، ربما يكون مصطلح “المُنشأ مسبقاً” (pre-generated) وصفاً أفضل له. فمحتواه ليس قديماً وساكناً – أي تغيير في المحتوى سيؤدي فعلياً إلى إعادة بناء الموقع مرة أخرى. ولكن ماذا لو احتجنا إلى التفاعل مع الزوار؟ في بعض الأحيان نحتاج إلى الحصول على بعض المدخلات منهم أو عرض محتوى مختلف لهم بناءً على أفعالهم. في هذه الحالات، يمكننا استخدام المكونات الديناميكية (Dynamic Components).

يتم تهيئة هذه المكونات بقيم خلال بناء الموقع، ولكنها يمكن أن تستمر في التفاعل مع أنظمة الواجهة الخلفية (backend systems) بناءً على إجراءات الزوار.

نموذج تفاعلي ديناميكي لجمع معلومات من المستخدمين

في موقعي، لدي نموذج يمكن للمدعوين استخدامه لتأكيد نوع الإقامة التي يهتمون بها. يجب تخزين اختيارهم مرة أخرى في نفس عنصر المحتوى Invitee الذي أنشأته في الأصل في نظام إدارة المحتوى اللارأسي.

مخطط يوضح بنية تنفيذ المكونات الديناميكية باستخدام الوظائف بلا خادم

يمكنني التواصل مع نظام إدارة المحتوى مباشرة من المكون على الموقع. ومع ذلك، نحن نتحدث عن JavaScript من جانب العميل (client-side). سيكون الكشف عن المفتاح مشكلة أمنية كبيرة، حتى لو لم أتوقع أن يفهم أي من المدعوين ما هو مفتاح الأمان أو كيف يمكن إساءة استخدامه. لذا، فإن الوسيط بين الموقع الساكن ونظام إدارة المحتوى هو وظيفة بلا خادم (serverless function).

مكون تفاعلي على موقع ساكن

لنبدأ بالمكون. لقد استخدمت Vue.js و Gridsome كمولد مواقع ساكنة (SSG)، ولكن مفهوم المكون الديناميكي هو نفسه بغض النظر عن الإطار المستخدم. نظام إدارة المحتوى اللارأسي الذي استخدمته هنا هو Kontent. لديه طبقة مجانية سخية، ولكن إذا كنت تفضل المصادر المفتوحة (لأقتبس أستاذي في الجامعة: “لا أثق به ما لم أرَ كوده”)، فقد سمعت أن Strapi خيار جيد.

تنفيذ المكون

في وقت البناء، سيتلقى المكون البيانات الأولية – البيانات التي نعرفها في تلك النقطة الزمنية المحددة. إذا اختار Michael أحد الخيارات الأسبوع الماضي ونحن نعيد بناء الموقع اليوم، فإننا نعرف اختياره.

 <RsvpAccommodation inviteeId="{GUID}" optionSelected="sleep_in_a_tent" howManyInvited="2" salutation="Michael" />

من ناحية أخرى، إذا لم يتفاعل مع الموقع بعد، فسيكون الاختيار فارغاً.

 <RsvpAccommodation inviteeId="{GUID}" optionSelected="" howManyInvited="2" salutation="Michael" />

يبدو المكون كالتالي:

<template>
  <!-- ... محتوى النموذج ... -->
  <input type="radio" name="option" value="not_interested" id="none" v-model="option" />
  <label for="none">Děkuji, nepotřebuji</label>

  <input type="radio" name="option" value="interested_in_booking_a_room" id="hotel" v-model="option" />
  <label for="hotel">Mám zájem o ubytování v okolí</label>

  <input type="radio" name="option" value="sleep_in_a_tent" id="tent" v-model="option" />
  <label for="tent">Mám zájem o přespání ve vlastním stanu</label>
  <!-- ... -->
</template>

<script>
export default {
  props: {
    salutation: String,
    inviteeId: String,
    howManyInvited: Number,
    optionSelected: String
  },
  data: function () {
    return {
      option: this.optionSelected
    }
  },
  // ...
}
</script>

يراقب Vue.js خصائص البيانات المستخدمة. عندما يغير Michael اختياره، يتم إطلاق حدث تغيير البيانات. لاحظ أن اسم الخاصية في كائن watch يجب أن يتطابق مع اسم خاصية البيانات. في تلك المرحلة، نحتاج إلى تخزين اختياره – نقوم بتشكيل البيانات وتقديم طلب غير متزامن (async request) إلى وظيفة بلا خادم – كل ذلك باستخدام JavaScript من جانب العميل.

<script>
export default {
  // ...
  watch: {
    option: function (newVal, oldVal) {
      let url = `{remote base URL}/action?id=${this.inviteeId}`;
      fetch(url, {
          method: 'POST',
          body: JSON.stringify({
            option: this.option,
          })
        })
        .then(response => {
          if (response.status !== 200) {
            alert("Unable to save, please try again.");
          }
        });
    }
  }
}
</script>

تنفيذ الوظيفة بلا خادم (Serverless Function)

لقد استخدمت Netlify لبناء ونشر الوظيفة بلا خادم. إذا كانت هذه أول وظيفة Netlify لك، فلا تتردد في إلقاء نظرة على فيديو المقدمة الخاص بي حيث أوضح كيفية إعداد بيئة تطوير Netlify المحلية.

يحتوي نظام إدارة المحتوى اللارأسي على واجهتي برمجة تطبيقات (APIs). واحدة لتسليم البيانات – استخدمتها للحصول على جميع البيانات أثناء بناء الموقع – وأخرى لإدارة البيانات. في الوظيفة بلا خادم، أحتاج إلى استخدام كلتا الواجهتين، لذلك أضفت معرف المشروع (project ID) ومفتاح واجهة برمجة تطبيقات الإدارة (management API key) إلى ملف .env في جذر مشروع وظائف Netlify:

KONTENT_PROJECT_ID={project ID}
KONTENT_CM_KEY={management API key}

ومن الأفضل دائماً استخدام حزمة تطوير البرامج (SDK) بدلاً من الكفاح مع استدعاءات REST API الخام:

npm i @dotenv --save
npm i @kentico/kontent-delivery --save
npm i @kentico/kontent-management --save

تبدو بداية الوظيفة كالتالي:

require("dotenv").config();
const KontentDelivery = require('@kentico/kontent-delivery')
const KontentManagement = require('@kentico/kontent-management')

الوظيفة متاحة بشكل عام تقريباً – يتم تخزين عنوان URL الخاص بها في كود JS من جانب العميل كنص عادي – لذلك نحتاج أولاً إلى إجراء بعض الفحوصات الأولية. يجب أن يحتوي كل طلب إلى هذه الوظيفة على معامل ID في سلسلة الاستعلام (querystring) يحمل معرف مدعو موجود. هذا هو الشخص الذي ملأ النموذج. إذا كان المعرف مفقوداً أو غير صالح، فإننا نرجع خطأ 404.

exports.handler = async (event, context, callback) => {
  const { KONTENT_PROJECT_ID, KONTENT_CM_KEY } = process.env;
  const deliveryClient = new KontentDelivery.DeliveryClient({
    projectId: KONTENT_PROJECT_ID
  });
  let id = event.queryStringParameters.id;
  const invitee = await deliveryClient.items()
    .type('invitee')
    .elementsParameter(['accommodation'])
    .equalsFilter('system.id', id)
    .toPromise();
  if (invitee.items == null || invitee.items.length == 0) {
    return {
      statusCode: 404,
      body: `Invitee not found`
    };
  }

يقتصر طلب deliveryClient على عنصر واحد فقط – accommodation. وذلك لأن المعلومات من النموذج لا يتم تخزينها ضمن نموذج Invitee ولكن في عنصر مرتبط من نوع Accommodation.

نموذج محتوى متداخل يوضح العلاقة بين المدعو والإقامة

نموذج المحتوى Accommodation يتوافق مباشرة مع النموذج على الموقع الإلكتروني:

تخطيط نموذج محتوى الإقامة الذي يتوافق مع نموذج الموقع

نريد تخزين البيانات التي حصلنا عليها من JS من جانب العميل كسجل جديد. تحديث عنصر المحتوى الموجود هو أيضاً احتمال، لكننا سنفقد كل التاريخ إذا غير المدعوون اختيارهم في المستقبل. أولاً، نلاحظ معرف عنصر المحتوى Accommodation الموجود ونقوم بتهيئة عميل إدارة المحتوى.

let accommodationId = invitee.items[0].accommodation.value[0].system.id;
const client = new KontentManagement.ManagementClient({
  projectId: KONTENT_PROJECT_ID,
  apiKey: KONTENT_CM_KEY
});

بعد ذلك، نحتاج إلى إنشاء نسخة لغوية جديدة من عنصر المحتوى Accommodation. حتى لو كانت هناك لغة واحدة فقط في المشروع، يتم تخزين المحتوى في سلة منفصلة تحمل اسم الكود الخاص بتلك اللغة. هذا يضمن انتقالاً سلساً إذا قررت إضافة لغات إضافية في المستقبل.

await client.createNewVersionOfLanguageVariant()
  .byItemId(accommodationId)
  .byLanguageCodename('default')
  .toPromise();

يقوم هذا الكود بنفس الشيء كما لو نقرت على “إنشاء إصدار جديد” في واجهة المستخدم.

لقطة شاشة لواجهة المستخدم تظهر خيار 'إنشاء إصدار جديد'

بعد ذلك، نحتاج إلى ملء النسخة بالبيانات. تأتي البيانات بتنسيق JSON في نص الطلب.

let accommodation = JSON.parse(event.body);
await client.upsertLanguageVariant()
  .byItemId(accommodationId)
  .byLanguageCodename('default')
  .withElements([{
    element: {
      codename: 'option'
    },
    value: [{
      codename: accommodation.option
    }]
  }])
  .toPromise();

الخطوة الأخيرة هي نشر النسخة الجديدة:

await client.publishOrScheduleLanguageVariant()
  .byItemId(accommodationId)
  .byLanguageCodename('default')
  .withData()
  .toPromise();
return {
  statusCode: 200,
  body: `OK`
}

مشاكل CORS مع Netlify

حتى لو كنت تقوم بتشغيل الوظائف والموقع الساكن محلياً، ستواجه مشكلة CORS (Cross-Origin Resource Sharing) حيث يتم تقديم كلا التنفيذين من منافذ مختلفة. في جميع الاستجابات من الوظيفة بلا خادم، تحتاج إلى إرجاع رأس "Access-Control-Allow-Origin". لدى Netlify طريقة بسيطة للتعامل مع هذا عالمياً من خلال ملف التكوين netlify.toml في جذر مشروع الوظائف:

[build]
Functions = "lambda"

[[headers]]
for = "/*"
  [headers.values]
  Access-Control-Allow-Origin = "*"

البيانات القديمة بعد تحديث الصفحة

الآن يتفاعل المكون مع إجراءات الزوار. ومع ذلك، فإن الحالة الأولية (التي ستُعرض أيضاً إذا قام الزائر بتحديث الصفحة) لا تزال تأتي من بناء الموقع الساكن. إذا قام زائر بتغيير اختياره، يتم حفظ التغيير في نظام إدارة المحتوى، ولكن لا يتم إعادة بناء الموقع. لن يكون فعالاً إعادة بناء الموقع بأكمله بعد كل تفاعل للمستخدم. حتى لو فعلنا ذلك، سيستغرق الأمر بضع ثوانٍ إلى دقائق حتى ينتهي البناء والنشر.

بدلاً من ذلك، نقوم بطلب غير متزامن (async request) عندما يتم عرض المكون لأول مرة (عادة في دورة حياة المكون mounted):

<script>
// ...
mounted: function () {
  let url = `{remote base URL}/delivery?id=${this.inviteeId}`;
  fetch(url, {
      method: 'GET',
      mode: 'cors'
    })
    .then(response => response.json())
    .then(accommodationObj => {
      this.option = accommodationObj.option;
    });
},
// ...
</script>

سيتم تهيئة المكون مسبقاً بالبيانات أثناء الإنشاء المسبق للموقع. ولكن بمجرد إنشاء المكون، سيحصل على بيانات جديدة من نظام إدارة المحتوى باستخدام وظيفة بلا خادم أخرى. هذه الوظيفة تشبه كثيراً الوظيفة السابقة:

exports.handler = async (event, context, callback) => {
  const { KONTENT_PROJECT_ID } = process.env;
  let id = event.queryStringParameters.id;
  const deliveryClient = new KontentDelivery.DeliveryClient({
    projectId: KONTENT_PROJECT_ID
  });
  const invitee = await deliveryClient.items()
    .queryConfig({ waitForLoadingNewContent: true })
    .type('invitee')
    .elementsParameter(['accommodation', 'option']) // تم تعديل هذا الجزء ليتضمن 'option'
    .equalsFilter('system.id', id)
    .toPromise();
  if (invitee.items == null || invitee.items[0] == null) {
    return {
      statusCode: 404,
      body: `Invitee not found`
    };
  }
  return {
    statusCode: 200,
    body: JSON.stringify({
      option: invitee.items[0].accommodation.value[0].codename
    })
  };
};

في هذه الحالة، نحتاج إلى إضافة تكوين إضافي لاستعلام البيانات – waitForLoadingNewContent. يتم تخزين المحتوى القادم من نظام إدارة المحتوى اللارأسي مؤقتاً وتسليمه عبر شبكة توصيل المحتوى (CDN)، لذلك قد نحصل على محتوى قديم إذا تم تغييره في الدقائق القليلة الماضية. يضمن خيار التكوين أن الاستجابة ستحتوي دائماً على بيانات جديدة.

إذن، تبدو العملية الشاملة للمكون الديناميكي على موقع ساكن كالتالي:

مخطط يوضح العملية الكاملة للمكون الديناميكي على موقع ساكن

سرعة وتفاعلية لا مثيل لها

كما ترى، الفائدة الكبرى التي تجلبها المواقع الساكنة هي أن جميع المعلومات المتاحة في وقت البناء يمكن تقديمها كملفات ساكنة، وهي سريعة وسهلة التوسع باستخدام شبكة توصيل المحتوى (CDN). ولكن يمكنها أيضاً توفير وظائف ديناميكية يمكن تسليمها عبر وظائف بلا خادم (serverless functions) – وهي أيضاً رخيصة وسهلة التوسع.

إذا أخذت موقعي كمثال – بدلاً من الاضطرار إلى نشر التطبيق بأكمله على السحابة، احتجت فقط إلى استضافة مجموعة من الملفات الساكنة الصغيرة ووظيفتين صغيرتين بلا خادم. وأنا أيضاً قادر على توسيع نطاق هذه الوظائف بشكل مستقل. قد لا تكون المواقع الساكنة الخيار الأمثل لتطوير الويب في جميع السيناريوهات، ولكنها للعديد من المشاريع تجلب الوضوح والأداء وفوائد الأمان، فضلاً عن تقليل تكاليف الصيانة.

الخلاصة التقنية

لقد أثبتت هذه المقاربة الهجينة، التي تجمع بين قوة مولدات المواقع الساكنة ومرونة الوظائف بلا خادم وأنظمة إدارة المحتوى اللارأسية، أنها حل فعال للغاية لبناء مواقع ويب سريعة، آمنة، وقابلة للتوسع، مع الحفاظ على القدرة على تقديم تجارب مستخدم ديناميكية وشخصية. إنها تمثل تطوراً مهماً في مجال تطوير الويب، مما يتيح للمطورين بناء تطبيقات ويب غنية بالميزات دون التضحية بمزايا الأداء التي توفرها البنية الساكنة. هذا النموذج يقلل بشكل كبير من تعقيد البنية التحتية ويوفر مرونة كبيرة في التعامل مع متطلبات المحتوى المتغيرة وتفاعلات المستخدم.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *