آشنایی با IndexedDB

Introduction to IndexedDB

29 آبان 1400
آشنایی با IndexedDB

سرعت یکی از مهم ترین فاکتورها در دنیای وب امروز است و یکی از روش های بالاتر بردن سرعت سایت منتقل کردن اطلاعات کمتر است. بسیاری از داده هایی که امروزه بین مرورگر و سرور رد و بدل می شوند اضافی هستند و می توانند در مرورگر خود کاربر ذخیره شوند چرا که اطلاعاتی حساس نیستند. مثلا انتخاب تم روشن یا تیره توسط کاربر بهتر است در همان مرورگر کاربر ذخیره شود نه اینکه از سمت سرور ارسال شود.

به طور مثال Performance API را در نظر بگیرید. این یک API در جاوا اسکریپت است که به شما اجازه می دهد زمان را با دقت بالا اندازه گیری کنید و معمولا از آن برای سنجش سرعت یک سایت استفاده می شود. فرض کنید شما می خواهید زمان لازم برای اجرای یک رویداد خاص در مرورگر را اندازه گیری کنید. فرض کنید شما چنین کاری را انجام داده اید و به محض آماده شدن داده ها آن ها را به سمت سرور خود ارسال می کنید. با انجام چنین کاری سرعت سایت خود را بسیار کاهش می دهید. بهتر است داده های آماده شده را در مرورگر کاربر ذخیره کنید و سپس با استفاده از Web Worker ها نتایج را بعدا به سمت سرور ارسال نمایید.

برای ذخیره داده ها در مرورگر کاربر دو API توسط جاوا اسکریپت ارائه شده است:

  • Web Storage: این API خودش به دو API کوچک تر تقسیم می شود: localStore که برای ذخیره سازی همگام (synchronous) و همیشگی داده ها استفاده می شود و sessionStore که برای ذخیره سازی داده ها فقط برای نشست فعلی استفاده می شود. هر دامنه مجاز است تا سقف ۵ مگابایت داده را در Web Storage ذخیره کرده باشد.
  • IndexedDB: این API برای ذخیره سازی غیرهمگام (asynchronous) داده ها استفاده می شوند. ساختار ذخیره سازی داده ها در این API به شکل پایگاه های داده NoSQL است که یعنی به شکل جفت های کلید/مقدار ذخیره می شوند. معمولا تا سقف ۱ گیگابایت داده را می توانید در IndexedDB ذخیره نمایید.

IndexedDB در سال ۲۰۱۱ معرفی و در سال ۲۰۱۵ به یک استاندارد وب (W3C) تبدیل شد بنابراین از نظر پشتیبانی مرورگر ها هیچ مشکلی نخواهید داشت.

سطح مقاله: این مقاله برای افرادی در نظر گرفته شده است که با زبان جاوا اسکریپت و کار با آن در مرورگر آشنا هستند.

آشنایی با اصطلاحات فنی IndexedDB

ما می خواهیم در این مقاله سیستمی برای اندازه گیری زمان بارگذاری صفحات و عناصر مختلف سایت بسازیم بنابراین باید قبل از آن با برخی از اصطلاحات IndexedDB آشنا شویم:

  • database یا پایگاه داده: هر دامنه می تواند یک یا چند پایگاه داده در IndexedDB ایجاد کند و فقط صفحات همان دامنه می توانند به این اطلاعات دسترسی داشته باشند. البته معمولا هر وب سایت فقط یک پایگاه داده IndexedDB ایجاد می کند.
  • object store یا مخزن شیئی: یک مخزن کلید/مقدار که برای ذخیره سازی داده ها استفاده می شود. دقیقا معادل کالکشن ها در MongoDB یا جدول ها در MySQL است.
  • key یا کلید: مقداری یکتا که به عنوان کلید برای دریافت مقادیر استفاده می شود. این کلید می تواند از نوع autoIncrement نیز باشد، یعنی خودش به صورت خودکار تولید شده و با هر مقدار افزایش پیدا کند.
  • index یا ایندکس: روشی دیگر برای مرتب سازی داده ها در مخزن شیئی است. تمام کوئری های جست و جو فقط می توانند از کلید یا ایندکس استفاده کنند.
  • schema یا طرح: تعریف مخزن شیئی، کلید ها و ایندکس ها است.
  • version یا نسخه: شماره نسخه یک پایگاه داده است. IndexedDB قابلیت نسخه گذاری روی پایگاه های داده را دارد. مثلا زمانی که برنامه خود را به روز رسانی کردید می توانید پایگاه داده خود را نیز به روز رسانی کنید.
  • operation یا عملیات: عملیات های انجام شده توسط پایگاه داده مانند ثبت مقدار جدید، حذف مقدار جدید، ویرایش مقادیر و غیره است.
  • transaction یا تراکنش: یک یا چند عملیات (operation) که باید همگی با موفقیت انجام شوند و شکست یک عملیات باعث شکست تمام عملیات های دیگر می شود.
  • cursor یا نشانگر: روشی برای گردش بین نتایج پایگاه داده بدون اینکه مجبور به بارگذاری تمام آن ها در مموری باشیم.

