تطبيقات أخرى غير SPA: تصاميم بديلة لتطبيقات الويب التقدّمية (PWA)

لنتحدّث عن... الهندسة المعمارية؟

سأتناول موضوعًا مهمًا، ولكن قد يُساء فهمه: البنية التي تستخدمها لتطبيق الويب، وعلى وجه التحديد، كيف تلعب قراراتك المعمارية دورًا عند إنشاء تطبيق ويب تقدّمي.

قد تبدو كلمة "بنية" غامضة، وقد لا يكون واضحًا على الفور سبب أهميتها. إحدى الطرق للتفكير في البنية هي طرح الأسئلة التالية على نفسك: عندما يزور مستخدم صفحة على موقعي الإلكتروني، ما هو رمز HTML الذي يتم تحميله؟ وبعد ذلك، ما الذي يتم تحميله عندما يزورون صفحة أخرى؟

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

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

تطبيق الويب التقدّمي (PWA) لـ Stack Overflow

ولمرافقة هذه المقالة، أنشأتُ تطبيق ويب تقدّميًا (PWA) لـ Stack Overflow. أقضي الكثير من الوقت في قراءة Stack Overflow والمساهمة فيه، وأردت إنشاء تطبيق ويب يسهّل تصفّح الأسئلة الشائعة حول موضوع معيّن. ويستند إلى واجهة برمجة التطبيقات Stack Exchange العامة. وهو برنامج مفتوح المصدر، ويمكنك معرفة المزيد من المعلومات من خلال الانتقال إلى مشروع GitHub.

التطبيقات المتعدّدة الصفحات (MPA)

قبل أن أبدأ في التفاصيل، دعنا نحدد بعض المصطلحات ونشرح أجزاء من التكنولوجيا الأساسية. أولاً، سأتناول ما أحب أن أسميه "التطبيقات المتعددة الصفحات" أو "MPA".

‫MPA هو اسم رائع للبنية التقليدية المستخدَمة منذ بداية الويب. في كل مرة ينتقل فيها المستخدم إلى عنوان URL جديد، يعرض المتصفح تدريجيًا HTML الخاص بتلك الصفحة. ولا تتم محاولة الحفاظ على حالة الصفحة أو المحتوى بين عمليات التنقّل. في كل مرة تزور فيها صفحة جديدة، تبدأ من جديد.

ويختلف هذا عن نموذج التطبيق المكوّن من صفحة واحدة (SPA) لإنشاء تطبيقات الويب، حيث يشغّل المتصفّح رمز JavaScript لتعديل الصفحة الحالية عندما يزور المستخدم قسمًا جديدًا. كلا النموذجين صالحان للاستخدام، ولكن في هذه المشاركة، أردت استكشاف مفاهيم تطبيقات الويب التقدّمية في سياق تطبيق متعدد الصفحات.

سرعة موثوقة

لقد سمعتني (وعدد لا يحصى من الآخرين) أستخدم عبارة "تطبيق الويب التقدّمي" أو PWA. قد تكون على دراية ببعض المعلومات الأساسية في مكان آخر على هذا الموقع الإلكتروني.

يمكنك اعتبار تطبيق الويب التقدّمي (PWA) تطبيق ويب يوفّر تجربة مستخدم من الدرجة الأولى، ويستحقّ حقًا مكانًا على الشاشة الرئيسية للمستخدم. يختصر الاختصار "FIRE"، الذي يشير إلى Fast (سريع) وIntegrated (مدمج) وReliable (موثوق) وEngaging (جذاب)، جميع السمات التي يجب مراعاتها عند إنشاء تطبيق ويب تقدّمي.

في هذه المقالة، سأركّز على مجموعة فرعية من هذه السمات: السرعة والموثوقية.

السرعة: على الرغم من أنّ كلمة "سريع" تعني أشياء مختلفة في سياقات مختلفة، سأتناول مزايا السرعة في تحميل أقل قدر ممكن من الشبكة.

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

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

