TypeScript ו-IndexedDB

TypeScript ו-IndexedDB

מאמר זה מסביר על TypeScript ו-IndexedDB.

נסביר על TypeScript ו-IndexedDB עם דוגמאות מעשיות.

YouTube Video

TypeScript ו-IndexedDB

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

מונחים בסיסיים וזרימת עבודה

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

פתיחת IndexedDB (תבנית בסיסית)

ראשית, מוצג דוגמה לפתיחת מסד נתונים עם IndexedDB ויצירת חנות עצמים ב-onupgradeneeded במידת הצורך.

 1// Open an IndexedDB database and create an object store if needed.
 2// This code shows the classic callback-based IndexedDB API wrapped into a Promise.
 3function openDatabase(dbName: string, version: number): Promise<IDBDatabase> {
 4  return new Promise((resolve, reject) => {
 5    const request = indexedDB.open(dbName, version);
 6
 7    request.onerror = () => {
 8      reject(request.error);
 9    };
10
11    request.onupgradeneeded = (event) => {
12      const db = request.result;
13      if (!db.objectStoreNames.contains('todos')) {
14        // Create object store with keyPath 'id'
15        db.createObjectStore('todos', { keyPath: 'id' });
16      }
17    };
18
19    request.onsuccess = () => {
20      resolve(request.result);
21    };
22  });
23}
24
25// Usage example:
26openDatabase('my-db', 1)
27  .then(db => {
28    console.log('DB opened', db.name, db.version);
29    db.close();
30  })
31  .catch(err => console.error('Failed to open DB', err));
  • קוד זה פותח את מסד הנתונים ומדפיס האם הצליח או נכשל.
  • במידת הצורך, חנות ה-todos נוצרת ב-onupgradeneeded.

הגדרת טיפוסים ב-TypeScript (מודלים)

כעת נבצע הגדרת טיפוסי נתונים בעזרת TypeScript. הגדרה זו מבטיחה בטיחות טיפוסית בפעולות CRUD הבאות.

1// Define the TypeScript interface for a Todo item.
2// This helps with type safety in the database operations.
3interface Todo {
4  id: string;          // primary key
5  title: string;
6  completed: boolean;
7  createdAt: number;
8}
  • כאן נגדיר את טיפוס Todo.

דוגמאות למימוש פונקציות CRUD פשוטות

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

 1// CRUD utilities for the 'todos' object store.
 2// Each operation uses a transaction and returns a Promise for easier async/await usage.
 3
 4function addTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 5  return new Promise((resolve, reject) => {
 6    const tx = db.transaction('todos', 'readwrite');
 7    const store = tx.objectStore('todos');
 8    const req = store.add(todo);
 9
10    req.onsuccess = () => {
11      console.log('Todo added', todo.id);
12    };
13    req.onerror = () => reject(req.error);
14
15    tx.oncomplete = () => resolve();
16    tx.onerror = () => reject(tx.error);
17  });
18}
  • פונקציה זו מוסיפה פריט Todo חדש לחנות todos ב-IndexedDB. היא מחזירה Promise לצורך טיפול אסינכרוני, שמתממש לאחר סיום התהליך.
 1function getTodo(db: IDBDatabase, id: string): Promise<Todo | undefined> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const req = store.get(id);
 6
 7    req.onsuccess = () => resolve(req.result as Todo | undefined);
 8    req.onerror = () => reject(req.error);
 9  });
10}
  • פונקציה זו שולפת את פריט ה-Todo עם ה-ID שצוין ומחזירה את האובייקט אם נמצא. אם לא נמצא נתון תואם, מוחזר undefined.
 1function updateTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.put(todo);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo updated', todo.id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • פונקציה זו מעדכנת נתוני Todo קיימים. בהצלחה, מזהה ה-Todo שעודכן מודפס.
 1function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.delete(id);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo deleted', id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • פונקציה זו מוחקת את ה-Todo עם ה-ID שצוין. כאשר הפעולה מצליחה, מזהה הפריט שנמחק נרשם ללוג.

  • פונקציות אלו פותרות או דוחות Promise בהתאם לסיום הטרנזקציה או לשגיאות. הדפסת פלט בעזרת console.log מקלה על מעקב אחרי ההרצה.

