מעבר ל-SPA: ארכיטקטורות חלופיות ל-PWA

בואו נדבר על... ארכיטקטורה?

אני רוצה לעסוק בנושא חשוב שיכול להיות שאינו מובן: הארכיטקטורה שבה אתם משתמשים לאפליקציית האינטרנט, ובמיוחד האופן שבו ההחלטות שלכם בתחום הארכיטקטורה נכנסות לפעולה בבניית Progressive Web App.

"ארכיטקטורה" יכולה להישמע מעורפלת, ולא תמיד ברור למה היא חשובה. ובכן, אחת הדרכים להסתכל על ארכיטקטורה היא לשאול את עצמכם את השאלות הבאות: כשמשתמש מבקר בדף באתר שלי, איזה HTML נטען? ולאחר מכן, מה נטען כשהם מבקרים בדף אחר?

התשובות לשאלות האלה לא תמיד ברורות, ואחרי שמתחילים לחשוב על Progressive Web Apps, הן יכולות להיות אפילו עוד יותר מורכבות. המטרה שלי היא להדריך אתכם באמצעות ארכיטקטורה אפשרית אחת שגיליתי יעילה. במאמר הזה אתן את התוויות להחלטות שקיבלתי כ'הגישה שלי' לבניית Progressive Web App.

אתם יכולים להשתמש בגישה שלי בבניית ה-PWA שלכם, אבל במקביל יש תמיד חלופות חוקיות אחרות. אני מקווה שהיכולת לראות איך כל החלקים משתלבים יחד יעוררו בכם השראה, ותרגישו חופשיים להתאים את המשחק לצרכים שלכם.

PWA ב-Stack Overflow

כדי להתייחס למאמר הזה בניתי Stack Overflow PWA. אני מקדישה זמן רב לקריאה ולהוספת תוכן ל-Stack Overflow, ורציתי ליצור אפליקציית אינטרנט שתאפשר לי לעיין בקלות בשאלות נפוצות בנושא מסוים. הוא מבוסס על Stack Exchange API הציבורי. מדובר בקוד פתוח, ואתם יכולים לקרוא מידע נוסף בפרויקט של GitHub.

אפליקציות מרובות דפים (MPA)

לפני שניכנס לפרטים, נגדיר כמה מונחים ונסביר את הטכנולוגיה הבסיסית. קודם כל, אכלול את מה שאני מכנה "אפליקציות מרובות דפים", או "MPA".

MPA הוא שם מפואר לארכיטקטורה המסורתית שהייתה בשימוש מאז תחילת האינטרנט. בכל פעם שמשתמש מנווט לכתובת URL חדשה, הדפדפן מעבד בהדרגה HTML ספציפי לאותו דף. אין ניסיון לשמר את מצב הדף או את התוכן בין הניווטים. בכל פעם שאתם מבקרים בדף חדש, אתם מתחילים מחדש.

זאת בניגוד למודל של אפליקציה עם דף יחיד (SPA) לבניית אפליקציות אינטרנט, שבו הדפדפן מריץ קוד JavaScript כדי לעדכן את הדף הקיים כשהמשתמש מבקר בקטע חדש. השימוש ב-SPA וב-MPA הוא זהה, אבל בפוסט הזה רציתי לחקור קונספטים של PWA בהקשר של אפליקציה עם כמה דפים.

מהירות אמינה

שמעתם שאני (ושל אינספור אנשים אחרים) משתמשים בביטוי "Progressive Web App", או PWA. יכול להיות שאתם כבר מכירים חלק מחומרי הרקע במקום אחר באתר הזה.

אפשר לחשוב על PWA כאפליקציית אינטרנט שמספקת חוויית משתמש ממדרגה ראשונה, ובאמת נותנת מקום במסך הבית של המשתמש. ראשי התיבות FIRE: Fast, Integated, Reliable ו-Engaging מסכם את כל המאפיינים שצריך לחשוב עליהם כשיוצרים PWA.

במאמר הזה נתמקד בקבוצת משנה של המאפיינים האלה: מהיר ואמין.

