قابلیت استفاده از وب‌سایت در حالت آفلاین: PWA و Google Workbox

قابلیت استفاده از وب‌سایت در حالت آفلاین: PWA و Google Workbox

اینترنت در دنیای امروز به بخشی جدایی‌ناپذیر از زندگی روزمره تبدیل شده. با اینکه نرخ دسترسی به اینترنت بیشتر شده، اما به دلیل گسترده شدن استفاده از گوشی‌های هوشمند، نقاط کوری هم هست که امکان دسترسی به اینترنت در آنها نیست. خوشبختانه در جاوااسکریپت راه‌هایی وجود داره که سایت شما در حالت آفلاین هم قابل استفاده باشه. در این مقاله از Google Workbox برای رسیدن به این منظور استفاده کردیم که گوگل برای راحت‌تر کردن توسعه سایت‌های PWA اون رو توسعه داده. PWA یا Progressive Web Applicationها، اپلیکیشن‌هایی هستند که با HTML، CSS و Javascript نوشته می‌شوند و با قابلیت‌هایی که دارند احساس استفاده از یک اپلیکیشن native رو در کاربر ایجاد می‌کنند. یکی از ویژگی‌های این اپلیکیشن‌ها،‌ پشتیبانی از حالت آفلاینه.

چرا آفلاین؟

برای مثال سایتی رو در نظر بگیرید که تنها یک محاسبه ساده را انجام میده؛ مثلا صفحه‌ای که با گرفتن قد و وزن شما، شاخص توده بدنی (BMI) رو محاسبه می‌کنه. و یا سایتی که یه سری مقاله برای خوندن داره. با استفاده از قابلیت‌های PWA میشه کاری کرد که در سایت اول در حالت آفلاین هم بشه محاسبات رو انجام داد و در سایت دوم هم به مقاله‌هایی که قبلا دیدیم دسترسی داشته باشیم.

به عنوان مثال، devdocs.io این کار رو انجام داده‌. این سایت شامل مستندات زبان‌های برنامه‌نویسی و کتابخانه‌های مختلفه و میشه مشخص کرد که کدوم رو میخواهیم در حالت آفلاین هم داشته باشیم.

در کل، مزیت پشتیبانی از حالت آفلاین برای سایت‌ها چیه؟

  • افزایش سرعت بالا آمدن وب‌سایت بعد از اولین لود
  • کاهش تعداد رکوئست‌های http
  • بهبود UX و راحتی بیشتر کاربر

چطور؟

برای این کار باید داده‌های صفحات (مثل فایل‌های html، جاوااسکریپت، فایل‌های استایلی css، تصویرها و فونت‌ها) رو در حافظه کش مرورگر کاربر ذخیره کرد و هربار در باز شدن دوباره سایت، از اونا استفاده کرد؛ باید وقتی که این فایل‌ها تغییر می‌کنند مرورگر رو مجبور کنیم که فایل‌های جدید رو بگیره (مثلا با تغییر رشته هشی که در آدرس رکوئست فایل‌ها قرار می‌دیم). وظیفه ذخیره داده‌ها در کش مرورگر برعهده service workerهاست که کدهای jsای هستند که در پس‌زمینه و در خارج از thread اصلی اجرا میشن.

اما باید حواسمون باشه تا حد ممکن کمتر از حافظه کش مرورگر کاربر استفاده کنیم، و فایل‌های حجیم‌تر رو (مثل فایل‌های جیسون و یا جواب رکوئست‌های بک‌اند) رو در حافظه دیگه‌ای به اسم IndexedDB قرار میدیم که یه APIسطح پایینه برای ذخیره داده‌های ساخت‌یافته (شامل فایل‌ها و blobها) در سمت کلاینت.

کار با  IndexedDB و service worker سخته. به همین دلیل گوگل Workbox رو توسعه داده که مجموعه‌ای از کتابخانه‌ها و کدهای NodeJs است که ساخت PWAها رو ساده‌تر کرده  (و جایگزین کتابخانه‌های پیشین sw-precache و sw-toolbox شده).

پروژه تستی