ساختار کلی پایگاه داده

ما در این پروژه یک پایگاه داده به نام performance را طراحی می کنیم که دو object store دارد. یادتان باشد که object store معادل جدول در MySQL است بنابراین من برای ساده تر شدن درک شما در این مقاله با نام جدول به آن اشاره می کنم.

  • navigation: این جدول اطلاعات مربوط به جا به جایی بین صفحات را ذخیره می کند (مثلا redirect ها یا DNS ها یا زمان بارگذاری صفحات و الی آخر).
  • resource: این جدول اطلاعات مربوط به منابع صفحه را ذخیره می کند. مثلا درخواست های AJAX یا تصاویر سایت یا فایل های CSS و الی آخر.

در طول این پروژه می توانید از سربرگ Application در مرورگر کروم یا سربرگ Storage در مرورگر فایرفاکس اطلاعات ذخیره شده در مرورگرتان را مشاهده کنید.

ساخت و اتصال به یک پایگاه داده IndexedDB

من در ابتدا پوشه ای به نام IndexedDB-demo را می سازم و سپس درون آن دو پوشه css و js را ایجاد می کنم. درون پوشه js یک فایل به نام indexeddb.js را تعریف می کنیم. این فایل باید حاوی کلاسی برای کار با پایگاه داده باشد:

export class IndexedDB {

  constructor(dbName, dbVersion, dbUpgrade) {

    return new Promise((resolve, reject) => {

      // شیء اتصال

      this.db = null;




      // بررسی سازگاری و پشتیبانی

      if (!("indexedDB" in window)) reject("not supported");




      // باز کرده پایگاه داده

      const dbOpen = indexedDB.open(dbName, dbVersion);




      if (dbUpgrade) {

        // به روز رسانی پایگاه داده

        dbOpen.onupgradeneeded = e => {

          dbUpgrade(dbOpen.result, e.oldVersion, e.newVersion);

        };

      }




      dbOpen.onsuccess = () => {

        this.db = dbOpen.result;

        resolve(this);

      };




      dbOpen.onerror = e => {

        reject(`IndexedDB error: ${e.target.errorCode}`);

      };

    });

  }

}

در ابتدا constructor این کلاس را داریم که سه پارامتر می گیرد: dbName (نام پایگاه داده)، dbVersion (نسخه پایگاه داده)، dbUpgrade (تابعی برای به روز رسانی پایگاه داده). در قدم اول یک promise را داریم که برگردانده می شود. چرا؟ به دلیل اینکه indexedDB به صورت پیش فرض از callback ها استفاده می کند که قدیمی شده اند. من می خواهم کد هایمان را درون یک promise قرار بدهم تا بتوانیم از دستورات جدید تر مانند async/await نیز استفاده کنیم.

ابتدا یک خصوصیت به نام db را برای این کلاس تعریف می کنیم که مقدارش فعلا روی null است. در مرحله بعدی بررسی می کنیم که آیا indexedDB در شیء window وجود دارد یا خیر. اگر وجود نداشت یعنی مرورگر کاربر از indexedDB پشتیبانی نمی کند بنابراین promise را کاملا reject می کنیم و کار دیگری انجام نمی دهیم. در غیر این صورت متد open را روی شیء indexedDB صدا می زنیم که باعث متصل شدن ما به پایگاه داده می شود. این متد نام پایگاه داده و نسخه آن را دریافت می کند و ما هم آن ها را پاس داده ایم. متغیر dbOpen همان اتصال شما به پایگاه داده است و مقدارش یکی از حالت های زیر است:

  • success: با موفقیت به پایگاه داده متصل شده ایم و از این به بعد می توانیم از dbOpen.result برای اجرای درخواست هایمان استفاده کنیم.
  • error: اتصال به پایگاه داده موفقیت آمیز نبوده است.
  • upgradeneeded: پایگاه داده آماده است اما نسخه آن قدیمی است.

ما در هنگام فراخوانی متد open نسخه پایگاه داده را نیز به آن پاس داده ایم که باید یک عدد صحیح باشد. این عدد یک عدد دلخواه است که توسط خودمان تعیین می شود بنابراین اگر نسخه پایگاه داده از نسخه پاس داده شده توسط ما کمتر باشد، یک رویداد به نام upgradeneeded اجرا می شود. در حال حاضر پایگاه داده ما هنوز تعریف نشده است بنابراین نسخه آن به صورت پیش فرض صفر است.

