برنامه نویسی

IndexedDB در React

IndexedDB در React

فهرست مطالب


IndexedDB در React: راهنمای جامع و تخصصی ذخیره‌سازی پیشرفته در سمت کلاینت

در دنیای برنامه‌های مدرن وب، نیاز به ذخیره‌سازی داده‌ها در سمت کاربر (کلاینت) بیش از هر زمان دیگری احساس می‌شود. از برنامه‌های آفلاین (Offline First) گرفته تا کش کردن داده‌های حجیم برای بهبود عملکرد، همه به یک راه‌حل قدرتمند نیاز دارند. اینجاست که IndexedDB در React وارد می‌شود. IndexedDB یک پایگاه داده شیءگرای درون‌مرورگری (NoSQL) است که به شما امکان می‌دهد حجم بالایی از داده‌های ساختاریافته را در مرورگر کاربر ذخیره کنید. اما IndexedDB در React یعنی چه و چرا باید به آن توجه کنیم؟ در پاسخ باید گفت: IndexedDB در React به معنای استفاده از قابلیت‌های قدرتمند IndexedDB در کنار کتابخانه React برای ساخت برنامه‌های کاربردی با قابلیت آفلاین، سرعت بالا و تجربه کاربری روان است. در این مقاله از دانا پدیا، قصد داریم به صورت عمیق و تخصصی به IndexedDB در React بپردازیم. از مفاهیم پایه IndexedDB و معماری آن گرفته تا پیاده‌سازی عملی در React، استفاده از کتابخانه‌های کمکی مانند Dexie.js، مدیریت داده‌های حجیم، همگام‌سازی با سرور، بهینه‌سازی عملکرد، اشکال‌زدایی و استقرار را پوشش خواهیم داد.

اگر شما یک توسعه‌دهنده React هستید که به دنبال راهی برای ذخیره‌سازی داده‌ها در سمت کاربر، ساخت برنامه‌های آفلاین، یا افزایش سرعت برنامه خود می‌گردید، این مقاله از دانا پدیا دقیقاً برای شما نوشته شده است. IndexedDB در React راه حلی است که برنامه‌های شما را به سطح بعدی می‌برد. پس با ما همراه باشید تا سفری حرفه‌ای در دنیای IndexedDB در React را آغاز کنیم.

Rust Web Framework Actix


IndexedDB چیست و چرا در React مهم است؟

قبل از پرداختن به جزئیات IndexedDB در React، باید درک کنیم که IndexedDB چیست و چه نیازی را در برنامه‌های React برطرف می‌کند.

IndexedDB چیست؟

IndexedDB یک پایگاه داده NoSQL درون‌مرورگری است که توسط W3C استاندارد شده است. ویژگی‌های کلیدی IndexedDB عبارتند از:

  • ذخیره‌سازی مبتنی بر تراکنش: تمام عملیات خواندن و نوشتن در قالب تراکنش‌های اتمی انجام می‌شوند.
  • مدل داده شیءگرا: داده‌ها به صورت اشیاء جاوااسکریپت (با پشتیبانی از تو در تو) ذخیره می‌شوند.
  • ایندکس‌گذاری پیشرفته: می‌توانید روی هر فیلد از اشیاء خود ایندکس (نمایه) ایجاد کنید تا جستجو سریع باشد.
  • حجم ذخیره‌سازی بالا: معمولاً بین ۵۰ مگابایت تا ۱ گیگابایت (بسته به مرورگر و فضای دیسک کاربر).
  • غیرهمزمان (Asynchronous): عملیات blocking انجام نمی‌دهد و رابط کاربری را قفل نمی‌کند.

مقایسه IndexedDB با سایر روش‌های ذخیره‌سازی در مرورگر

روش ذخیره‌سازیحجم مجازنوع دادهدسترسی همزمانقابلیت ایندکسسادگی API
localStorage~5-10MBرشتهخیرخیربسیار ساده
sessionStorage~5-10MBرشتهخیرخیربسیار ساده
Cookies~4KBرشتهخیرخیرساده
Cache APIمتغیر (بزرگ)Response objectsبلهمحدودمتوسط
IndexedDB50MB – 1GB+هر نوع (شیء)بله (با تراکنش)بله (قوی)پیچیده (بدون کتابخانه)

چرا IndexedDB در React مهم است؟

IndexedDB در React ترکیبی قدرتمند است که مزایای زیر را ارائه می‌دهد:

۱. برنامه‌های آفلاین (Offline-First): با ذخیره داده‌ها در IndexedDB، برنامه React شما حتی بدون اتصال به اینترنت نیز کار می‌کند. کاربر می‌تواند داده‌ها را مشاهده، اضافه یا ویرایش کند و بعداً با سرور همگام شود.

۲. بهبود عملکرد: به جای فراخوانی مکرر API، می‌توانید داده‌ها را در IndexedDB کش کنید. این کار به خصوص برای داده‌های حجیم یا اطلاعاتی که به ندرت تغییر می‌کنند (مانند لیست محصولات) بسیار مؤثر است.

۳. مدیریت state پایدار (Persistent State): در React، state معمولاً در حافظه (RAM) نگهداری می‌شود و با رفرش صفحه از بین می‌رود. IndexedDB در React به شما امکان می‌دهد state را به صورت دائمی ذخیره کنید.

۴. بارگذاری تدریجی (Progressive Loading): می‌توانید مقدار زیادی داده (مانند هزاران رکورد) را در IndexedDB ذخیره کرده و به صورت صفحه‌بندی شده (Pagination) در React نمایش دهید.