التكنولوجيات المساعدة: Service Workers وCache Storage API

تضع تطبيقات الويب التقدّمية معايير عالية للسرعة والمرونة. لحسن الحظ، توفّر منصة الويب بعض العناصر الأساسية لتحقيق هذا النوع من الأداء. أقصد بذلك مشغّلي الخدمات وواجهة برمجة التطبيقات Cache Storage.

يمكنك إنشاء مشغّل خدمات يستمع إلى الطلبات الواردة، ويمرّر بعضها إلى الشبكة، ويخزّن نسخة من الردّ لاستخدامها في المستقبل، من خلال Cache Storage API.

عامل خدمة يستخدم واجهة برمجة التطبيقات Cache Storage API لحفظ نسخة من
          استجابة الشبكة

في المرة التالية التي يُرسل فيها تطبيق الويب الطلب نفسه، يمكن لمشغّل الخدمات التحقّق من ذاكرة التخزين المؤقت وإرجاع الردّ المخزَّن مؤقتًا سابقًا.

عامل خدمة يستخدم Cache Storage API للردّ، متجاوزًا الشبكة.

يُعدّ تجنُّب الشبكة كلما أمكن جزءًا مهمًا من تقديم أداء سريع وموثوق.

JavaScript "متماثل"

هناك مفهوم آخر أريد تناوله، وهو ما يُشار إليه أحيانًا باسم "isomorphic" أو "universal" JavaScript. ببساطة، هي فكرة أنّه يمكن مشاركة رمز JavaScript نفسه بين بيئات تشغيل مختلفة. عندما أنشأتُ تطبيق الويب التقدّمي، أردتُ مشاركة رمز JavaScript بين خادم الخلفية ومشغّل الخدمات.

هناك العديد من الطرق الصالحة لمشاركة الرموز البرمجية بهذه الطريقة، ولكن طريقتي كانت استخدام وحدات ES كرمز مصدر نهائي. بعد ذلك، قمتُ بترجمة هذه الوحدات من مصدر إلى مصدر وتجميعها للخادم ومشغّل الخدمات باستخدام مزيج من Babel وRollup. في مشروعي، الملفات التي تحمل امتداد .mjs هي رموز برمجية في وحدة ES.

الخادم

مع وضع هذه المفاهيم والمصطلحات في الاعتبار، دعنا نتعمق في كيفية إنشاء تطبيق Stack Overflow PWA الخاص بي. سأبدأ بتغطية خادم الخلفية، وأشرح كيف يتناسب ذلك مع البنية العامة.

كنت أبحث عن مزيج من الخلفية الديناميكية مع الاستضافة الثابتة، وكان نهجي هو استخدام منصة Firebase.

ستعمل وظائف Firebase السحابية تلقائيًا على إنشاء بيئة مستندة إلى Node عند تلقّي طلب وارد، ودمجها مع إطار عمل Express HTTP الرائج الذي كنت على دراية به. ويوفّر أيضًا استضافة جاهزة للاستخدام لجميع الموارد الثابتة في موقعي الإلكتروني. لنلقِ نظرة على كيفية تعامل الخادم مع الطلبات.

عندما يرسل المتصفّح طلب انتقال إلى الخادم، يمرّ بالخطوات التالية:

نظرة عامة على إنشاء استجابة تنقّل من جهة الخادم

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

هناك جزأين من هذه الصورة يستحقان الاستكشاف بمزيد من التفصيل: التوجيه، والقوالب.

التوجيه

في ما يتعلّق بالتوجيه، كان نهجي هو استخدام بنية التوجيه الأصلية لإطار عمل Express. وهي مرنة بما يكفي لمطابقة بادئات عناوين URL البسيطة، بالإضافة إلى عناوين URL التي تتضمّن مَعلمات كجزء من المسار. هنا، أنشئ عملية ربط بين أسماء المسارات ونمط Express الأساسي الذي يجب مطابقته.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