در مرحله بعدی گفته ام اگر نیاز به آپدیت کردن نسخه پایگاه داده داریم از متد onupgradeneeded کمک می گیریم که رویداد به روز رسانی (e) را به صورت خودکار به تابع ما پاس می دهد. همچنین رویداد onsuccess را تعریف کرده ایم تا اگر به موفقیت به پایگاه داده متصل شدیم خصوصیت db را برابر با dbOpen.result قرار داده و سپس this (نمونه کلاس فعلی) را به عنوان نتیجه promise برمی گردانیم. در نهایت رویداد onerror را داریم که در صورت موفقیت آمیز نبودن اتصال به پایگاه داده اجرا می شود. در این حالت با یک پیام خطای ساده promise را رد کرده ایم.

ساخت فایل performance.js

حالا در پوشه js یک فایل دیگر به نام performance.js را تعریف می کنیم که از کلاس بالا استفاده می کند:

// performance.js

import { IndexedDB } from "./indexeddb.js";




window.addEventListener("load", async () => {

  // اتصال به پایگاه داده

  const perfDB = await new IndexedDB(

    "performance",

    1,

    (db, oldVersion, newVersion) => {

      console.log(`upgrading database from ${oldVersion} to ${newVersion}`);




      switch (oldVersion) {

        case 0: {

          const navigation = db.createObjectStore("navigation", {

              keyPath: "date",

            }),

            resource = db.createObjectStore("resource", {

              keyPath: "id",

              autoIncrement: true,

            });




          resource.createIndex("dateIdx", "date", { unique: false });

          resource.createIndex("nameIdx", "name", { unique: false });

        }

      }

    }

  );




  // در این بخش کد های بیشتری اضافه خواهیم کرد

});

همانطور که می بینید ابتدا منتظر بارگذاری صفحه می مانیم (addEventListener) و پس از آنکه صفحه بارگذاری شد یک نمونه جدید از کلاس IndexedDB را ایجاد می کنیم. اگر یادتان باشد باید ابتدا نام پایگاه داده و سپس نسخه آن و نهایتا متد به روز رسانی آن را به این کلاس پاس بدهیم. من نام پایگاه داده را performance گذاشته و نسخه آن را نیز ۱ تعیین کرده ام اما برای متد به روز رسانی چطور؟

متد به روز رسانی ابتدا اتصال پایگاه داده و سپس نسخه قبلی پایگاه داده و سپس نسخه جدید آن را دریافت می کند. ما این سه را خودمان در فایل indexeddb.js پاس داده بودیم. حالا از یک دستور switch استفاده می کنیم تا ببنیم اگر نسخه پایگاه داده صفر باشد (یعنی پایگاه داده هنوز ساخته نشده باشد) آن را بسازیم. متد createObjectStore یک object store یا مخزن شیئی می سازد که معادل جدول در MySQL است بنابراین من دو جدول را ساخته ام (در ابتدای مقاله در اینباره توضیح داده بودم). این متد دو پارامتر می گیرد:

  • نام دلخواه شما. من نام های navigation و resource را انتخاب کرده ام.
  • شیء ای غیر اجباری به نام keyOptions که تنظیمات آن data store را مشخص می کند و می تواند دو مقدار بگیرد: keyPath آدرس یک خصوصیت که indexedDB از آن به عنوان کلید استفاده خواهد کرد. autoIncrement که اگر روی true باشد با اضافه شدن هر مقدار جدید یک شماره جدید به عنوان id یا همان کلید ساخته می شود.

در مرحله بعدی با استفاده از متدهای createIndex باید ایندکسی را برای پایگاه داده خود بسازیم. چرا؟ به دلیل اینکه در حالت ساده فقط می توانیم با استفاده از کلید ها در داده ها جست و جو کنیم اما با ایندکس کردن فیلد ها می توانیم با استفاده از مقادیر دیگر نیز جست و جوی خودمان را انجام بدهیم. متد createIndex می تواند سه پارامتر دریافت کند:

name: نام دلخواه شما برای ایندکس. keyPath: فیلدی از object store که باید ایندکس شود. ما با استفاده از همین فیلد جست و جو خواهیم کرد. option: یک شیء غیر اجباری که می تواند دو خصوصیت را مشخص کند. ابتدا unique که به معنای یکتا و غیر تکراری بودن ایندکس است. اگر این مقدار را روی true بگذارید و بعدا بخواهید ستونی تکراری در آن ایندکس قرار بدهید یک خطا دریافت می کنید. multiEntry نیز خصوصیت بعدی است. اگر keyPath شما یک آرایه باشد کل آن آرایه به صورت پیش فرض ایندکس خواهد بود اما اگر خصوصیت multiEntry را روی true قرار بدهید تک تک اعضای آن آرایه ایندکس خواهند بود.

حالا هر برنامه ای که صفحه را باز کند از یک نسخه یکسان از پایگاه داده استفاده می کند مگر آنکه کاربر برنامه را در دو سربرگ (tab) جداگانه باز کرده باشد. حالا در پوشه اصلی پروژه یک فایل به نام index.html ایجاد می کنیم و فایل performance.js را به آن اضافه می کنیم:

<!DOCTYPE html>

<html lang="en">




<head>

  <meta charset="UTF-8">

  <title>IndexedDB performance store</title>

  <meta name="viewport" content="width=device-width,initial-scale=1" />

  <link rel="stylesheet" href="./css/main.css" />

  <script type="module" src="./js/performance.js"></script>

</head>




<body>




  <h1>IndexedDB performance test</h1>




</body>




</html>

حالا این صفحه را در مرورگر خود باز کنید. زمانی که این کار را انجام دادید باید پایگاه داده برایتان ساخته شده باشد. من برای تست این موضوع به قسمت developer tools و سپس سربرگ Application در کروم می روم.

ساخت فایل main.css

در پوشه css یک فایل جدید به نام main.css را تعریف کنید که محتوای ساده زیر را داشته باشد:

body {

  font-family: sans-serif;

}

من می خواهم متوسط زمان لازم برای دانلود این فایل را اندازه گیری کنم بنابراین اصلا مهم نیست که واقعا آن را پر از استایل های CSS کنیم. فعلا برای تست فقط همین کد ساده را درون آن می نویسیم.

تکمیل متدهای کلاس IndexedDB

حالا به کلاس IndexedDB برمی گردیم تا متدهایی را در آن بنویسیم. این متدها بعدا در فایل performance.js استفاده می شوند و کار ما را ساده تر می کنند. اولین متد یک getter ساده است که اتصال شما به پایگاه داده (indexedDB) را برمی گرداند:

get connection() {

  return this.db;

}

متد بعدی ما برای ثبت موارد جدید در پایگاه داده است. از آنجایی که ثبت و به روز رسانی در IndexedDB یکی است من نام این متد را update گذاشته ام:

// ثبت آیتم های جدید

update(storeName, value, overwrite = false) {




  return new Promise((resolve, reject) => {




    // تراکنش جدید

    const

      transaction = this.db.transaction(storeName, 'readwrite'),

      store = transaction.objectStore(storeName);




    // اطمینان از اینکه مقادیر درون یک آرایه هستند

    value = Array.isArray(value) ? value : [ value ];




    // ثبت تمام مقادیر در پایگاه داده

    value.forEach(v => {

      if (overwrite) store.put(v);

      else store.add(v);

    });




    transaction.oncomplete = () => {

      resolve(true); // عملیات موفق بوده است

    };




    transaction.onerror = () => {

      reject(transaction.error); // عملیات با شکست مواجه شده است

    };




  });




}

متد بالا سه پارامتر را می گیرد: storeName که نام یا همان کلیدی مقدار شما است. value که خود مقدار است و overwrite که به صورت پیش فرض روی false بوده و وظیفه اش این است که مشخص کند آیا اگر کلید یکسانی از قبل وجود داشت آن را جایگزین کنیم (آن کلید با یک مقدار جدید به روز رسانی شود یا خیر).

نکته: تمام عملیات های IndexedDB باید در قالب تراکنش انجام شوند.

در مرحله بعدی یک تراکنش یا transaction را داریم. این متد مسئول ساخت یک شیء تراکنش است و یک یا چند object store را به همراه سطح دسترسی به آن ها تعریف می کند. متد transaction دو پارامتر می گیرد. اولین پارامتر نام store ای است که تراکنش باید به آن متصل شود و پارامتر دوم نوع دسترسی به آن store است. ما در تراکنش ها دو حالت دسترسی داریم: حالت readonly که فقط برای خواندن داده ها است و حالت readwrite که برای خواندن و نوشتن داده ها است.

در مرحله بعدی با استفاده از متد objectStore یک object store را برای کار در تراکنش انتخاب می کنیم. طبیعتا همان پارامتر اول متد خودمان (storeName) را به آن پاس داده ایم. با این کار اعلام کرده ایم که تراکنش ما قرار است روی این object store اجرا شود. در مرحله بعدی مقداری که باید ثبت شود (value - پارامتر دوم) را بررسی کرده ایم تا آرایه باشد. این مسئله انتخاب شخصی من است و شما ملزم به ثبت آرایه ها نیستید بلکه می توانید از اشیاء نیز استفاده کنید. من آرایه ها را انتخاب کرده ام چرا که ممکن است بخواهم چند مقدار را یکجا ثبت کنم و گردش روی آرایه ها آسان تر از گردش روی اشیاء است.