אינדקסים ושאילתות מורכבות

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

 1// When opening DB, create an index for createdAt.
 2// Then demonstrate a range query using the index.
 3
 4function openDatabaseWithIndex(dbName: string, version: number): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const request = indexedDB.open(dbName, version);
 7
 8    request.onupgradeneeded = () => {
 9      const db = request.result;
10      if (!db.objectStoreNames.contains('todos')) {
11        const store = db.createObjectStore('todos', { keyPath: 'id' });
12        // Create an index on createdAt for sorting/filtering
13        store.createIndex('by-createdAt', 'createdAt', { unique: false });
14      } else {
15        const store = request.transaction!.objectStore('todos');
16        if (!store.indexNames.contains('by-createdAt')) {
17          store.createIndex('by-createdAt', 'createdAt', { unique: false });
18        }
19      }
20    };
21
22    request.onerror = () => reject(request.error);
23    request.onsuccess = () => resolve(request.result);
24  });
25}
  • פונקציה זו פותחת את המסד נתונים ויוצרת או מוודאת את קיומו של האינדקס by-createdAt בשדה createdAt. כך ניתן לבצע חיפוש ומיון יעילים לפי תאריך יצירה.
 1async function getTodosCreatedAfter(db: IDBDatabase, timestamp: number): Promise<Todo[]> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const index = store.index('by-createdAt');
 6    const range = IDBKeyRange.lowerBound(timestamp, true); // exclusive
 7    const req = index.openCursor(range);
 8
 9    const results: Todo[] = [];
10    req.onsuccess = (event) => {
11      const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null;
12      if (cursor) {
13        results.push(cursor.value as Todo);
14        cursor.continue();
15      } else {
16        resolve(results);
17      }
18    };
19    req.onerror = () => reject(req.error);
20  });
21}
  • פונקציה זו שולפת רק פריטי Todo שנוצרו לאחר הזמן שצוין. שימוש באינדקס מאפשר סריקה יעילה של נתונים לפי סדר תאריך יצירה.

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

מעטפת קלה מבוססת Promise

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

