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

שנדבר על... אדריכלות?

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

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

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

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

Stack Overflow PWA

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

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

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

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

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

מהירות אמינה

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

אפשר לחשוב על PWA כאתר שמספק חוויית משתמש מעולה, ושבאמת מגיע לו מקום במסך הבית של המשתמש. ראשי התיבות FIRE, שמייצגים את המילים Fast (מהיר), Integrated (משולב), Reliable (אמין) ו-Engaging (מעניין), מסכמים את כל המאפיינים שצריך להביא בחשבון כשיוצרים PWA.

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

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

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

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

טכנולוגיות מאפשרות: Service Workers + Cache Storage API

אפליקציות אינטרנט מתקדמות (PWA) מציבות רף גבוה של מהירות ועמידות. למרבה המזל, פלטפורמת האינטרנט מציעה כמה אבני בניין שיכולות לעזור לכם להשיג את הביצועים האלה. אני מתכוון ל-Service Workers ול-Cache Storage API.

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

‫Service Worker שמשתמש ב-Cache Storage API כדי לשמור עותק של תגובה מהרשת.

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

קובץ Service Worker שמשתמש ב-Cache Storage API כדי להגיב, תוך עקיפת הרשת.

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

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

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

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

השרת

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

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

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

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

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

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

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

ניתוב

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

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

שפת תבניות

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

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

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

Workbox

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

ניתוב

בדיוק כמו בקוד בצד השרת, ה-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 של Workbox, שפועל בכל פעם שאני בונה מחדש את האתר.

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

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

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

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

סטרימינג

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

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

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

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

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

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

האסטרטגיה stale-while-revalidate אומרת שאם יש לי תגובה שנשמרה בעבר במטמון לקריאה הזו ל-API, אוכל להעביר אותה בסטרימינג לדף באופן מיידי, תוך עדכון הרשומה במטמון "ברקע" לפעם הבאה שהיא תתבקש.

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

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

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

שיפורים דינמיים והדרגתיים

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

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

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

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

משאבים