מהירה: בעוד ש'מהיר' אומר דברים שונים בהקשרים שונים, אפרט את יתרונות מהירות הטעינה מהרשת ככל האפשר.

אמינות: אבל מהירות גולמית לא מספיקה. כדי שתרגישו כמו PWA, אפליקציית האינטרנט שלכם צריכה להיות אמינה. היא צריכה להיות עמידה מספיק כדי תמיד לטעון משהו, גם אם זה רק דף שגיאה מותאם אישית, ללא קשר למצב הרשת.

מהירה ומהימנה: ולבסוף, אני רוצה לנסח מחדש קצת את ההגדרה של ה-PWA, ולבדוק מה המשמעות של פיתוח מהיר מהימן. לא כדאי להיות מהיר ואמין רק ברשת עם זמן אחזור קצר. המשמעות של מהירות אמינה היא שהמהירות של אפליקציית האינטרנט יציבה, ללא קשר לתנאי הרשת הבסיסיים.

הפעלת טכנולוגיות: Service Workers + Cache Storage API

אפליקציות PWA כוללות רף גבוה של מהירות ועמידות. למרבה המזל, פלטפורמת האינטרנט מציעה כמה אבני בניין כדי להפוך את הביצועים האלה למציאות. אני מתייחס ל-service worker ול-Cache Storage API.

אתם יכולים ליצור Service Worker שמאזין לבקשות נכנסות, מעביר חלק לרשת ומאחסן עותק של התגובה לשימוש עתידי באמצעות ה-API ל-מטמון אחסון.

קובץ שירות (service worker) שמשתמש ב-API ל-מטמון אחסון כדי לשמור עותק של תגובת רשת.

בפעם הבאה שאפליקציית האינטרנט תשלח את אותה בקשה, ה-Service Worker יכול לבדוק את המטמון שלה ופשוט להחזיר את התגובה שנשמרה במטמון.

קובץ שירות (service worker) שמשתמש ב-API של אחסון מטמון כדי להגיב ועוקף את הרשת.

ההימנעות ככל האפשר מהרשת היא חלק חיוני בהצעת ביצועים מהירים ואמינים.

JavaScript "איזומורפי"

אחד המושגים הנוספים שאני רוצה להסביר הוא מה שלפעמים נקרא JavaScript "איזומורפי" או "יוניברסל". במילים פשוטות, הרעיון הוא שאפשר לשתף את אותו קוד JavaScript בין סביבות זמן ריצה שונות. כשיצרתי את ה-PWA, רציתי לשתף קוד JavaScript בין השרת העורפי וה-Service Worker.

יש הרבה גישות תקינות לשיתוף קוד בצורה הזו, אבל הגישה שלי הייתה להשתמש במודולים של ES כקוד המקור הסופי. לאחר מכן העברתי את המודולים האלה לשרת ול-Service Worker באמצעות שילוב של Babel ו-Rollup. בפרויקט שלי, קבצים עם סיומת הקובץ .mjs הם קוד שנמצא במודול ES.

השרת

תוך התחשבות במושגים ובטרמינולוגיה האלה, בואו נראה איך בניתי בפועל את ה-PWA שלי ב-Stack Overflow. אתחיל בסקירה של השרת העורפי שלנו, ואסביר איך זה משתלב בארכיטקטורה הכוללת.

חיפשתי שילוב של קצה עורפי דינמי עם אירוח סטטי, והגישה שלי הייתה להשתמש בפלטפורמת Firebase.

Firebase Cloud Functions יפעיל באופן אוטומטי סביבה מבוססת צומת כשיש בקשה נכנסת, וישלב את המסגרת הפופולרית Express HTTP, שאותה כבר הכרתי. הוא גם כולל אירוח חדש לכל המשאבים הסטטיים באתר. בואו נראה איך השרת מטפל בבקשות.

כשדפדפן שולח בקשת ניווט נגד השרת שלנו, הוא עובר בתהליך הבא:

סקירה כללית של יצירת תגובת ניווט, בצד השרת.