برای اینکه کار با Workbox رو بتونیم توضیح بدیم از یه صفحه HTML ساده شروع می‌کنیم که فقط دو فایل جاوااسکریپت و یه فایل css رو استفاده کرده. در یکی از فایل‌های جاوااسکریپتی، متنی به صفحه اضافه میشه. یه سرور ساده هم می‌نویسم که صفحه رو بالا بیاره و یه فایل json رو هم با یه آدرس برگردونه که هر وقت خواستیم قطعش می‌کنیم که یعنی اینترنت قطعه. هدف اینه بعد از قطع شدن سرور، باز هم صفحه کار کنه. در بخش بعدی برای اینکه فایل‌ها رو در کش مرورگر ذخیره کنیم از service worker استفاده می‌کنیم.

رجیستر service worker

وظیفه اصلی service worker اینه که فایل‌هایی رو که ما مشخص کردیم در حافظه کش مرورگر کاربر ذخیره کنه. برای استفاده از service workerها باید httpsرو در سایتتون فعال کرده باشین و تقریبا در تمام مرورگرهای مدرن کار می‌کنه.  عمل ذخیره فایل‌ها در حافظه کش مراحلی داره و با رجیستر شدن service worker در مرورگر کاربر شروع میشه. برای اینکه مشخصی بشه چه فایل‌هایی باید ذخیره بشن اونها رو در یک فایل جاوااسکریپتی جدا مشخص کنیم ولی چون با هر بار تغییر فایل‌های ما، رشته هش اونها هم باید تغییر کنه پس از gulp استفاده می‌کنیم. پکیج‌هایی که باید نصب کنیم در فایل ‍package.json زیر مشخصه:

{
  "name": "test_offline_website",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "body-parser": "^1.18.2",
    "express": "^4.16.3",
    "fs": "^0.0.1-security"
  },
  "devDependencies": {
    "concurrently": "^3.5.1",
    "del": "^3.0.0",
    "gulp": "^4.0.0",
    "workbox-build": "^3.5.0"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "gulp && concurrently --kill-others \"gulp watch\" \"node server.js\""
  },
  "author": "Ashkan PH.",
  "license": "MIT"
}

در gulpfile.js به غیر از بخش مربوط به کپی کردن فایل‌ها از پوشه app به build و واچ کردن تغییرات، تسک جدیدی که مربوط به کار ما باشه تسک service-worker است:

const workboxBuild = require('workbox-build');

// Inject a precache manifest into the service worker
const serviceWorker = () => {
  return workboxBuild.injectManifest({
    swSrc: 'app/sw.js',
    swDest: 'build/sw.js',
    globDirectory: 'build',
    globPatterns: [
      'style/style.css',
      'index.html',
      'js/idb-promised.js',
      'js/main.js',
      'images/**/*.*',
      'manifest.json'
    ]
  }).then(resources => {
    console.log(`Injected ${resources.count} resources for precaching, ` +
        `totaling ${resources.size} bytes.`);
  }).catch(err => {
    console.log('Uh oh 😬', err);
  });
}

gulp.task('service-worker', serviceWorker);

در این تسک، آدرس فایل خام sw.js و مقصد فایل نهایی بهش داده شده و فایل‌هایی که باید در حافظه کش مرورگر ذخیره بشوند مشخص شده. کاری که این تسک انجام میده اینه که هربار رشته هش مربوط به هر فایل رو تولید می‌کنه تا مرورگر بدونه که کدوم فایل تغییر کرده تا اون رو دوباره بگیره.

فایل خام sw.js که در فولدر app قرار داره مثل کد زیره:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js');

if (workbox) {
  console.log(`Yay! Workbox is loaded 🎉`);
  workbox.precaching.precacheAndRoute([]);
} else {
  console.log(`Boo! Workbox didn't load 😬`);
}

در اینجا، تابع precacheAndRoute آرایه‌ای از فایل‌هایی که باید کش شوند رو می‌گیره و بعد از رجیستر شدن service worker اونا رو ذخیره می‌کنه (این آرایه فعلا خالیه و وظیفه تسک service worker در فایل gulpfile.js پر کردن این آرایه‌ بعد از هر بار تغییر فایل‌هاست). کتابخانه workbox-sw.js هم مجموعه‌ای از توابعیه که workbox برای کار با service worker از اونا استفاده می‌کنه. ممکنه آدرس googleapis.com برای بعضی در ایران قابل دسترسی نباشه و در نتیجه service worker رجیستر نشه (در این موارد لاگ رجیستر نشدن service worker در کنسول چاپ میشه).