يمكنني بعد ذلك الرجوع إلى هذه الخريطة مباشرةً من رمز الخادم. عندما يكون هناك تطابق لنمط Express معيّن، يستجيب المعالج المناسب باستخدام منطق النماذج الخاص بالمسار المطابق.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

النماذج من جهة الخادم

وكيف يبدو منطق النماذج هذا؟ لقد اتّبعت نهجًا يجمع أجزاء HTML جزئية بالتسلسل، واحدة تلو الأخرى. هذا النموذج مناسب جدًا للبث.

يرسل الخادم بعض رموز HTML الأولية على الفور، ويتمكّن المتصفّح من عرض تلك الصفحة الجزئية على الفور. بينما يجمع الخادم بقية مصادر البيانات، فإنه يرسلها إلى المتصفح حتى يكتمل المستند.

لفهم ما أعنيه، ألقِ نظرة على رمز Express لأحد مساراتنا:

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

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

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

بعد أن يتلقّى تطبيق الويب الردّ من Stack Exchange API، يستدعي دالة نموذج مخصّص لترجمة البيانات من واجهة برمجة التطبيقات إلى ترميز HTML المقابل لها.

لغة النماذج

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

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

إليكم مثال على كيفية إنشاء نموذج لجزء HTML الديناميكي من فهرس تطبيق الويب. كما هو الحال مع مساراتي، يتم تخزين منطق النماذج في وحدة ES يمكن استيرادها إلى كل من الخادم ومشغّل الخدمات.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

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

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

Of particular note is a data attribute that I add to each link, data-cache-url, set to the Stack Exchange API URL that I need in order to display the corresponding question. يُرجى وضع ذلك في الاعتبار. سأعود إليها لاحقًا.

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

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

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

مشغّل الخدمات

نظرة عامة على إنشاء استجابة تنقّل في مشغّل الخدمات

من المفترض أن يكون هذا المخطّط البياني مألوفًا، فالعديد من الأجزاء نفسها التي تناولتها سابقًا موجودة هنا بترتيب مختلف قليلاً. لنلقِ نظرة على مسار الطلب، مع أخذ عامل الخدمة في الاعتبار.

يتولّى مشغّل الخدمات معالجة طلب التنقّل الوارد لعنوان URL معيّن، وكما فعل الخادم، يستخدم مزيجًا من منطق التوجيه والنماذج لمعرفة كيفية الاستجابة.

النهج هو نفسه كما كان من قبل، ولكن مع عناصر أساسية مختلفة منخفضة المستوى، مثل fetch() وCache Storage API. أستخدم مصادر البيانات هذه لإنشاء استجابة HTML، والتي يمررها عامل الخدمة مرة أخرى إلى تطبيق الويب.

Workbox

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

التوجيه

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

كانت طريقتي هي ترجمة كل مسار Express إلى تعبير عادي مقابل، باستخدام مكتبة مفيدة تسمى regexparam. بعد إجراء هذه الترجمة، يمكنني الاستفادة من دعم Workbox المضمّن لتوجيه التعبيرات العادية.

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

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

التخزين المؤقت لمواد العرض الثابتة

من الأجزاء الرئيسية في عملية إنشاء النماذج التأكّد من أنّ نماذج HTML الجزئية متاحة محليًا من خلال Cache Storage API، ويتم تحديثها باستمرار عند نشر التغييرات على تطبيق الويب. قد تكون صيانة ذاكرة التخزين المؤقت عرضة للأخطاء عند إجرائها يدويًا، لذا أستعين بـ Workbox للتعامل مع التخزين المؤقت المسبق كجزء من عملية الإنشاء.

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

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

يأخذ Workbox لقطة من محتوى كل ملف، ويُدخل تلقائيًا قائمة عناوين URL والمراجعات هذه في ملف مشغّل الخدمات النهائي. يحتوي Workbox الآن على كل ما يحتاجه لإتاحة الملفات المخزّنة مؤقتًا دائمًا، ومواكبة التحديثات. النتيجة هي ملف service-worker.js يحتوي على شيء مشابه لما يلي:

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

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