השרת מנתב את הבקשה על סמך כתובת ה-URL, ומשתמש בלוגיקת יצירת תבניות כדי ליצור מסמך HTML מלא. אני משתמש בשילוב של נתונים מ-Stack Exchange API, וגם בקטעי HTML חלקיים שהשרת מאחסן באופן מקומי. ברגע שה-Service Worker יידע איך להגיב, הוא יכול להתחיל להזרים HTML בחזרה לאפליקציית האינטרנט שלנו.

בתמונה הזו יש שני חלקים ששווה לחקור לעומק: תכנון מסלול ויצירת תבניות.

יצירת מסלול מתבצעת

כשמדובר בניתוב, הגישה שלי הייתה להשתמש בתחביר הניתוב המקורי של Express framework. זו רשת גמישה מספיק כדי להתאים לקידומות פשוטות של כתובות URL וגם לכתובות URL שכוללות פרמטרים כחלק מהנתיב. בדוגמה הזו, צריך ליצור מיפוי בין שמות המסלולים לדפוס ה-Express, שאליו צריך להתאים.

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

export default routes;

לאחר מכן אוכל להפנות למיפוי הזה ישירות מקוד השרת. כשיש התאמה לדפוס Express נתון, ה-handler המתאים מגיב ללוגיקת תבניות ספציפית למסלול התואם.

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

יצירת תבניות בצד השרת

ואיך נראית לוגיקת התבניות הזו? ובכן, נקטתי גישה שאיחדה קטעי HTML חלקיים ברצף, זה אחר זה. המודל הזה מתאים מאוד לסטרימינג.

השרת שולח חזרה טקסט סטנדרטי ראשוני של 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();
});

באמצעות שיטת write() של האובייקט response, והפניות לתבניות חלקיות שמאוחסנות באופן מקומי, אפשר להתחיל את שידור התגובה באופן מיידי, בלי לחסום אף מקור נתונים חיצוני. הדפדפן לוקח את ה-HTML הראשוני הזה ומעבד ממשק משמעותי וטוען את ההודעה באופן מיידי.

בחלק הבא בדף שלנו נעשה שימוש בנתונים מ-Stack Exchange API. המשמעות היא שהשרת שלנו צריך לשלוח בקשת רשת. אפליקציית האינטרנט לא יכולה לעבד שום דבר אחר עד שהיא מגיבה חזרה ומעבדת אותה, אבל לפחות משתמשים לא מביטים במסך ריק בזמן שהם ממתינים.

אחרי שאפליקציית האינטרנט מקבלת את התגובה מ-Stack Exchange API, היא מפעילה פונקציית יצירת תבניות בהתאמה אישית כדי לתרגם את הנתונים מה-API ל-HTML המתאים.

שפת התבניות

יצירת תבנית יכולה להיות נושא שנוי במחלוקת, ועקרונות הגישה שלי הם רק גישה אחת מתוך רבות. כדאי להחליף את הפתרון שלכם, במיוחד אם יש לכם קשר מדור קודם למסגרת קיימת ליצירת תבניות.

מה שהגיוני לעשות בתרחיש לדוגמה שלי היה להסתמך על ליטרל התבניות של JavaScript, תוך חלוקה של לוגיקה כלשהי לפונקציות מסייעות. אחד הדברים הנחמדים בבנייה של MPA הוא שאין צורך לעקוב אחרי עדכוני מצב ולעבד מחדש את ה-HTML, אז הגישה הבסיסית שיצרה HTML סטטי התאימה לי.

אז הנה דוגמה לאופן שבו אני בונה את חלק ה-HTML הדינמי באינדקס של אפליקציית האינטרנט שלי. כמו במסלולים שלי, הלוגיקה של התבניות מאוחסנת במודול ES שאפשר לייבא גם לשרת וגם ל-Service Worker.

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 בלבד, וכדאי לחלק את הלוגיקה לפונקציות מסייעות קטנות יותר, לפי הצורך. כאן אני מעביר כל אחד מהפריטים שהוחזרו בתגובת ה-API לפונקציה אחת כזו, שיוצרת רכיב HTML סטנדרטי עם כל המאפיינים המתאימים שהוגדרו.

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