برای رجیستر شدن service worker در فایل main.js کد زیر رو نوشتم. رجیستر کردن service worker، فرآیند نصب اونها رو بدست مرورگر شروع می‌کنه و در همین فرآیند نصبه  که فایل‌ها در حافظه کش مرورگر ذخیره میشه. نصب service worker وقتی تمام می‌شه که ذخیره‌سازی فایل‌ها تمام شده باشه (برای آشنایی بیشتر با  service worker lifecycles می‌تونید این مقاله رو بخونید).

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then(registration => {
        console.log(`Service Worker registered! Scope: ${registration.scope}`);
      })
      .catch(err => {
        console.log(`Service Worker registration failed: ${err}`);
      });
  });
}

با هر بار تغییر در فایل main.js، واچر گالپ لیست فایل‌های sw.js را تغییر میده ولی تا زمانی که صفحه را رفرش نکنید یا تب جدید باز نکنید مرورگر باخبر نمیشه و چون ما استراتژی بارگذاری ابتدایی فایل‌ها از کش رو انتخاب کردیم (precacheAndRoute) پس از شناسایی service worker جدید هم اون رو فعال نمی‌کنه. برای اینکه تغییرات رو بلافاصله ببینیم میشه مثل تصویر زیر از گزینه skip waiting استفاده کنیم و یا برای اینکه بطور پیشفرض با هر بار بارگذاری مجدد صفحه service worker جدید فعال بشه گزینه Update on reload را از Chrome Developer Tools انتخاب کنیم.

استفاده از skip waiting برای فعال کردن service worker جدید

اگر کدهای تا همین جای کار را در پروژه اضافه کرده باشیم باید با یکبار باز کردن سایت در آدرس 127.0.0.1:3002 و سپس بستن سرور، بتوانیم محتوای فعلی سایت را در حالت آفلاین ببینیم. فایل‌های کش‌شده رو در مرورگر کروم از بخش developer tools در تب Application ناحیه مربوط به Cache میشه دید.

ذخیره داده‌های رکوئست‌ها در حافظه IndexedDB

از IndexedDB برای ذخیره‌سازی داده‌های ساخت‌یافته بصورت key و value استفاده میشه. ما در اینجا برای ذخیره جیسونی که از سمت بک‌اند میاد از اون استفاده می‌کنیم. IndexedDB تقریبا در تمام مرورگرها پشتیبانی می‌شود.

کار با API ای که IndexedDB داره کمی سخته و معمولا به کدهای نامرتب و اسپاگتی منجر میشه و به همین دلیله که کتابخانه‌های مختلفی برای راحت‌تر کردن اون نوشته شده. ما هم در اینجا از idb-promised استفاده می‌کنیم که توسط گوگل نوشته شده و کار  را با IndexedDB ساده‌تر کرده.

در فایل html ما دکمه‌ای وجود داره که با کلیکش یک json شامل اطلاعات کاربران رو از بک‌اند می‌گیره و در صفحه نمایش میده. حالا ما باید این داده‌ها رو در IndexedDB ذخیره کنیم و در هنگام قطع بودن اینترنت، اونها رو لود کنیم.

برای این کار ابتدا باید یک دیتابیس IndexedDB بسازیم. این کار را در main.js، با کد زیر انجام میدیم (اگر از قبل وجود ندارد). در اینجا اسم این دیتابیس رو usersDB گذاشتیم و نسخه‌اش رو ۱ گذاشتیم. IndexedDB داده‌ها رو بر اساس کلید و مقدار ذخیره می‌کنه؛ در اینجا هم ما گفتیم که کلید سطرهای دیتابیس کاربران باید مقدار پراپرتی code در جیسون مربوطه باشه ({keyPath: 'code'}). این مقدار باید برای تمام کاربران unique باشه:

// Create IndexedDB (For caching the request data in the indexedDB) 
function createIndexedDB() {
  if (!('indexedDB' in window)) {return null;}
  return idb.open('usersDB', 1, function(upgradeDb) {
    if (!upgradeDb.objectStoreNames.contains('users')) {
      const users = upgradeDb.createObjectStore('users', {keyPath: 'code'}); // code is a unique required proprty of each user
    }
  });
}