1// A minimal TypeScript wrapper around IndexedDB to simplify common operations.
2// This class is generic over the store's value type and assumes 'keyPath' is 'id'.
3
4class IDBWrapper<T extends { id: string }> {
5  private dbPromise: Promise<IDBDatabase>;
6
7  constructor(private dbName: string, private version: number, private storeName: string) {
8    this.dbPromise = this.open();
9  }
  • מחלקה זו עוטפת פעולות IndexedDB ומספקת פעולות CRUD בטוחות טיפוס. הנחת עבודה היא שחנות העצמים מתבססת על מפתח id.
 1  private open(): Promise<IDBDatabase> {
 2    return new Promise((resolve, reject) => {
 3      const req = indexedDB.open(this.dbName, this.version);
 4      req.onerror = () => reject(req.error);
 5      req.onupgradeneeded = () => {
 6        const db = req.result;
 7        if (!db.objectStoreNames.contains(this.storeName)) {
 8          db.createObjectStore(this.storeName, { keyPath: 'id' });
 9        }
10      };
11      req.onsuccess = () => resolve(req.result);
12    });
13  }
  • היא פותחת את המסד נתונים ויוצרת חנות נתונים חדשה אם נדרש. אתחול החנות מתבצע באירוע השדרוג.
 1  async add(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    await new Promise<void>((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.add(item);
 7      req.onsuccess = () => {
 8        console.log('added', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • מוסיף נתון לחנות IndexedDB. לאחר ההוספה, המזהה החדש נרשם ללוג.
 1  async get(id: string): Promise<T | undefined> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.get(id);
 7      req.onsuccess = () => resolve(req.result as T | undefined);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
  • מאחזר נתון לפי מזהה מוגדר. undefined יוחזר אם הנתון אינו קיים.
 1  async put(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.put(item);
 7      req.onsuccess = () => {
 8        console.log('put', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • מעדכן נתון קיים או מוסיף חדש. בסיום, המזהה המעודכן נרשם ללוג.
 1  async delete(id: string): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.delete(id);
 7      req.onsuccess = () => {
 8        console.log('deleted', id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • מוחק נתון לפי מזהה נתון. בהצלחה, המזהה שנמחק נרשם ללוג.
 1  async getAll(): Promise<T[]> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.getAll();
 7      req.onsuccess = () => resolve(req.result as T[]);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
11}
  • מאחזר את כל הנתונים בחנות. ערך החזרה הוא מערך מסוג T.
 1// Example usage with Todo type:
 2interface Todo {
 3  id: string;
 4  title: string;
 5  completed: boolean;
 6  createdAt: number;
 7}
 8
 9const todoStore = new IDBWrapper<Todo>('my-db', 1, 'todos');
10
11(async () => {
12  const newTodo: Todo = { id: '1', title: 'Learn IndexedDB', completed: false, createdAt: Date.now() };
13  await todoStore.add(newTodo);
14  const fetched = await todoStore.get('1');
15  console.log('fetched', fetched);
16  newTodo.completed = true;
17  await todoStore.put(newTodo);
18  const all = await todoStore.getAll();
19  console.log('all todos', all);
20  await todoStore.delete('1');
21})();
  • קוד זה מהווה דוגמה לשימוש מעשי במחלקת IDBWrapper. זה מציג את הזרימה להוספה, שליפה, עדכון, רשימה ומחיקה של נתוני Todo.

  • מעטפת זו מאפשרת טיפול פשוט בפעולות CRUD בסיסיות. בסביבה אמיתית, יש לטפל גם בניהול שגיאות ובניהול סכימה (אינדקסים).

הגירת סכמות (שדרוג גרסה)

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

 1// Example of handling upgrade to version 2: add an index and perhaps migrate data.
 2// onupgradeneeded receives an event where oldVersion and newVersion are accessible.
 3
 4function upgradeToV2(dbName: string): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const req = indexedDB.open(dbName, 2);
 7    req.onupgradeneeded = (ev) => {
 8      const db = req.result;
 9      const oldVersion = (ev as IDBVersionChangeEvent).oldVersion;
10      console.log('Upgrading DB from', oldVersion, 'to', db.version);
11      let store: IDBObjectStore;
12      if (!db.objectStoreNames.contains('todos')) {
13        store = db.createObjectStore('todos', { keyPath: 'id' });
14      } else {
15        store = req.transaction!.objectStore('todos');
16      }
17      // Add index if not present
18      if (!store.indexNames.contains('by-completed')) {
19        store.createIndex('by-completed', 'completed', { unique: false });
20      }
21
22      // Optional: data migration logic if necessary can go here,
23      // but heavy migrations often should be done lazily on read.
24    };
25    req.onsuccess = () => resolve(req.result);
26    req.onerror = () => reject(req.error);
27  });
28}
  • עיבוד כבד בתוך onupgradeneeded יכול לעכב את ה-UI, לכן יש לצמצם אותו, ולשקול הגירה מושהית (עיבוד הדרגתי בזמן עליית האפליקציה).

הערות זהירות לגבי טרנזקציות (מחזור חיים ושגיאות)

טרנזקציות נסגרות אוטומטית לפני שהסקריפט שיצר אותן מסיים לרוץ. בעת שימוש ב-await בתוך טרנזקציה, ייתכן שהיא תיסגר באופן בלתי צפוי; יש להיזהר כאשר מבצעים מספר פעולות אסינכרוניות באותה טרנזקציה.

 1// Bad pattern: awaiting outside transaction callbacks can cause tx to auto-commit.
 2// Good pattern is to chain requests and resolve on tx.oncomplete as shown earlier.
 3
 4// Example: Do multiple operations inside single tx, avoid awaiting inside.
 5function multiOperation(db: IDBDatabase, items: Todo[]): Promise<void> {
 6  return new Promise((resolve, reject) => {
 7    const tx = db.transaction('todos', 'readwrite');
 8    const store = tx.objectStore('todos');
 9
10    for (const item of items) {
11      const req = store.put(item);
12      req.onerror = () => console.error('put error', req.error);
13      // Do NOT await here; just schedule requests synchronously.
14    }
15
16    tx.oncomplete = () => {
17      console.log('All operations in transaction completed');
18      resolve();
19    };
20    tx.onerror = () => reject(tx.error);
21  });
22}
  • יש להיות מודעים למחזור החיים של טרנזקציה; יש לבצע טרנזקציות נפרדות בעת הצורך, או לתזמן פעולות בסנכרון בתוך טרנזקציה.

שימושים בסמן ודפדוף (pagination)

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

 1// Example: fetch first N items using a cursor (ascending by key).
 2function fetchFirstN(db: IDBDatabase, n: number): Promise<Todo[]> {
 3  return new Promise((resolve, reject) => {
 4    const tx = db.transaction('todos', 'readonly');
 5    const store = tx.objectStore('todos');
 6    const req = store.openCursor();
 7    const out: Todo[] = [];
 8    req.onsuccess = (ev) => {
 9      const cursor = (ev.target as IDBRequest).result as IDBCursorWithValue | null;
10      if (cursor && out.length < n) {
11        out.push(cursor.value as Todo);
12        cursor.continue();
13      } else {
14        resolve(out);
15      }
16    };
17    req.onerror = () => reject(req.error);
18  });
19}
  • באמצעות איסוף פריטים אחד-אחד עם סמן, ניתן להקטין את צריכת הזיכרון. בעת מימוש דפדוף, נהוג לזכור את המפתח האחרון שנקרא.

טיפול בשגיאות ומעבר לגיבוי

1// Feature detection
2if (!('indexedDB' in window)) {
3  console.warn('IndexedDB is not supported. Falling back to localStorage.');
4  // implement fallback logic...
5}
  • ייתכן ש-IndexedDB לא יהיה זמין עקב הבדלים במימוש בין דפדפנים או בהגדרות פרטיות של המשתמשים, כגון גלישה פרטית. לכן, בדוק אם קיים indexedDB ואם לא, ספק חלופה כמו localStorage.

ביצועים והמלצות עבודה

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

  • עצבו את חנות האובייקטים לפי השימוש בפועל. לדוגמה, אם יש הרבה קריאות, ספקו אינדקסים; אם יש הרבה כתיבות, שימרו על עיצוב מפתחות פשוט.
  • נתונים בינריים גדולים כמו תמונות ואודיו יש לאחסן כ-Blobs, או לנהל בעזרת File API או service workers לפי הצורך. ניתן לשקול גם דחיסה לפי הצורך.
  • השאירו את הטרנזקציות קצרות ככל האפשר ובצעו עיבוד כבד מחוץ לטרנזקציה כדי לצמצם זמני נעילה.
  • אינדקסים יכולים להאיץ חיפושים, אך להאט הוספות ועדכונים, לכן צרו רק את אלו שבאמת דרושים.
  • בעת טיפול בהרבה פריטי מידע קטנים, שליפת כולם בבת אחת עם getAll() עלולה לכלות את הזיכרון. ניתן להקטין שימוש בזיכרון על ידי חלוקת העיבוד עם קורסורים.

אבטחה ופרטיות

הנתונים ב-IndexedDB מבודדים לכל דומיין ופרוטוקול בהתאם למדיניות same-origin. עצבו מתוך הנחה שנתונים עלולים ללכת לאיבוד אם המשתמשים מוחקים נתוני דפדפן או משתמשים במצב פרטי.

סיכום ודפוסי תכנון מומלצים

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

  • הגדרת טיפוסים ב-TypeScript ועטיפת פעולות IndexedDB עם Promise/async/await משפרות את הבטיחות וקריאות הקוד.
  • שינויים בסכמות יש לממש באמצעות ניהול גרסאות עם onupgradeneeded, ועיבוד כבד מומלץ לדחות כשניתן.
  • יש לתכנן טרנזקציות להיות קצרות ולהימנע מעבודה אסינכרונית כבדה באותה טרנזקציה.
  • על ידי יצירת מחלקות עוטפות, ניתן להפחית תהליכים משותפים מיותרים כמו טיפול בשגיאות, לוגים והגדרות טיפוסים.

תוכלו לעקוב אחר המאמר שלמעלה באמצעות Visual Studio Code בערוץ היוטיוב שלנו. נא לבדוק גם את ערוץ היוטיוב.

YouTube Video