חשוב לשים לב הוא מאפיין נתונים שאני מוסיף לכל קישור, data-cache-url, שמוגדר לכתובת ה-URL של Stack Exchange API שנדרשת לי כדי להציג את השאלה המתאימה. חשוב לזכור. אבדוק זאת שוב מאוחר יותר.

בחזרה למטפל במסלול, בסיום יצירת התבנית, אעביר את החלק האחרון של ה-HTML ב-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();
});

זהו סיור קצר בהגדרת השרת שלי. משתמשים שמבקרים באפליקציית האינטרנט שלי בפעם הראשונה תמיד יקבלו תגובה מהשרת, אבל כשמבקר חוזר לאפליקציית האינטרנט, ה-service worker מתחיל להגיב. בואו ניכנס לעומק.

קובץ השירות (service worker)

סקירה כללית של יצירת תגובת ניווט ב-Service Worker.

הדיאגרמה הזו אמורה להיראות מוכרת - חלק גדול מהקטעים שכבר טיפלתי בהם מופיעים כאן בסידור מעט שונה. בואו נסביר על תהליך הבקשה, ונביא בחשבון את קובץ השירות (service worker).

קובץ השירות (service worker) מטפל בבקשת ניווט נכנסת לכתובת URL נתונה, ובדיוק כמו בשרת, הוא משתמש בשילוב של לוגיקה של ניתוב ולוגיקה כדי לקבוע איך להגיב.

הגישה זהה לשיטה הקודמת, אבל עם פרימיטיבים שונים ברמה נמוכה, כמו fetch() ו-מטמון Storage API. אני משתמש במקורות הנתונים האלה כדי ליצור את תגובת ה-HTML, ש-Service Worker מחזיר לאפליקציית האינטרנט.

Workbox

במקום להתחיל מאפס עם פרימיטיבים ברמה נמוכה, אבנה את Service Worker על גבי קבוצה של ספריות ברמה גבוהה שנקראות Workbox. הוא מספק בסיס מוצק ללוגיקת השמירה במטמון, הניתוב ויצירת התגובות של כל Service Worker.

יצירת מסלול מתבצעת

בדיוק כמו בקוד בצד השרת, גם ה-Service Worker צריך לדעת איך להתאים בקשה נכנסת ללוגיקת התגובה המתאימה.

הגישה שלי הייתה לתרגם כל מסלול ב-Express לביטוי רגולרי תואם, ולהשתמש בספרייה שימושית שנקראת regexparam. אחרי ביצוע התרגום, אפשר להשתמש בתמיכה המובנית של Workbox בניתוב של ביטויים רגולריים.

אחרי שמייבאים את המודול שמכיל את הביטויים הרגולריים, אני רושם כל ביטוי רגולרי בנתב של Workbox. בתוך כל מסלול אני יכולה לספק לוגיקה של תבניות בהתאמה אישית כדי ליצור תשובה. יצירת תבנית ב-Service Worker קצת יותר מעורבת מאשר בשרת העורפי שלי, אבל Workbox עוזרת הרבה לתהליך העבודה.

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

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

שמירה סטטית של נכסים דיגיטליים במטמון

חלק מרכזי אחד בסיפור של יצירת התבנית הוא לוודא שתבניות ה-HTML החלקיות שלי זמינות באופן מקומי דרך Cache Storage API, ושהם מתעדכנים כאשר אני פורס שינויים באפליקציית האינטרנט. תחזוקה של מטמון יכולה לגרום לשגיאות באופן ידני, ולכן אני פונה ל-Workbox כדי לטפל בתהליך מקדים במטמון כחלק מה-build שלי.

אני מורה ל-Workbox אילו כתובות URL לשמור מראש במטמון באמצעות קובץ תצורה, ומצביע על הספרייה שמכילה את כל הנכסים המקומיים שלי יחד עם קבוצה של תבניות להתאמה. ה-CLI של תיבת עבודה קורא את הקובץ הזה באופן אוטומטי, run בכל פעם שאני בונה מחדש את האתר.

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