البث

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

قد تكون سمعت عن واجهة Streams API من قبل. وقد كان زميلي "جيك أرشيبالد" يشيد بها لسنوات. وقد توقّع بجرأة أنّ عام 2016 سيكون عام مصادر البيانات من المواقع الإلكترونية. وواجهة برمجة التطبيقات Streams API رائعة اليوم كما كانت قبل عامين، ولكن مع اختلاف جوهري.

على الرغم من أنّ Chrome كان المتصفّح الوحيد الذي يتيح استخدام Streams في ذلك الوقت، أصبحت واجهة برمجة التطبيقات Streams API متوافقة على نطاق أوسع الآن. القصة بشكل عام إيجابية، ومع رمز احتياطي مناسب، لا يوجد شيء يمنعك من استخدام التدفقات في عامل الخدمة اليوم.

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

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

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

أستخدم برنامج تضمين جديدًا عالي المستوى، workbox-streams. باستخدامها، يمكنني تمريرها في مزيج من مصادر البث، سواء من ذاكرة التخزين المؤقت أو بيانات وقت التشغيل التي قد تأتي من الشبكة. تتولى Workbox تنسيق المصادر الفردية ودمجها معًا في استجابة واحدة للبث.

بالإضافة إلى ذلك، يرصد Workbox تلقائيًا ما إذا كانت واجهة برمجة التطبيقات Streams API متاحة، وعندما لا تكون متاحة، ينشئ استجابة مكافئة غير متعلّقة بالبث. وهذا يعني أنّه لا داعي للقلق بشأن كتابة عناصر احتياطية، لأنّ واجهات برمجة التطبيقات Streams تقترب من التوافق مع المتصفّحات بنسبة %100.

التخزين المؤقت في وقت التشغيل

لنتعرّف على كيفية تعامل مشغّل الخدمات مع بيانات وقت التشغيل من Stack Exchange API. أستخدم دعم Workbox المضمّن لاستراتيجية التخزين المؤقت stale-while-revalidate، إلى جانب انتهاء الصلاحية لضمان عدم نمو مساحة تخزين تطبيق الويب بلا حدود.

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

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

تقرأ الاستراتيجية الأولى البيانات التي تم تخزينها مؤقتًا مسبقًا، مثل نماذج HTML الجزئية.

تنفّذ الاستراتيجية الأخرى منطق التخزين المؤقت stale-while-revalidate، بالإضافة إلى انتهاء صلاحية ذاكرة التخزين المؤقت الأقل استخدامًا مؤخرًا بعد الوصول إلى 50 إدخالاً.

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

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

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

تجلب دالة المصدر التالية البيانات من واجهة برمجة تطبيقات Stack Exchange، وتعالج الاستجابة في HTML الذي يتوقعه تطبيق الويب.

تعني استراتيجية stale-while-revalidate أنّه إذا كان لديّ استجابة مخزّنة مؤقتًا في السابق لطلب البيانات من واجهة برمجة التطبيقات هذا، سأتمكّن من بثّها إلى الصفحة على الفور، مع تعديل إدخال ذاكرة التخزين المؤقت "في الخلفية" للمرة التالية التي يتم فيها طلبها.

أخيرًا، أرسل نسخة مخزّنة مؤقتًا من التذييل وأغلق علامات HTML النهائية لإكمال الردّ.

تساعد مشاركة الرمز في الحفاظ على مزامنة البيانات

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

تحسينات ديناميكية وتدريجية

لقد شرحتُ كلاً من الخادم ومشغّل الخدمات لتطبيق الويب التقدّمي (PWA)، ولكن هناك جزء أخير من المنطق يجب تغطيته: هناك كمية صغيرة من JavaScript يتم تشغيلها على كل صفحة من صفحاتي، بعد أن يتم بثها بالكامل.

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

البيانات الوصفية للصفحة