۵. پشتیبانی از فایل‌ها: IndexedDB می‌تواند فایل‌های باینری (تصاویر، ویدیوها، PDF) را مستقیماً ذخیره کند. این ویژگی برای برنامه‌هایی که نیاز به آپلود فایل دارند، عالی است.

تست Accessibility با Axe

IndexedDB در React

معماری IndexedDB: مفاهیم کلیدی

برای استفاده مؤثر از IndexedDB در React، باید با مفاهیم پایه IndexedDB آشنا شوید:

۱. پایگاه داده (Database)

یک فضای نام برای ذخیره‌سازی داده‌ها. هر برنامه می‌تواند چندین پایگاه داده داشته باشد. هر پایگاه داده یک نام و نسخه (version) دارد.

۲. Object Store

معادل جدول در پایگاه داده‌های رابطه‌ای. هر Object Store مجموعه‌ای از اشیاء مرتبط را ذخیره می‌کند (مثلاً Object Store برای “کاربران” یا “محصولات”).

۳. رکورد (Record)

یک شیء جاوااسکریپت که در Object Store ذخیره می‌شود. هر رکورد باید یک کلید منحصر به فرد (key) داشته باشد.

۴. کلید (Key)

شناسه یکتای هر رکورد. می‌تواند یک مقدار ساده (عدد، رشته) یا یک مسیر در داخل شیء (مانند user.id) باشد.

۵. ایندکس (Index)

برای جستجوی سریع بر اساس فیلدهای غیرکلید. مثلاً می‌توانید روی فیلد email کاربران ایندکس ایجاد کنید تا جستجو بر اساس ایمیل سریع باشد.

۶. تراکنش (Transaction)

واحد اتمی عملیات. یک تراکنش می‌تواند شامل چندین عملیات خواندن و نوشتن باشد. اگر یکی از عملیات fail شود، کل تراکنش abort می‌شود. تراکنش‌ها می‌توانند فقط خواندنی (readonly) یا خواندن-نوشتن (readwrite) باشند.

۷. محدوده (Range)

برای انتخاب بازه‌ای از کلیدها در هنگام جستجو. مثلاً “تمام کاربران با ID بین ۱۰ تا ۵۰”.

AsyncLocalStorage در Node.js


شروع کار با IndexedDB در React: پیاده‌سازی خام

حالا بیایید دست به کد شویم و یک پروژه React با IndexedDB در React بسازیم. در این بخش از کتابخانه کمکی استفاده نمی‌کنیم تا API اصلی IndexedDB را به خوبی درک کنیم.

ایجاد پروژه React

npx create-react-app indexeddb-react-app
cd indexeddb-react-app
npm start

راه‌اندازی پایگاه داده (Open Database)

// src/db.js
export const DB_NAME = 'MyAppDB';
export const DB_VERSION = 1;
export const STORE_NAME = 'users';

export function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = (event) => {
      reject(`خطا در باز کردن دیتابیس: ${event.target.error}`);
    };

    request.onsuccess = (event) => {
      const db = event.target.result;
      resolve(db);
    };

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // اگر Object Store وجود نداشت، ایجاد کن
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        // ایجاد Object Store با کلید خودکار (auto-increment)
        const store = db.createObjectStore(STORE_NAME, { 
          keyPath: 'id', 
          autoIncrement: true 
        });

        // ایجاد ایندکس روی فیلد name برای جستجوی سریع
        store.createIndex('nameIndex', 'name', { unique: false });

        // ایجاد ایندکس روی فیلد email (یکتا)
        store.createIndex('emailIndex', 'email', { unique: true });

        console.log('Object Store و ایندکس‌ها با موفقیت ایجاد شدند');
      }
    };
  });
}

عملیات CRUD با IndexedDB در React

// src/services/userService.js
import { openDB, STORE_NAME } from '../db';