אפליקציית Workbox יוצרת תמונת מצב של התוכן של כל קובץ, ומחדירה באופן אוטומטי את רשימת כתובות ה-URL והגרסאות האלה לקובץ ה-Service Worker הסופי. לתיבת העבודה יש עכשיו את כל מה שצריך כדי שהקבצים השמורים מראש יהיו זמינים תמיד, ומעודכנים. התוצאה שמתקבלת היא קובץ service-worker.js שמכיל משהו שדומה לזה:

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

למשתמשים בתהליך build מורכב יותר, יש ב-Workbox גם פלאגין של webpack וגם מודול צומת גנרי, בנוסף לממשק שורת הפקודה (CLI).

סטרימינג

לאחר מכן, אני רוצה שה-Service Worker יזרים קוד HTML חלקי שנשמר מראש באפליקציית האינטרנט באופן מיידי. זה חלק חיוני מ'מהירות אמינה' – תמיד מופיע במסך משהו משמעותי. למרבה המזל, השימוש ב-Streams API בתוך Service Worker מאפשר לנו לעשות זאת.

יכול להיות ששמעתם בעבר על Streams API. הקולגה שלי, ג'ייק ארק'יבלד, שר את שבחים שלו כבר שנים. הוא ניהל את התחזית הנועזת ש-2016 תהיה שנת הסטרימינג של האינטרנט. ו-Streams API הוא מדהים לא פחות היום מאשר לפני שנתיים, אבל עם הבדל קריטי.

אמנם רק Chrome תמך בעבר בצ'אטים, אבל כיום יש תמיכה נרחבת יותר ב-Streams API . הסיפור הכללי הוא חיובי, ועם קוד חלופי מתאים, אתם לא יכולים למנוע מכם להשתמש היום ב-streams ב-Service Worker.

ובכן... יכול להיות שדבר אחד עוצר אותך בזמן ש-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);
        }
      });
  },
});

אבל יכול להיות שלא כולם יבינו את ההשלכות המלאות של הקוד. במקום לנתח את הלוגיקה הזו, בואו נדבר על הגישה שלי לסטרימינג של Service Worker.

אני משתמש ב-wrapper חדש לגמרי ברמה גבוהה, workbox-streams. באמצעותו אני יכול להעביר אותו בשילוב של מקורות סטרימינג, גם מהמטמון וגם מנתוני זמן הריצה שעשויים להגיע מהרשת. מערכת Workbox יכולה לתאם בין המקורות הנפרדים ולחבר אותם לתגובה אחת, שמשודרת בסטרימינג.

בנוסף, Workbox מזהה באופן אוטומטי אם יש תמיכה ב-Streams API, ואם הוא לא נתמך, הוא יוצר תגובה מקבילה שאינה סטרימינג. במילים אחרות, אין צורך לדאוג לגבי כתיבת גרסאות חלופיות, מכיוון ששידורים בסטרימינג קרובים יותר ל-100% מהתמיכה בדפדפן.

שמירה במטמון בזמן ריצה

ועכשיו נראה איך ה-service worker שלי מטפל בנתוני זמן ריצה מ-Stack Exchange API. אני משתמש בתמיכה המובנית של 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 החלקיות שלנו.

האסטרטגיה השנייה מיישמת את הלוגיקה של השמירה במטמון המיושנת בזמן האימות מחדש במטמון, יחד עם תפוגת התוקף של המטמון הכי פחות בשימוש ברגע שמגיעים ל-50 רשומות.

עכשיו, אחרי שהתקנתי את האסטרטגיות האלה, כל מה שנשאר הוא לומר ל-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, כך שהן תמיד יהיו זמינות באופן מיידי. כך אפשר להבטיח שההטמעה של קובץ השירות (service worker) תהיה מהירה בצורה אמינה להגיב לבקשות, בדיוק כמו הקוד בצד השרת שלי.

פונקציית המקור הבאה תאחזר נתונים מ-Stack Exchange API, ומעבדת את התגובה ל-HTML שאפליקציית האינטרנט מצפה לה.

האסטרטגיה'לא פעיל בזמן אימות מחדש' פירושה שאם יש לי תגובה ששמורה במטמון בעבר לקריאה הזו ל-API, אוכל להזרים אותה לדף באופן מיידי בעודי עדכון של רשומת המטמון 'ברקע' בפעם הבאה שתוצג בקשה.