حالا با استفاده از یک متد forEach روی value گردش می کنیم و تک تک مقادیر درون آن را در پایگاه داده ثبت می کنیم. برای این کار دو متد وجود دارد: متد put بررسی می کند که قبلا چنین کلیدی وجود نداشته باشد. اگر قبلا چنین کلیدی وجود داشت، به جای ثبت مقدار جدید همان مقدار قبلی را با مقدار جدید جایگزین می کند که یک update یا به روز رسانی است اما اگر کلید وجود نداشت یک مقدار جدید را در پایگاه داده ثبت می کند. از طرفی متد add هیچ گاه به روز رسانی انجام نمی دهد و بدون توجه به دیگر ردیف ها فقط یک مقدار جدید را در پایگاه داده ثبت خواهد کرد. اینجاست که پارامتر سوم (overwrite) تعیین کننده خواهد بود.

توجه داشته باشید که تمام این عملیات ها درون یک promise انجام شده است. چرا؟ همانطور که گفتم من می خواهم از ساختار های جدیدتر async/await استفاده کنم بنابراین کد ها را درون یک promise گذاشته ام. در نهایت دو رویداد را خواهیم داشت. رویداد oncomplete که در صورت موفقیت آمیز بودن تراکنش اجرا می شود و رویداد onerror که در صورت شکست تراکنش اجرا خواهد شد. در حالت اول promise را resolve کرده ایم و در حالت دوم آن را به همراه متن خطا reject کرده ایم.

متد بعدی ما یک تراکنش برای خواندن داده ها از پایگاه داده است. این متد از object store یا از یک ایندکس خاص شروع به خواندن داده ها می کند:

index(storeName, indexName) {




  const

    transaction = this.db.transaction(storeName),

    store = transaction.objectStore(storeName);




  return indexName ? store.index(indexName) : store;




}

در ابتدا با استفاده از متد transaction یک تراکنش را شروع کرده و نام store مورد نظرمان را به آن پاس داده ایم. در مرحله بعدی object store خودمان برای آن تراکنش را نیز مشخص کرده ایم. حالا اگر نام ایندکس (indexName - پارامتر دوم) پاس داده شده باشد متد index را روی store صدا می زنیم و آن را برمی گردانیم، در غیر این صورت خود store را برمی گردانیم. این متد یک متد کمکی است بنابراین کاربرد آن را در ادامه خواهید دید.

bound(lowerBound, upperBound) {




  let bound;

  if (lowerBound && upperBound) bound = IDBKeyRange.bound(lowerBound, upperBound);

  else if (lowerBound) bound = IDBKeyRange.lowerBound(lowerBound);

  else if (upperBound) bound = IDBKeyRange.upperBound(upperBound);




  return bound;




}

در جاوا اسکریپت یک شیء سراسری به نام IDBKeyRange وجود دارد که متدی به نام bound را روی خود دارد. این متد یک key range را تولید کرده و به ما می دهد. key range ها در پایگاه داده IndexedDB به «کلید های بازه» مشهور هستند چرا که یک بازه را مشخص می کنند. برخی از متدهای IndexedDB می توانند یک بازه خاص را به عنوان فیلتر قبول کنند و نتایج را فقط در آن بازه خاص نمایش بدهند.

متد بعدی ما شمردن داده های خاص در پایگاه داده است. من نام این متد را count گذاشته ام:

// شمارش آیتم ها

count(storeName, indexName, lowerBound = null, upperBound = null) {




  return new Promise((resolve, reject) => {




    const request = this.index(storeName, indexName)

      .count( this.bound(lowerBound, upperBound) );




    request.onsuccess = () => {

      resolve(request.result); // برگرداندن تعداد شمرده شده

    };




    request.onerror = () => {

      reject(request.error);

    };




  });




}

برای اجرای این متد نیاز به چند پارامتر متفاوت داریم. اولا نام store ای را می خواهیم که باید در آن شمارش را انجام بدهیم. من نام storeName را برایش انتخاب کرده ام که پارامتر اول ما است. در مرحله بعدی باید نام ایندکس را داشته باشیم که indexName و پارامتر دوم ما است. در ضمن ما می توانیم بازه شمارش را محدود کنیم. به همین دلیل پارامتر های سوم (lowerBound) و چهارم (upperBound) را داریم که به ترتیب پایین ترین و بالاترین حد شمارش را مشخص می کنند.

ما در این متد از متد index استفاده کرده ایم تا آن ایندکس خاص را از store مورد نظرمان دریافت کنیم. توجه کنید که ایندکس های ما یکتا نیستند (در فایل performance.js خصوصیت unique را روی false گذاشتیم). در مرحله بعدی متد count را روی آن صدا زده ایم تا آن ایندکس شروع به شمارش کند. ما می توانیم متد bound را نیز به متد count پاس بدهیم تا بازه شمارش نیز تعیین شود. مثل همیشه دو رویداد onsuccess و onerror را نیز داریم.