// ایجاد کاربر جدید
export async function addUser(user) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.add(user);

    request.onsuccess = () => {
      resolve(request.result); // ID کاربر جدید
    };

    request.onerror = (event) => {
      reject(`خطا در افزودن کاربر: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// خواندن همه کاربران
export async function getAllUsers() {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.getAll();

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = (event) => {
      reject(`خطا در خواندن کاربران: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// خواندن یک کاربر با ID
export async function getUserById(id) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.get(id);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = (event) => {
      reject(`خطا در یافتن کاربر: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// به‌روزرسانی کاربر
export async function updateUser(user) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.put(user);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = (event) => {
      reject(`خطا در به‌روزرسانی کاربر: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// حذف کاربر
export async function deleteUser(id) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readwrite');
    const store = transaction.objectStore(STORE_NAME);
    const request = store.delete(id);

    request.onsuccess = () => {
      resolve();
    };

    request.onerror = (event) => {
      reject(`خطا در حذف کاربر: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

// جستجوی کاربران با ایندکس نام
export async function searchUsersByName(name) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([STORE_NAME], 'readonly');
    const store = transaction.objectStore(STORE_NAME);
    const index = store.index('nameIndex');
    const range = IDBKeyRange.bound(name, name + '\uffff');
    const request = index.getAll(range);

    request.onsuccess = () => {
      resolve(request.result);
    };

    request.onerror = (event) => {
      reject(`خطا در جستجو: ${event.target.error}`);
    };

    transaction.oncomplete = () => {
      db.close();
    };
  });
}

کامپوننت React برای مدیریت کاربران

// src/components/UserManager.jsx
import React, { useState, useEffect } from 'react';
import { getAllUsers, addUser, deleteUser, searchUsersByName } from '../services/userService';

function UserManager() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');
  const [formData, setFormData] = useState({ name: '', email: '', age: '' });

  // بارگذاری اولیه کاربران
  useEffect(() => {
    loadUsers();
  }, []);

  const loadUsers = async () => {
    setLoading(true);
    try {
      const allUsers = await getAllUsers();
      setUsers(allUsers);
    } catch (error) {
      console.error('خطا در بارگذاری کاربران:', error);
      alert('خطا در بارگذاری اطلاعات');
    } finally {
      setLoading(false);
    }
  };

  const handleSearch = async () => {
    if (!searchTerm.trim()) {
      await loadUsers();
      return;
    }

    setLoading(true);
    try {
      const results = await searchUsersByName(searchTerm);
      setUsers(results);
    } catch (error) {
      console.error('خطا در جستجو:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!formData.name || !formData.email) {
      alert('نام و ایمیل الزامی است');
      return;
    }

    setLoading(true);
    try {
      const newUser = {
        name: formData.name,
        email: formData.email,
        age: parseInt(formData.age) || null,
        createdAt: new Date().toISOString()
      };

      await addUser(newUser);
      setFormData({ name: '', email: '', age: '' });
      await loadUsers();
      alert('کاربر با موفقیت اضافه شد');
    } catch (error) {
      console.error('خطا در افزودن کاربر:', error);
      alert('خطا در افزودن کاربر. احتمالاً ایمیل تکراری است.');
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async (id) => {
    if (!window.confirm('آیا از حذف این کاربر مطمئن هستید؟')) return;

    setLoading(true);
    try {
      await deleteUser(id);
      await loadUsers();
      alert('کاربر حذف شد');
    } catch (error) {
      console.error('خطا در حذف کاربر:', error);
      alert('خطا در حذف کاربر');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>📀 مدیریت کاربران با **IndexedDB در React**</h1>

      {/* فرم افزودن کاربر */}
      <form onSubmit={handleSubmit} style={{ marginBottom: '30px', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <h3>➕ افزودن کاربر جدید</h3>
        <input
          type="text"
          placeholder="نام"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
          style={{ margin: '5px', padding: '8px' }}
        />
        <input
          type="email"
          placeholder="ایمیل"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          style={{ margin: '5px', padding: '8px' }}
        />
        <input
          type="number"
          placeholder="سن"
          value={formData.age}
          onChange={(e) => setFormData({ ...formData, age: e.target.value })}
          style={{ margin: '5px', padding: '8px' }}
        />
        <button type="submit" disabled={loading} style={{ margin: '5px', padding: '8px 16px' }}>
          {loading ? 'در حال ذخیره...' : 'ذخیره کاربر'}
        </button>
      </form>

      {/* جستجو */}
      <div style={{ marginBottom: '20px' }}>
        <input
          type="text"
          placeholder="جستجو بر اساس نام..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          style={{ padding: '8px', width: '70%' }}
        />
        <button onClick={handleSearch} disabled={loading} style={{ padding: '8px 16px', marginLeft: '10px' }}>
          🔍 جستجو
        </button>
      </div>

      {/* لیست کاربران */}
      {loading && <p>در حال بارگذاری...</p>}

      <div>
        <h3>📋 لیست کاربران ({users.length})</h3>
        {users.length === 0 && !loading && <p>هیچ کاربری یافت نشد.</p>}

        {users.map((user) => (
          <div key={user.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0', borderRadius: '4px' }}>
            <p><strong>نام:</strong> {user.name}</p>
            <p><strong>ایمیل:</strong> {user.email}</p>
            <p><strong>سن:</strong> {user.age || 'نامشخص'}</p>
            <p><strong>تاریخ ثبت:</strong> {new Date(user.createdAt).toLocaleDateString('fa-IR')}</p>
            <button onClick={() => handleDelete(user.id)} style={{ backgroundColor: '#f44336', color: 'white', border: 'none', padding: '5px 10px', cursor: 'pointer' }}>
              🗑️ حذف
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default UserManager;

استفاده در App.js

// src/App.js
import React from 'react';
import UserManager from './components/UserManager';
import './App.css';

function App() {
  return (
    <div className="App">
      <UserManager />
    </div>
  );
}

export default App;

این مثال پایه‌ای نشان می‌دهد که چگونه می‌توان IndexedDB در React را بدون کتابخانه کمکی پیاده‌سازی کرد. اما کد خام IndexedDB بسیار verbose و مستعد خطا است. در بخش بعدی، از Dexie.js استفاده خواهیم کرد که API بسیار ساده‌تری ارائه می‌دهد.

Hotwire Turbo چیست


استفاده از Dexie.js برای ساده‌سازی IndexedDB در React

Dexie.js یک کتابخانه wrapper بسیار محبوب برای IndexedDB است که API promise-based و بسیار ساده‌ای ارائه می‌دهد. IndexedDB در React با Dexie.js لذت‌بخش می‌شود.

نصب Dexie.js

npm install dexie

تعریف دیتابیس با Dexie

// src/db/dexieDB.js
import Dexie from 'dexie';

export const db = new Dexie('MyDexieApp');
db.version(1).stores({
  // syntax: 'field1, field2, &uniqueField, *multiEntryField'
  users: '++id, name, &email, age, createdAt',
  products: '++id, name, price, category, inStock',
  orders: '++id, userId, orderDate, total'
});

// می‌توانید متدهای کمکی اضافه کنید
db.users.addSampleData = async () => {
  const count = await db.users.count();
  if (count === 0) {
    await db.users.bulkAdd([
      { name: 'علی رضایی', email: 'ali@example.com', age: 28, createdAt: new Date() },
      { name: 'سارا محمدی', email: 'sara@example.com', age: 32, createdAt: new Date() },
      { name: 'رضا کریمی', email: 'reza@example.com', age: 25, createdAt: new Date() }
    ]);
  }
};

export default db;

عملیات CRUD با Dexie.js

// src/services/userServiceDexie.js
import db from '../db/dexieDB';

// ایجاد کاربر
export async function addUser(user) {
  try {
    const id = await db.users.add(user);
    return id;
  } catch (error) {
    console.error('خطا در افزودن کاربر:', error);
    throw error;
  }
}

// خواندن همه کاربران
export async function getAllUsers() {
  try {
    const users = await db.users.toArray();
    return users;
  } catch (error) {
    console.error('خطا در خواندن کاربران:', error);
    throw error;
  }
}

// خواندن کاربر با ID
export async function getUserById(id) {
  try {
    const user = await db.users.get(id);
    return user;
  } catch (error) {
    console.error('خطا در یافتن کاربر:', error);
    throw error;
  }
}

// به‌روزرسانی کاربر
export async function updateUser(id, updatedData) {
  try {
    await db.users.update(id, updatedData);
    return true;
  } catch (error) {
    console.error('خطا در به‌روزرسانی:', error);
    throw error;
  }
}

// حذف کاربر
export async function deleteUser(id) {
  try {
    await db.users.delete(id);
    return true;
  } catch (error) {
    console.error('خطا در حذف:', error);
    throw error;
  }
}

// جستجوی پیشرفته
export async function searchUsers(filters) {
  try {
    let collection = db.users;

    if (filters.name) {
      collection = collection.filter(user => 
        user.name.toLowerCase().includes(filters.name.toLowerCase())
      );
    }

    if (filters.minAge) {
      collection = collection.filter(user => user.age >= filters.minAge);
    }

    if (filters.maxAge) {
      collection = collection.filter(user => user.age <= filters.maxAge);
    }

    const results = await collection.toArray();
    return results;
  } catch (error) {
    console.error('خطا در جستجو:', error);
    throw error;
  }
}

// صفحه‌بندی (Pagination)
export async function getUsersPaginated(page = 1, pageSize = 10) {
  try {
    const offset = (page - 1) * pageSize;
    const users = await db.users
      .orderBy('name')
      .offset(offset)
      .limit(pageSize)
      .toArray();

    const totalCount = await db.users.count();

    return {
      data: users,
      total: totalCount,
      page,
      pageSize,
      totalPages: Math.ceil(totalCount / pageSize)
    };
  } catch (error) {
    console.error('خطا در صفحه‌بندی:', error);
    throw error;
  }
}

کامپوننت React با Dexie.js

// src/components/UserManagerDexie.jsx
import React, { useState, useEffect } from 'react';
import { getAllUsers, addUser, deleteUser, searchUsers, getUsersPaginated } from '../services/userServiceDexie';
import db from '../db/dexieDB';

function UserManagerDexie() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [searchName, setSearchName] = useState('');
  const [minAge, setMinAge] = useState('');
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [formData, setFormData] = useState({ name: '', email: '', age: '' });
  const pageSize = 5;

  useEffect(() => {
    // افزودن داده‌های نمونه در اولین بار
    const initDB = async () => {
      await db.users.addSampleData();
      await loadUsersPaginated();
    };
    initDB();
  }, []);

  const loadUsersPaginated = async () => {
    setLoading(true);
    try {
      const result = await getUsersPaginated(page, pageSize);
      setUsers(result.data);
      setTotalPages(result.totalPages);
    } catch (error) {
      console.error('خطا:', error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    loadUsersPaginated();
  }, [page]);

  const handleSearch = async () => {
    setLoading(true);
    try {
      const filters = {};
      if (searchName) filters.name = searchName;
      if (minAge) filters.minAge = parseInt(minAge);

      const results = await searchUsers(filters);
      setUsers(results);
      setTotalPages(1);
    } catch (error) {
      console.error('خطا در جستجو:', error);
    } finally {
      setLoading(false);
    }
  };

  const resetSearch = async () => {
    setSearchName('');
    setMinAge('');
    setPage(1);
    await loadUsersPaginated();
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!formData.name || !formData.email) {
      alert('نام و ایمیل الزامی است');
      return;
    }

    setLoading(true);
    try {
      await addUser({
        name: formData.name,
        email: formData.email,
        age: parseInt(formData.age) || null,
        createdAt: new Date()
      });
      setFormData({ name: '', email: '', age: '' });

      if (searchName || minAge) {
        await handleSearch();
      } else {
        await loadUsersPaginated();
      }
      alert('کاربر با موفقیت اضافه شد');
    } catch (error) {
      alert('خطا: ' + error.message);
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async (id) => {
    if (!window.confirm('آیا از حذف این کاربر مطمئن هستید؟')) return;

    setLoading(true);
    try {
      await deleteUser(id);

      if (searchName || minAge) {
        await handleSearch();
      } else {
        await loadUsersPaginated();
      }
      alert('کاربر حذف شد');
    } catch (error) {
      console.error('خطا:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '900px', margin: '0 auto' }}>
      <h1>📀 **IndexedDB در React** با Dexie.js</h1>

      <form onSubmit={handleSubmit} style={{ marginBottom: '30px', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <h3>➕ افزودن کاربر جدید</h3>
        <input type="text" placeholder="نام" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} style={{ margin: '5px', padding: '8px' }} />
        <input type="email" placeholder="ایمیل" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} style={{ margin: '5px', padding: '8px' }} />
        <input type="number" placeholder="سن" value={formData.age} onChange={(e) => setFormData({ ...formData, age: e.target.value })} style={{ margin: '5px', padding: '8px' }} />
        <button type="submit" disabled={loading} style={{ margin: '5px', padding: '8px 16px' }}>ذخیره</button>
      </form>

      <div style={{ marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px' }}>
        <h3>🔍 جستجوی پیشرفته</h3>
        <input type="text" placeholder="نام" value={searchName} onChange={(e) => setSearchName(e.target.value)} style={{ margin: '5px', padding: '8px' }} />
        <input type="number" placeholder="حداقل سن" value={minAge} onChange={(e) => setMinAge(e.target.value)} style={{ margin: '5px', padding: '8px' }} />
        <button onClick={handleSearch} disabled={loading} style={{ margin: '5px', padding: '8px 16px' }}>جستجو</button>
        <button onClick={resetSearch} disabled={loading} style={{ margin: '5px', padding: '8px 16px' }}>نمایش همه</button>
      </div>

      {loading && <p>در حال بارگذاری...</p>}

      <div>
        <h3>📋 لیست کاربران ({users.length})</h3>
        {users.map(user => (
          <div key={user.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0', borderRadius: '4px' }}>
            <p><strong>نام:</strong> {user.name}</p>
            <p><strong>ایمیل:</strong> {user.email}</p>
            <p><strong>سن:</strong> {user.age || 'نامشخص'}</p>
            <button onClick={() => handleDelete(user.id)} style={{ backgroundColor: '#f44336', color: 'white', border: 'none', padding: '5px 10px', cursor: 'pointer' }}>حذف</button>
          </div>
        ))}
      </div>

      {/* صفحه‌بندی */}
      {!searchName && !minAge && totalPages > 1 && (
        <div style={{ marginTop: '20px', textAlign: 'center' }}>
          <button onClick={() => setPage(p => Math.max(1, p-1))} disabled={page === 1} style={{ margin: '5px', padding: '5px 10px' }}>قبلی</button>
          <span> صفحه {page} از {totalPages} </span>
          <button onClick={() => setPage(p => Math.min(totalPages, p+1))} disabled={page === totalPages} style={{ margin: '5px', padding: '5px 10px' }}>بعدی</button>
        </div>
      )}
    </div>
  );
}

export default UserManagerDexie;

کار با Prisma در Next.js


ساخت برنامه آفلاین (Offline-First) با IndexedDB در React

یکی از قدرتمندترین کاربردهای IndexedDB در React، ساخت برنامه‌هایی است که بدون اتصال به اینترنت نیز کار می‌کنند. بیایید یک برنامه یادداشت‌برداری آفلاین بسازیم.

معماری آفلاین

کاربر <-> React Component <-> IndexedDB (کد آفلاین) <-> سرویس همگام‌سازی <-> سرور

پیاده‌سازی سرویس همگام‌سازی

// src/services/syncService.js
import db from '../db/dexieDB';

export class SyncService {
  constructor(apiUrl) {
    this.apiUrl = apiUrl;
    this.isOnline = navigator.onLine;
    this.syncInProgress = false;

    // شنونده برای تغییر وضعیت آنلاین/آفلاین
    window.addEventListener('online', () => this.handleOnline());
    window.addEventListener('offline', () => this.handleOffline());
  }

  handleOnline() {
    this.isOnline = true;
    console.log('اتصال اینترنت برقرار شد، شروع همگام‌سازی...');
    this.sync();
  }

  handleOffline() {
    this.isOnline = false;
    console.log('برنامه در حالت آفلاین است');
  }

  // ذخیره داده در IndexedDB (همیشه)
  async saveNoteLocally(note) {
    const noteWithStatus = {
      ...note,
      syncStatus: 'pending', // 'pending', 'synced', 'failed'
      updatedAt: new Date().toISOString()
    };

    if (note.id) {
      await db.notes.update(note.id, noteWithStatus);
    } else {
      noteWithStatus.id = await db.notes.add(noteWithStatus);
    }

    // اگر آنلاین هستیم، بلافاصله همگام‌سازی کن
    if (this.isOnline) {
      await this.syncNote(noteWithStatus);
    }

    return noteWithStatus;
  }

  // همگام‌سازی یک یادداشت با سرور
  async syncNote(note) {
    try {
      let response;
      if (note._deleted) {
        response = await fetch(`${this.apiUrl}/notes/${note.id}`, { method: 'DELETE' });
      } else if (note.id && !note._new) {
        response = await fetch(`${this.apiUrl}/notes/${note.id}`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(note)
        });
      } else {
        response = await fetch(`${this.apiUrl}/notes`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(note)
        });
      }

      if (response.ok) {
        await db.notes.update(note.id, { syncStatus: 'synced' });
        console.log(`یادداشت ${note.id} همگام‌سازی شد`);
      } else {
        throw new Error('خطا در همگام‌سازی');
      }
    } catch (error) {
      console.error(`خطا در همگام‌سازی یادداشت ${note.id}:`, error);
      await db.notes.update(note.id, { syncStatus: 'failed' });
    }
  }

  // همگام‌سازی همه داده‌های در انتظار
  async sync() {
    if (!this.isOnline || this.syncInProgress) return;

    this.syncInProgress = true;
    console.log('شروع همگام‌سازی کلی...');

    try {
      // دریافت داده‌های همگام‌سازی نشده
      const pendingNotes = await db.notes
        .filter(note => note.syncStatus === 'pending' || note.syncStatus === 'failed')
        .toArray();

      for (const note of pendingNotes) {
        await this.syncNote(note);
      }

      // دریافت داده‌های جدید از سرور
      await this.pullFromServer();

    } catch (error) {
      console.error('خطا در همگام‌سازی کلی:', error);
    } finally {
      this.syncInProgress = false;
    }
  }

  // دریافت داده‌های جدید از سرور
  async pullFromServer() {
    try {
      const lastSync = localStorage.getItem('lastSync') || new Date(0).toISOString();
      const response = await fetch(`${this.apiUrl}/notes?since=${lastSync}`);
      const serverNotes = await response.json();

      for (const serverNote of serverNotes) {
        const localNote = await db.notes.get(serverNote.id);

        if (!localNote || new Date(serverNote.updatedAt) > new Date(localNote.updatedAt)) {
          await db.notes.put({
            ...serverNote,
            syncStatus: 'synced'
          });
        }
      }

      localStorage.setItem('lastSync', new Date().toISOString());
      console.log(`${serverNotes.length} یادداشت جدید از سرور دریافت شد`);
    } catch (error) {
      console.error('خطا در دریافت از سرور:', error);
    }
  }
}

کامپوننت یادداشت‌ها با پشتیبانی آفلاین

// src/components/OfflineNotes.jsx
import React, { useState, useEffect } from 'react';
import db from '../db/dexieDB';
import { SyncService } from '../services/syncService';

function OfflineNotes() {
  const [notes, setNotes] = useState([]);
  const [loading, setLoading] = useState(false);
  const [syncStatus, setSyncStatus] = useState('online');
  const [formData, setFormData] = useState({ title: '', content: '' });

  // راه‌اندازی دیتابیس و سرویس همگام‌سازی
  useEffect(() => {
    const initDB = async () => {
      // تعریف Object Store برای یادداشت‌ها
      if (!db.tables.some(t => t.name === 'notes')) {
        db.version(2).stores({
          notes: '++id, title, content, syncStatus, updatedAt'
        });
      }

      const syncService = new SyncService('https://api.example.com');

      // بارگذاری یادداشت‌ها
      await loadNotes();

      // همگام‌سازی اولیه
      await syncService.sync();
      await loadNotes();
    };

    initDB();
  }, []);

  const loadNotes = async () => {
    setLoading(true);
    try {
      const allNotes = await db.notes.toArray();
      setNotes(allNotes.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)));
    } catch (error) {
      console.error('خطا در بارگذاری یادداشت‌ها:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!formData.title.trim()) {
      alert('عنوان یادداشت الزامی است');
      return;
    }

    setLoading(true);
    try {
      const syncService = new SyncService('https://api.example.com');
      const newNote = {
        title: formData.title,
        content: formData.content,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString()
      };

      await syncService.saveNoteLocally(newNote);
      setFormData({ title: '', content: '' });
      await loadNotes();
      alert('یادداشت ذخیره شد (در حالت آفلاین نیز قابل دسترسی است)');
    } catch (error) {
      console.error('خطا:', error);
      alert('خطا در ذخیره یادداشت');
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async (id) => {
    if (!window.confirm('آیا از حذف این یادداشت مطمئن هستید؟')) return;

    setLoading(true);
    try {
      await db.notes.delete(id);
      await loadNotes();
      alert('یادداشت حذف شد');
    } catch (error) {
      console.error('خطا:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>📝 برنامه یادداشت‌ها با **IndexedDB در React** (آفلاین)</h1>

      <div style={{ marginBottom: '20px', padding: '10px', backgroundColor: navigator.onLine ? '#e8f5e9' : '#ffebee', borderRadius: '8px' }}>
        وضعیت: {navigator.onLine ? '🟢 آنلاین (همگام‌سازی فعال)' : '🔴 آفلاین (داده‌ها محلی ذخیره می‌شوند)'}
      </div>

      <form onSubmit={handleSubmit} style={{ marginBottom: '30px', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <input
          type="text"
          placeholder="عنوان یادداشت"
          value={formData.title}
          onChange={(e) => setFormData({ ...formData, title: e.target.value })}
          style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
        />
        <textarea
          placeholder="متن یادداشت..."
          value={formData.content}
          onChange={(e) => setFormData({ ...formData, content: e.target.value })}
          rows="4"
          style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
        />
        <button type="submit" disabled={loading} style={{ padding: '8px 16px' }}>
          {loading ? 'در حال ذخیره...' : '💾 ذخیره یادداشت'}
        </button>
      </form>

      <h3>📋 یادداشت‌های من ({notes.length})</h3>
      {loading && <p>در حال بارگذاری...</p>}

      {notes.map(note => (
        <div key={note.id} style={{ border: '1px solid #ddd', padding: '15px', margin: '10px 0', borderRadius: '8px' }}>
          <h4>{note.title}</h4>
          <p>{note.content}</p>
          <small>
            آخرین ویرایش: {new Date(note.updatedAt).toLocaleString('fa-IR')}
            {note.syncStatus === 'pending' && <span style={{ color: 'orange', marginLeft: '10px' }}>⏳ در انتظار همگام‌سازی</span>}
            {note.syncStatus === 'failed' && <span style={{ color: 'red', marginLeft: '10px' }}>❌ خطا در همگام‌سازی</span>}
            {note.syncStatus === 'synced' && <span style={{ color: 'green', marginLeft: '10px' }}>✅ همگام‌سازی شده</span>}
          </small>
          <div style={{ marginTop: '10px' }}>
            <button onClick={() => handleDelete(note.id)} style={{ backgroundColor: '#f44336', color: 'white', border: 'none', padding: '5px 10px', cursor: 'pointer' }}>
              🗑️ حذف
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

export default OfflineNotes;

Redis Caching Patterns


ذخیره فایل‌ها و تصاویر در IndexedDB با React

IndexedDB در React می‌تواند فایل‌های باینری (مانند تصاویر) را نیز ذخیره کند. این قابلیت برای برنامه‌های گالری عکس آفلاین بسیار مفید است.

// src/components/ImageGallery.jsx
import React, { useState, useEffect } from 'react';
import db from '../db/dexieDB';

function ImageGallery() {
  const [images, setImages] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const initDB = async () => {
      if (!db.tables.some(t => t.name === 'images')) {
        db.version(3).stores({
          images: '++id, name, type, size, uploadedAt'
        });
      }
      await loadImages();
    };
    initDB();
  }, []);

  const loadImages = async () => {
    setLoading(true);
    try {
      const allImages = await db.images.toArray();
      // تبدیل blob به URL برای نمایش
      const imagesWithUrls = allImages.map(img => ({
        ...img,
        url: URL.createObjectURL(img.data)
      }));
      setImages(imagesWithUrls);
    } catch (error) {
      console.error('خطا:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleFileUpload = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    setLoading(true);
    try {
      const reader = new FileReader();
      reader.onload = async (e) => {
        const imageData = {
          name: file.name,
          type: file.type,
          size: file.size,
          data: e.target.result,
          uploadedAt: new Date().toISOString()
        };

        await db.images.add(imageData);
        await loadImages();
        alert('تصویر با موفقیت در IndexedDB ذخیره شد');
      };
      reader.readAsArrayBuffer(file);
    } catch (error) {
      console.error('خطا در آپلود:', error);
      alert('خطا در ذخیره تصویر');
    } finally {
      setLoading(false);
    }
  };

  const handleDeleteImage = async (id) => {
    if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;

    setLoading(true);
    try {
      await db.images.delete(id);
      await loadImages();
      alert('تصویر حذف شد');
    } catch (error) {
      console.error('خطا:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h1>🖼️ گالری تصاویر آفلاین با **IndexedDB در React**</h1>

      <input type="file" accept="image/*" onChange={handleFileUpload} disabled={loading} />

      {loading && <p>در حال بارگذاری...</p>}

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '20px', marginTop: '20px' }}>
        {images.map(image => (
          <div key={image.id} style={{ border: '1px solid #ccc', padding: '10px', borderRadius: '8px' }}>
            <img src={image.url} alt={image.name} style={{ width: '100%', height: '150px', objectFit: 'cover' }} />
            <p><strong>{image.name}</strong></p>
            <p>{(image.size / 1024).toFixed(2)} KB</p>
            <button onClick={() => handleDeleteImage(image.id)} style={{ backgroundColor: '#f44336', color: 'white', border: 'none', padding: '5px 10px', cursor: 'pointer' }}>
              حذف
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

export default ImageGallery;

CSS :where() و :is() Selectors

IndexedDB در React

بهینه‌سازی عملکرد IndexedDB در React

برای رسیدن به حداکثر کارایی IndexedDB در React، نکات زیر را رعایت کنید:

۱. استفاده از Bulk Operations

به جای اضافه کردن هزاران رکورد یکی یکی، از bulkAdd و bulkPut استفاده کنید:

// ❌ بد - حلقه و add جداگانه
for (const item of items) {
  await db.users.add(item);
}

// ✅ خوب - bulkAdd
await db.users.bulkAdd(items);

۲. محدود کردن داده‌های برگشتی

هرگز getAll() را بدون محدودیت روی Object Storeهای بزرگ صدا نزنید. از limit و offset استفاده کنید:

// ❌ بد - بارگذاری همه 100,000 رکورد
const all = await db.users.toArray();

// ✅ خوب - صفحه‌بندی
const page = await db.users.offset(0).limit(50).toArray();

۳. استفاده از ایندکس‌های مناسب

همیشه روی فیلدهایی که مرتباً بر اساس آنها جستجو یا مرتب‌سازی می‌کنید، ایندکس ایجاد کنید:

db.version(1).stores({
  users: '++id, name, &email, age, *tags'  // *tags برای آرایه‌ها
});

۴. بستن Connection‌های بلااستفاده

هر چند Dexie.js این کار را خودکار می‌کند، اما در API خام حتماً دیتابیس را ببندید:

const db = await openDB();
// ... عملیات
db.close(); // فراموش نکنید!

۵. استفاده از Web Workers برای عملیات سنگین

برای عملیات بسیار سنگین (مانند واردات CSV با 100,000 رکورد)، از Web Worker استفاده کنید تا UI قفل نشود.

Storybook برای React


عیب‌یابی مشکلات رایج IndexedDB در React

مشکل ۱: خطای “QuotaExceededError”

علت: فضای ذخیره‌سازی مرورگر پر شده است.

راه‌حل:

  • داده‌های قدیمی را پاک کنید.
  • از کاربر درخواست فضای بیشتر کنید (با navigator.storage.persist()).
  • از فشرده‌سازی (compression) داده‌ها استفاده کنید.

مشکل ۲: از دست رفتن داده‌ها پس از رفرش صفحه

علت: مرورگر ممکن است داده‌های IndexedDB را در شرایط کمبود فضا پاک کند.

راه‌حل: درخواست فضای ذخیره‌سازی پایدار (Persistent Storage):

if (navigator.storage && navigator.storage.persist) {
  const isPersisted = await navigator.storage.persist();
  console.log(`فضای ذخیره‌سازی پایدار: ${isPersisted}`);
}

مشکل ۳: خطای “DataError” هنگام ذخیره شیء

علت: وجود داده‌های غیرقابل شبیه‌سازی (non-cloneable) مانند توابع، DOM elements، یا ارجاعات چرخه‌ای.

راه‌حل: قبل از ذخیره، اشیاء را پاکسازی کنید (با JSON.parse(JSON.stringify(obj))).

مشکل ۴: کندی شدید در حین عملیات حجیم

علت: عدم استفاده از تراکنش‌های بهینه.

راه‌حل: عملیات مرتبط را در یک تراکنش گروه‌بندی کنید:

await db.transaction('rw', db.users, async () => {
  await db.users.bulkAdd(items1);
  await db.users.bulkAdd(items2);
  // یک تراکنش واحد
});

Kubernetes Service Mesh


بهترین شیوه‌های IndexedDB در React

بر اساس تجربیات تیم دانا پدیا، رعایت این نکات شما را در IndexedDB در React به یک حرفه‌ای تبدیل می‌کند:

۱. همیشه از Dexie.js استفاده کنید

API خام IndexedDB بسیار پیچیده و مستعد خطا است. Dexie.js کد شما را ۵-۱۰ برابر خواناتر و قابل نگهداری‌تر می‌کند.

۲. از Context API برای دسترسی سراسری به دیتابیس استفاده کنید

const DBContext = React.createContext(null);

function App() {
  return (
    <DBContext.Provider value={db}>
      <UserManager />
    </DBContext.Provider>
  );
}

۳. داده‌های حساس را رمزگذاری کنید

IndexedDB رمزگذاری داخلی ندارد. برای داده‌های حساس (مانند توکن‌ها)، قبل از ذخیره رمزگذاری کنید.

۴. از نسخه‌گذاری (Versioning) برای مدیریت مهاجرت داده‌ها استفاده کنید

db.version(1).stores({ users: '++id' });
db.version(2).stores({ users: '++id, name, email' });
db.version(3).stores({ users: '++id, name, email, age' });

۵. لاگ و مانیتورینگ

عملیات مهم را لاگ کنید تا در صورت بروز مشکل، بتوانید دیباگ کنید.

پایتون Type Hints


نتیجه‌گیری نهایی از دیدگاه دانا پدیا

در این مقاله جامع از دانا پدیا، ما به طور کامل IndexedDB در React را از جنبه‌های مختلف بررسی کردیم. IndexedDB در React به شما امکان می‌دهد:

  • برنامه‌های آفلاین (Offline-First) با تجربه کاربری روان بسازید.
  • داده‌های حجیم (تا صدها مگابایت) را در سمت کاربر ذخیره کنید.
  • عملکرد برنامه را با کش کردن داده‌ها بهبود بخشید.
  • فایل‌ها و تصاویر را مستقیماً در مرورگر ذخیره کنید.
  • state برنامه را حتی پس از رفرش صفحه حفظ کنید.

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

امیدواریم این مقاله از دانا پدیا برای شما مفید بوده باشد. اگر تجربه یا سؤالی درباره IndexedDB در React دارید، در بخش نظرات با ما در میان بگذارید.

فرق بین SQL و PL/SQL


سوالات متداول (FAQ)

۱. تفاوت IndexedDB با localStorage چیست؟

IndexedDB می‌تواند حجم بسیار بیشتری (تا ۱ گیگابایت) را ذخیره کند، از داده‌های پیچیده (اشیاء، فایل‌ها) پشتیبانی می‌کند، ایندکس دارد و غیرهمزمان است. localStorage فقط رشته‌های ساده (تا ۱۰ مگابایت) را ذخیره می‌کند، همزمان است و ایندکس ندارد.

۲. آیا IndexedDB در همه مرورگرها پشتیبانی می‌شود؟

بله، تمام مرورگرهای مدرن از IndexedDB پشتیبانی می‌کنند (Chrome، Firefox، Safari، Edge). تنها مرورگری که پشتیبانی نمی‌کند، Internet Explorer است.

۳. آیا می‌توان از IndexedDB در React Native استفاده کرد؟

خیر، React Native از IndexedDB پشتیبانی نمی‌کند. در React Native باید از AsyncStorage یا SQLite استفاده کنید.

۴. بهترین کتابخانه برای کار با IndexedDB در React چیست؟

Dexie.js محبوب‌ترین و بهترین گزینه است. کتابخانه‌های دیگر عبارتند از: idb (سبک‌تر)، localForage (API ساده‌تر)، و ZangoDB (شبیه MongoDB).

۵. آیا IndexedDB امن است؟

داده‌های IndexedDB در مرورگر کاربر ذخیره می‌شوند و فقط همان دامنه (origin) به آنها دسترسی دارد. اما رمزگذاری داخلی ندارد. برای داده‌های حساس، حتماً قبل از ذخیره رمزگذاری کنید.

۶. چگونه می‌توانم فضای استفاده شده توسط IndexedDB را ببینم؟

در Chrome DevTools > Application > IndexedDB، می‌توانید دیتابیس‌ها و حجم آنها را مشاهده کنید. همچنین با navigator.storage.estimate() می‌توانید میزان استفاده را در کد بدست آورید.

۷. آیا IndexedDB می‌تواند جایگزین backend شود؟

خیر. IndexedDB فقط در مرورگر کاربر ذخیره می‌شود و بین دستگاه‌ها همگام نمی‌شود. برای ذخیره‌سازی امن و متمرکز، هنوز به backend نیاز دارید. IndexedDB مکمل backend است، نه جایگزین.

۸. چگونه IndexedDB را در برنامه React تست کنم؟

می‌توانید از fake-indexeddb (یک پیاده‌سازی درون‌حافظه برای Node.js) برای تست واحد استفاده کنید. همچنین می‌توانید با Jest و React Testing Library کامپوننت‌های خود را تست کنید.

Node.js Memory Leak Detection


دیدگاهتان را بنویسید