לבסוף, אני משדר עותק שנשמר במטמון של הכותרת התחתונה שלי וסוגר את תגי ה-HTML הסופיים, כדי להשלים את התגובה.

הקוד לשיתוף שומר על סנכרון

תוכלו לראות שחלקים מסוימים בקוד של Service Worker נראים מוכרים. ה-HTML החלקי ולוגיקת התבניות שה-service worker משתמש בו זהה למה שבו משתמש ה-handler בצד השרת. שיתוף הקוד הזה מבטיח שהמשתמשים ייהנו מחוויה עקבית, בין שהם מבקרים באפליקציית האינטרנט שלי בפעם הראשונה ובין שהם חוזרים לדף שעובד על ידי Service Worker. זה היופי ב-JavaScript איזומורפי.

שיפורים דינמיים ומתקדמים

עברתי על השרת של ה-PWA וגם על ה-Service Worker, אבל צריך לחשוב על הלוגיקה האחרונה: יש כמות קטנה של JavaScript שרץ בכל אחד מהדפים, אחרי הסטרימינג המלא שלהם.

הקוד הזה משפר בהדרגה את חוויית המשתמש, אבל הוא לא חיוני – אפליקציית האינטרנט תמשיך לפעול גם אם היא לא תרוץ.

מטא-נתונים של הדף

האפליקציה שלי משתמשת ב-JavaScipt בצד הלקוח כדי לעדכן את המטא-נתונים של הדף על סמך תגובת ה-API. מכיוון שאני משתמש באותו קטע HTML ראשוני ששמור במטמון עבור כל דף, אפליקציית האינטרנט מקבלת בסופו של דבר תגים גנריים בראש המסמך שלי. אבל באמצעות תיאום בין הקוד של התבניות לבין הקוד בצד הלקוח, אפשר לעדכן את כותרת החלון באמצעות מטא-נתונים ספציפיים לדף.

כחלק מהקוד ליצירת תבניות, הגישה שלי היא לכלול תג סקריפט שמכיל את המחרוזת שמסומנת בתו בריחה (escape) בצורה תקינה.

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

לאחר מכן, אחרי שהדף נטען, קוראים את המחרוזת הזו ומעדכנים את כותרת המסמך.

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

אם יש חלקים אחרים של מטא-נתונים ספציפיים לדף שאתם רוצים לעדכן באפליקציית האינטרנט שלכם, תוכלו לפעול באותה גישה.

חוויית משתמש במצב אופליין

השיפור המתקדם הנוסף שהוספתי משמש למשיכת תשומת הלב ליכולות שלנו במצב אופליין. בניתי PWA אמינה, ואני רוצה שהמשתמשים יידעו שכשהם במצב אופליין, הם עדיין יכולים לטעון דפים שביקרו בהם בעבר.

קודם כל, אני משתמש ב-Cache Storage API כדי לקבל רשימה של כל בקשות ה-API שנשמרו במטמון בעבר, ואני מתרגם אותה לרשימה של כתובות URL.

לזכור את מאפייני הנתונים המיוחדים שדיברתי עליהם. כל אחד מהם מכיל את כתובת ה-URL של בקשת ה-API הנדרשת כדי להציג שאלה? אני יכול להצליב את מאפייני הנתונים האלה מול הרשימה של כתובות ה-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);

בעיות נפוצות

למדתי עכשיו את הגישה שלי לבניית PWA מרובה דפים. יש גורמים רבים שצריך להתחשב בהם כשמחליטים על גישה, ויכול להיות שבסופו של דבר תקבלו החלטות שונות ממה שאני עשיתי. הגמישות הזו היא אחד מהיתרונות הגדולים של בניית אתרים.

יש כמה מכשולים נפוצים שאתם עשויים להיתקל בהם במהלך קבלת החלטות בנושא אדריכליות, והייתי רוצה לחסוך לכם קצת כאב.

אין לשמור במטמון HTML מלא