const dbPromise = createIndexedDB();

باید با کلیک روی دکمه، داده‌ها از بک‌اند گرفته بشه و در IndexedDB ذخیره بشه. اگر نتوانستیم داده‌ها رو از بک‌اند بگیریم پس یعنی اتصال قطعه و از داده‌های IndexedDB استفاده می‌کنیم و به کاربر نشون میدیم. برای این کار ما به دو تابع احتیاج داریم که یکی داده‌های از بک‌اند گرفته‌شده رو در دیتابیس ذخیره کنه و تابع دیگه، داده‌ها رو از دیتابیس برگردانه. این دو تابع در کد زیر نشون داده شدن که در فایل main.js قرار دارن. دقت کنید که تمام فعالیت‌هایی که بر روی دیتابیس‌های IndexedDB انجام میدیم بصورت تراکنش هستند:

// Save the users' data in the indexedDB
function saveUserDataLocally(users) {
  if (!('indexedDB' in window)) {return null;}
  return dbPromise.then(db => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    if(users) {
      /* Clear the users' data before saving the new users' data
         You can omit this line if you are sure that your uniqe 'code' proprty of 
         each user wont change in future.  
       */
      store.clear();    
      
      return Promise.all(users.map(user => store.put(user)))
      .catch(() => {
        tx.abort();
        throw Error('Users were not added to the store');
      });
    }
  });
}


// Get users' data from the saved data in the indexedDB (For when we are offline)
function getLocalUserData() {
  if (!('indexedDB' in window)) {return null;}
  return dbPromise.then(db => {
    const tx = db.transaction('users', 'readonly');
    const store = tx.objectStore('users');
    return store.getAll();
  });
}

و اما تابعی که با کلیک کاربر روی دکمه اجرا میشه کد زیره:

/* Load users' data - If we are online get it from the backend, 
 *      otherwise use the saved data in the indexedDB 
 */
 function loadContentNetworkFirst() {
  getUsers()
  .then(dataFromNetwork => {
    printUsers(dataFromNetwork);          // Print users
    saveUserDataLocally(dataFromNetwork)  // Save data in indexedDB
    .then(() => {
      console.log("Server data was saved for offline mode");
    }).catch(err => {
      console.log("Server data couldn't be saved offline :(");
      console.warn(err);
    });
  }).catch(err => { // if we can't connect to the server...
    console.log('Network requests have failed, this is expected if offline');
    getLocalUserData()
    .then(offlineData => {
      if (!offlineData.length) {
        console.log("You're offline and local data is unavailable.");
      } else {
        console.log("You're offline and viewing stored data.");
        printUsers(offlineData); 
      }
    });
  });
}

در کد بالا اگه دریافت داده‌ها از بک‌اند موفقیت‌آمیز باشه اونها رو به تابع printUsers پاس میده که رو صفحه چاپشون می‌کنه و بعدش در IndexedDB ذخیره می‌شوند و در صورتی که دریافت داده‌ها موفقیت‌آمیز نباشه داده‌های IndexedDB رو به کاربر نمایش می‌دهیم.

در پایان

اصول کلی کار و بخش‌های کلیدی کار با workbox در اینجا شرح داده شد. اگر مراحل بالا رو به درستی انجام بدید سایت شما می‌تونه در هنگام آفلاین بودن هم کار کنه. کد نهایی این پروژه تستی رو می‌تونید در این آدرس گیت‌هاب ببینید.

workbox قابلیت‌های زیاد بیشتری داره، مثل استراتژی‌های مختلف، تنظیم زمان انقضا برای فایل‌های مختلف، سینک‌کردن فایل‌ها در پس‌زمینه و موارد دیگه‌ای که در سایت خودش می‌تونید ببینید.

توجه داشته باشید که این مقاله بر پایه این آموزش در گوگل codelab نوشته شده و پیشنهاد می‌کنم که اگر به موضوع علاقه‌مندید حتما اون رو هم بخونید.

پشتیبانی مرورگرها از Service worker

Data on support for the serviceworkers feature across the major browsers from caniuse.com

پشتیبانی مرورگرها از IndexedDB

Data on support for the indexeddb feature across the major browsers from caniuse.com