متد بعدی ما برای دریافت یک آیتم با استفاده از cursor است. اگر با پایگاه های داده ای مانند mongodb کار کرده باشید حتما با مفهوم cursor آشنایی دارید. تصور کنید که به دنبال حجم نسبتا بزرگی از داده هستید. به جای اینکه تمام این داده ها را از پایگاه داده به صورت یکجا دریافت کنید می توانید فقط بخش مهمی از آن را بگیرید، آن بخش را فیلتر کرده و نهایتا داده های مورد نظرتان را دریافت کنید. یا مثلا اگر می خواهید ۱۰۰ داده مختلف از پایگاه داده را بررسی کنید چطور؟ آیا عاقلانه است که ۱۰۰ آیتم را یکجا در مموری برنامه بگیریم؟ قطعا خیر چرا که دریافت این حجم از داده به صورت یکجا پایگاه داده را شدیدا درگیر می کند. بنابراین یک cursor به ما داده می شود که به ما اجازه می دهد به صورت تک به تک روی این ۱۰۰ آیتم حرکت کنیم. بدین صورت علاوه بر درگیر نکردن پایگاه داده، مموری سیستم را نیز اشغال نمی کنیم.

fetch(storeName, indexName, lowerBound = null, upperBound = null, callback) {




  const

    request = this.index(storeName, indexName)

      .openCursor( this.bound(lowerBound, upperBound) );




  // اجرای کالبک با مقدار فعلی

  request.onsuccess = () => {

    if (callback) callback(request.result);

  };




  request.onerror = () => {

    return(request.error); // عملیات با شکست مواجه شده است

  };




}

همانطور که از ساختار متد بالا متوجه شده اید، cursor ها نیز می توانند حد بالا و پایین داشته باشند (lowerBound و upperBound). ما می توانیم با استفاده از متد index به ایندکس های مورد نظرمان دسترسی داشته باشیم و سپس با متد openCursor یک cursor را روی آن ها باز کنیم. حالا آیتم فعلی را به callback پاس داده شده (پارامتر پنجم) ارسال می کنیم.

با این حساب تمام کد های فایل indexeddb.js باید بدین شکل باشد:

// IndexedDB wrapper class

export class IndexedDB {

  // connect to IndexedDB database

  constructor(dbName, dbVersion, dbUpgrade) {

    return new Promise((resolve, reject) => {

      // connection object

      this.db = null;




      // no support

      if (!("indexedDB" in window)) reject("not supported");




      // open database

      const dbOpen = indexedDB.open(dbName, dbVersion);




      if (dbUpgrade) {

        // database upgrade event

        dbOpen.onupgradeneeded = e => {

          dbUpgrade(dbOpen.result, e.oldVersion, e.newVersion);

        };

      }




      dbOpen.onsuccess = () => {

        this.db = dbOpen.result;

        resolve(this);

      };




      dbOpen.onerror = e => {

        reject(`IndexedDB error: ${e.target.errorCode}`);

      };

    });

  }




  // return database connection

  get connection() {

    return this.db;

  }




  // store item

  update(storeName, value, overwrite = false) {

    return new Promise((resolve, reject) => {

      // new transaction

      const transaction = this.db.transaction(storeName, "readwrite"),

        store = transaction.objectStore(storeName);




      // ensure values are in array

      value = Array.isArray(value) ? value : [value];




      // write all values

      value.forEach(v => {

        if (overwrite) store.put(v);

        else store.add(v);

      });




      transaction.oncomplete = () => {

        resolve(true); // success

      };




      transaction.onerror = () => {

        reject(transaction.error); // failure

      };

    });

  }




  // count items

  count(storeName, indexName, lowerBound = null, upperBound = null) {

    return new Promise((resolve, reject) => {

      const request = this.index(storeName, indexName).count(

        this.bound(lowerBound, upperBound)

      );




      request.onsuccess = () => {

        resolve(request.result); // return count

      };




      request.onerror = () => {

        reject(request.error);

      };

    });

  }




  // get items using cursor

  fetch(storeName, indexName, lowerBound = null, upperBound = null, callback) {

    const request = this.index(storeName, indexName).openCursor(

      this.bound(lowerBound, upperBound)

    );




    // run callback with current value

    request.onsuccess = () => {

      if (callback) callback(request.result);

    };




    request.onerror = () => {

      return request.error; // failure

    };

  }




  // start a new read transaction on object store or index

  index(storeName, indexName) {

    const transaction = this.db.transaction(storeName),

      store = transaction.objectStore(storeName);




    return indexName ? store.index(indexName) : store;

  }




  // get bounding object

  bound(lowerBound, upperBound) {

    let bound;

    if (lowerBound && upperBound)

      bound = IDBKeyRange.bound(lowerBound, upperBound);

    else if (lowerBound) bound = IDBKeyRange.lowerBound(lowerBound);

    else if (upperBound) bound = IDBKeyRange.upperBound(upperBound);




    return bound;

  }

}

استفاده از IndexedDB