מומלץ לא לאחסן במטמון מסמכי HTML מלאים. קודם כל, זה בזבוז מקום. אם אפליקציית האינטרנט שלכם משתמשת באותו מבנה HTML בסיסי לכל אחד מהדפים, היא תאחסן שוב ושוב עותקים של אותם תגי עיצוב.

חשוב לציין שאם תפרסו שינוי במבנה ה-HTML המשותף של האתר, כל אחד מהדפים שהיו שמורים במטמון עדיין תקוע עם הפריסה הישנה. דמיינו את התסכול שמבקר חוזר רואה כאשר יש שילוב של דפים ישנים וחדשים.

נסחף של קובץ שירות (service worker)

דבר נוסף שיש להימנע ממנו הוא חוסר סנכרון של השרת ושל ה-Service Worker. הגישה שלי הייתה להשתמש ב-JavaScript איזומורפי, כדי שאותו קוד יפעל בשני המקומות. בהתאם לארכיטקטורת השרת הקיימת, זה לא תמיד אפשרי.

לא משנה מה תהיה ההחלטה הארכיטקטונית שתקבלו, כדאי שתהיה לכם אסטרטגיה מסוימת להרצת הניתוב והתבניות המקבילים בשרת וב-service worker.

התרחישים הגרועים ביותר

חוסר עקביות בפריסה / בעיצוב

מה קורה כשמתעלמים מהמכשולים? ובכן, יכולים להיות כל מיני כשלים, אבל במקרה הגרוע ביותר, משתמש חוזר מבקר בדף שמור במטמון עם פריסה מיושנת מאוד, למשל דף עם טקסט כותרת לא עדכני או משתמש בשמות מחלקות של CSS שאינם חוקיים יותר.

התרחיש הגרוע ביותר: ניתוב שגוי

לחלופין, משתמש עשוי להיתקל בכתובת URL שמטופלת על ידי השרת אבל לא על ידי Service Worker. אתר שמלא בפריסות של זומבים ובדרכים מבוי סתום הוא לא PWA מהימן.

טיפים להצלחה

אבל אתם לא לבד! כדי להימנע מהמכשולים האלה, כדאי להיעזר בטיפים הבאים:

שימוש בספריות של תבניות וניתוב עם הטמעות בשפות רבות

נסו להשתמש בספריות של יצירת תבניות וניתוב עם הטמעות JavaScript. עכשיו, אני יודע שלא כל מפתח מציע את האפשרות לעבור משרת האינטרנט הנוכחי שלכם וליצור תבניות.

עם זאת, לכמה מסגרות פופולריות של תבניות וניתוב יש הטמעות במספר שפות. אם יש לכם אפליקציה שפועלת עם JavaScript וגם עם שפת השרת הנוכחית שלכם, התקרבתם עוד שלב כדי לשמור על סנכרון בין ה-service worker והשרת.

העדפה לתבניות רציפות במקום לתבניות מקננות

בשלב הבא מומלץ להשתמש בסדרה של תבניות רציפות שניתן לשדר בזו אחר זו. זה בסדר אם חלקים מאוחרים יותר בדף משתמשים בלוגיקת תבניות מורכבת יותר, כל עוד אתם יכולים להזרים בחלק הראשוני של ה-HTML במהירות האפשרית.

מטמון תוכן סטטי וגם דינמי ב-Service Worker

על מנת להשיג את הביצועים הטובים ביותר, כדאי לשמור מראש את כל המשאבים הסטטיים הקריטיים של האתר. כדאי גם להגדיר לוגיקה של שמירה במטמון בזמן ריצה כדי לטפל בתוכן דינמי, כמו בקשות API. כשמשתמשים ב-Workbox אפשר לפתח אפליקציות שמבוססות על אסטרטגיות שכבר הוכחו כמוכנות לייצור, במקום להטמיע אותן מאפס.

חסימה ברשת רק כשזה הכרחי

לכן, יש לחסום את הרשת רק כשאי אפשר לשדר תגובה מהמטמון. הצגה מיידית של תגובת API שנשמרה במטמון יכולה להוביל לחוויית משתמש טובה יותר מאשר המתנה לנתונים חדשים.

משאבים