يستخدم تطبيقي JavaScript من جهة العميل لتعديل البيانات الوصفية لصفحة معيّنة استنادًا إلى الردّ من واجهة برمجة التطبيقات. بما أنّني أستخدم الجزء الأولي نفسه من رمز HTML المخزَّن مؤقتًا لكل صفحة، ينتهي الأمر بتطبيق الويب باستخدام علامات عامة في رأس المستند. ولكن من خلال التنسيق بين الرموز البرمجية الخاصة بالقوالب والرموز البرمجية من جهة العميل، يمكنني تعديل عنوان النافذة باستخدام البيانات الوصفية المحدّدة للصفحة.

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

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

بعد ذلك، بعد تحميل صفحتي، أقرأ هذه السلسلة وأعدّل عنوان المستند.

if (self._title) {
  document.title = unescape(self._title);
}

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

تجربة المستخدم بلا إنترنت

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

أولاً، أستخدم Cache Storage API للحصول على قائمة بجميع طلبات البيانات من واجهة برمجة التطبيقات التي تم تخزينها مؤقتًا في السابق، وأترجم ذلك إلى قائمة بعناوين URL.

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

عندما يدخل المتصفّح في حالة عدم الاتصال بالإنترنت، أقوم بالتكرار خلال قائمة الروابط غير المخزّنة مؤقتًا، وأقوم بتعتيم الروابط التي لن تعمل. تذكَّر أنّ هذا مجرد تلميح مرئي للمستخدم حول ما يجب أن يتوقعه من تلك الصفحات، فأنا لا أوقف الروابط فعليًا أو أمنع المستخدم من التنقل.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

الأخطاء الشائعة

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

هناك بعض الأخطاء الشائعة التي قد تواجهها عند اتخاذ قراراتك المعمارية، وأريد أن أجنّبك بعض المشاكل.

لا تخزّن ملفات HTML الكاملة مؤقتًا

لا أنصح بتخزين مستندات HTML كاملة في ذاكرة التخزين المؤقت. أولاً، إنّها مضيعة للمساحة. إذا كان تطبيق الويب يستخدم بنية HTML الأساسية نفسها لكل صفحة من صفحاته، سينتهي بك الأمر بتخزين نُسخ من الترميز نفسه مرارًا وتكرارًا.

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

انحراف الخادم أو مشغّل الخدمات

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

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

أسوأ السيناريوهات

تنسيق أو تصميم غير متّسق

ماذا يحدث عندما تتجاهل هذه الأخطاء؟ قد تحدث جميع أنواع الأخطاء، ولكن أسوأ سيناريو هو أن يزور مستخدم مكرر الزيارة صفحة مخزّنة مؤقتًا بتنسيق قديم جدًا، ربما صفحة تحتوي على نص عنوان قديم أو تستخدم أسماء فئات CSS لم تعد صالحة.

أسوأ سيناريو: توجيه معطَّل

بدلاً من ذلك، قد يصادف المستخدم عنوان URL يعالجه الخادم، ولكن ليس مشغّل الخدمات. إنّ الموقع الإلكتروني الذي يتضمّن تنسيقات غير مستجيبة وروابط معطّلة لا يُعدّ تطبيق ويب تقدّميًا موثوقًا.

نصائح لتحقيق النجاح

لكنّك لست وحدك في هذه الرحلة. يمكن أن تساعدك النصائح التالية في تجنب تلك المزالق:

استخدام مكتبات النماذج والتوجيه التي تتضمّن عمليات تنفيذ متعددة اللغات

حاوِل استخدام مكتبات النماذج والتوجيه التي تتضمّن عمليات تنفيذ JavaScript. أعلم أنّ بعض المطوّرين لا يمكنهم التخلي عن خادم الويب الحالي ولغة النماذج.

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

استخدام النماذج المتسلسلة بدلاً من النماذج المدمجة

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

تخزين المحتوى الثابت والمحتوى الديناميكي مؤقتًا في مشغّل الخدمات

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

حظر الشبكة عند الضرورة القصوى فقط

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

الموارد