کلاس indexeddb کلاسی کمکی برای استفاده از indexedDB بود تا هنگام صدا زدن آن راحت تر باشیم. کار اصلی اندازه گیری ما درون فایل performance.js اتفاق می افتد بنابراین به این فایل رفته و  کد هایش را کامل می کنیم:

import { IndexedDB } from './indexeddb.js';




window.addEventListener('load', async () => {




  const perfDB = await new IndexedDB('performance', 1, (db, oldVersion, newVersion) => {




    console.log(`upgrading database from ${ oldVersion } to ${ newVersion }`);




    switch (oldVersion) {




      case 0: {




        const

          navigation = db.createObjectStore('navigation', { keyPath: 'date' }),

          resource = db.createObjectStore('resource', { keyPath: 'id', autoIncrement: true });




        resource.createIndex('dateIdx', 'date', { unique: false });

        resource.createIndex('nameIdx', 'name', { unique: false });




      }







    }




  });







  if (!('performance' in window) || !perfDB) return;

// بقیه کد ها

همانطور که قبلا هم توضیح داده بودم ما در ابتدا با یک event listener منتظر بارگذاری اولیه صفحه می مانیم. زمانی که صفحه بارگذاری شد ابتدا نسخه صفر از پایگاه داده خود را می سازیم که همان راه اندازی اولیه است (ساخت object store و ایندکس ها و الی آخر). در مرحله بعدی با استفاده از یک شرط if چک می کنم که performance در شیء سراسری window باشد. چرا؟ به دلیل اینکه می خواهیم از performance (یکی از API های جاوا اسکریپت) برای اندازه گیری زمان استفاده کنیم بنابراین مرورگر کاربر باید از آن پشتیبانی کند. اگر اینطور نبود یا مرورگر کاربر از IndexedDB پشتیبانی نمی کرد (شیء perfDB خالی یا دارای خطا بود) فقط return می کنیم تا از event listener خارج شویم.

در مرحله بعدی مطمئن می شویم که همه چیز توسط مرورگر کاربر پشتیبانی می شود بنابراین شروع به ثبت اطلاعات مرورگر می کنیم:

// ثبت اطلاعات صفحه در پایگاه داده

const

  date = new Date(),




  nav = Object.assign(

    { date },

    performance.getEntriesByType('navigation')[0].toJSON()

  );




await perfDB.update('navigation', nav);

در ابتدا تاریخ روز را در شیء date ذخیره کرده ایم، سپس این تاریخ را به همراه اطلاعات جا به جایی در صفحات در شیء nav ذخیره می کند و نهایتا آن را با استفاه از متد update در پایگاه داده ثبت می کنیم. در ضمن متد getEntriesByType یک شیء PerformanceEntry را به شما برمی گرداند. این شیء مجموعه ای از جوانب مختلف برای اندازه گیری را در خود دارد که ما از بین آن navigation (یعنی جا به جایی بین صفحات) را انتخاب کرده ایم تا اطلاعات مربوط به گردش در صفحات مختلف برایمان ثبت شود. این داده ها در اولین object store به نام navigation ذخیره می شوند.

در مرحله بعدی باید داده های مربوط به بارگذاری عناصر صفحه را در object store دیگرمان به نام resources ثبت کنیم:

const res = performance.getEntriesByType('resource').map(

  r => Object.assign({ date }, r.toJSON())

);




await perfDB.update('resource', res);

این بار اطلاعات مربوط به resource را دریافت کرده ایم و با تابع map روی تک تک آن ها گردش کرده ایم. در هر گردش تاریخ را به همراه آن داده ها در شیء res ذخیره کرده ایم. در نهایت این شیء را به متد update داده ایم تا در پایگاه داده ثبت شود. حالا باید سه دستور log را داشته باشیم تا تعداد  منابع برای هر کدام را چاپ کنیم:

console.log('page navigation records: ', await perfDB.count('navigation'));

console.log('resource records: ', await perfDB.count('resource'));

console.log('page load times during 2021:');

من این اکر را به صورت سلیقه ای انجام داده ام، طبیعتا شما می توانید این کار را انجام ندهید.

حالا اشیاء مربوط به جا به جایی در صفحات را دریافت کرده کرده و نمایش می دهیم:

perfDB.fetch(

  'navigation',

  null, // بدون ایندکس

  new Date(2021,0,1,10,40,0,0), // حد پایین

  new Date(2021,11,1,10,40,0,0), // حد بالا

  cursor => { // کالبک انتخابی ما




    if (cursor) {

      console.log(`${ cursor.value.date }: ${ cursor.value.domContentLoadedEventEnd }`);

      cursor.continue();

    }




  }

);

اگر یادتان باشد متد fetch یک cursor را به ما می داد. با صدا زدن متد continue روی این cursor می توانید به نتیجه بعدی بروید. ما نیز در این بخش همین کار را کرده ایم و اطلاعات ثبت شده در مرورگر کاربر در سال ۲۰۲۱ را دریافت کرده ایم (ابتدا و انتهای سال ۲۰۲۱ را به عنوان حد پایین و بالای بازه مشخص کرده ایم). در نهایت اگر در این بازه مقداری وجود داشته باشد (cursor داشته باشیم) مقادیر آن را log کرده ایم و سپس با صدا زدن continue به داده بعدی می رویم.

در نهایت اگر بخواهیم متوسط زمان دانلود فایل main.css را محاسبه کنیم باید باز هم از fetch استفاده کرده و زمان مورد نظر را log می کنیم. به کد زیر توجه نمایید:

let

filename = 'http://localhost:8888/css/main.css',

count = 0,

total = 0;




perfDB.fetch(

'resource', // object store

'nameIdx',  // index

filename,   // matching file

filename,

cursor => { // callback




  if (cursor) {




    count++;

    total += cursor.value.duration;

    cursor.continue();




  }

  else {




    // all records processed

    if (count) {




      const avgDuration = total / count;




      console.log(`average duration for ${ filename }: ${ avgDuration } ms`);




    }




  }




});

ابتدا متغیر filename را داریم که آدرس کامل فایل من است. از آنجایی که من این فایل را روی سیستم خودم و در پورت ۸۸۸۸ اجرا می کنم چنین آدرسی را به آن داده ام اما شما باید آدرس دقیق خودتان را بدهید. سپس متغیر های count و total را داریم که به ترتیب تعداد داده های پایگاه داده و زمان کل برای دانلود آن را در نظر می گیرد.

من با استفاده از fetch دوباره شروع به گردش بین نتایج کرده ام و تا زمانی که cursor را داشته باشیم یک واحد به count اضافه کرده و سپس زمان دانلود آن را به متغیر total اضافه می کنم. این کار را تا زمانی ادامه می دهیم که دیگر cursor وجود نداشته باشد (داده ای در پایگاه داده باقی نمانده باشد). آنگاه زمان کل را بر تعداد دانلود تقسیم کرده و آن را به عنوان زمان متوسط در نظر می گیریم. نهایتا آن را log می کنیم.

من این کد ها را به انتهای فایل performance.js متصل کرده و سپس تمام کد های این فایل را برایتان قرار می دهم. محتویات کامل این فایل باید بدین شکل باشد:

import { IndexedDB } from './indexeddb.js';




window.addEventListener('load', async () => {




  // IndexedDB connection

  const perfDB = await new IndexedDB('performance', 1, (db, oldVersion, newVersion) => {




    console.log(`upgrading database from ${ oldVersion } to ${ newVersion }`);




    switch (oldVersion) {




      case 0: {




        const

          navigation = db.createObjectStore('navigation', { keyPath: 'date' }),

          resource = db.createObjectStore('resource', { keyPath: 'id', autoIncrement: true });




        resource.createIndex('dateIdx', 'date', { unique: false });

        resource.createIndex('nameIdx', 'name', { unique: false });




      }







    }




  });







  if (!('performance' in window) || !perfDB) return;







  // record page navigation information

  const

    date = new Date(),




    nav = Object.assign(

      { date },

      performance.getEntriesByType('navigation')[0].toJSON()

    );




  await perfDB.update('navigation', nav);







  // record resource

  const res = performance.getEntriesByType('resource').map(

    r => Object.assign({ date }, r.toJSON())

  );




  await perfDB.update('resource', res);







  // counr all records

  console.log('page navigation records: ', await perfDB.count('navigation'));

  console.log('resource records: ', await perfDB.count('resource'));

  console.log('page load times during 2021:');







  // fetch page navigation objects in 2021

  perfDB.fetch(

    'navigation',

    null, // not an index

    new Date(2021,0,1,10,40,0,0), // lower

    new Date(2021,11,1,10,40,0,0), // upper

    cursor => { // callback function




      if (cursor) {

        console.log(`${ cursor.value.date }: ${ cursor.value.domContentLoadedEventEnd }`);

        cursor.continue();

      }




    }

  );







  // calculate average download time using index

  let

    filename = 'http://localhost:8888/css/main.css',

    count = 0,

    total = 0;




  perfDB.fetch(

    'resource', // object store

    'nameIdx',  // index

    filename,   // matching file

    filename,

    cursor => { // callback




      if (cursor) {




        count++;

        total += cursor.value.duration;

        cursor.continue();




      }

      else {




        // all records processed

        if (count) {




          const avgDuration = total / count;




          console.log(`average duration for ${ filename }: ${ avgDuration } ms`);




        }




      }




    });




               

});

در حال حاضر می توانید به مرورگر خود رفته و از سربرگ Application داده های موجود در پایگاه داده خود را مشاهده کنید. همچنین زمان ها در بخش console برایتان چاپ می شوند.


منبع: وب سایت openreplay

نویسنده شوید

دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.