لماذا تحتاج تطبيقاتك للعمل بدون إنترنت؟
تخيّل إنك تستخدم تطبيق ملاحظات في الطائرة، أو تطبيق مخزون في مستودع ما توصله شبكة محترمة. إذا كان التطبيق يعتمد كلياً على الخادم، فأنت أمام شاشة تحميل لا نهاية لها — وهذا الموقف محبط بشكل لا يوصف. هذا بالضبط ما تحلّه بنية Offline-First، حيث تُعامَل قاعدة البيانات المحلية على أنها مصدر الحقيقة، والشبكة مجرد تحسين إضافي وليست شرطاً أساسياً.
في هذا الدليل، سنبني تطبيق React Native كاملاً يعمل بدون إنترنت باستخدام expo-sqlite كقاعدة بيانات محلية وDrizzle ORM للحصول على استعلامات آمنة النوع (Type-Safe). سنغطّي كل شيء من الإعداد إلى عمليات CRUD إلى المزامنة مع الخادم — بأمثلة كود تعمل فعلاً.
ما هو expo-sqlite ولماذا نختاره؟
expo-sqlite هو المكتبة الرسمية من Expo للتعامل مع قواعد بيانات SQLite محلياً. يوفّر واجهة برمجية حديثة غير متزامنة (Async API) تدعم:
- استعلامات SQL كاملة بدون قيود
- معاملات حصرية (Exclusive Transactions) لضمان سلامة البيانات
- استعلامات تفاعلية (Live Queries) تُحدّث الواجهة تلقائياً عند تغيّر البيانات
- تشفير قاعدة البيانات عبر SQLCipher
- مخزن قيم مفتاحية (Key-Value Store) كبديل مباشر لـ AsyncStorage
- أداء عالٍ بفضل JSI — بدون جسر React Native القديم
طيب، مقارنةً بالبدائل: MMKV أسرع لكنه مخزن مفتاحي فقط ولا يدعم استعلامات SQL. WatermelonDB ممتاز للتطبيقات التفاعلية الكبيرة لكنه أعقد في الإعداد وصراحةً يحتاج وقتاً لاستيعابه. expo-sqlite يقدّم التوازن الأمثل بين القوة والبساطة، خصوصاً مع Expo SDK 55.
ما هو Drizzle ORM ولماذا نستخدمه؟
Drizzle ORM هو ORM خفيف الوزن مكتوب بـ TypeScript، يعمل على Node.js و Bun و Deno و React Native. من تجربتي الشخصية، أول مرة جرّبته توقّعت إنه سيكون معقداً مثل Prisma أو TypeORM، لكنه فاجأني بمدى بساطته. ما يميّزه:
- أمان النوع الكامل: كل استعلام تكتبه يُفحَص وقت الترجمة — لو أخطأت في اسم عمود، TypeScript يُنبّهك فوراً
- بدون سحر: الاستعلامات تبدو مثل SQL الحقيقي، لا تتعلّم DSL جديدة
- Live Queries مدمجة: منذ الإصدار 0.31.1 يدعم useLiveQuery مع expo-sqlite
- Drizzle Kit: أداة CLI لتوليد الترحيلات (Migrations) تلقائياً من المخطط
- Drizzle Studio: أداة تصحيح أخطاء مرئية تعمل مباشرة من Expo CLI
إعداد المشروع خطوة بخطوة
الخطوة 1: إنشاء مشروع Expo جديد
إذا لم يكن لديك مشروع بعد، ابدأ من هنا:
npx create-expo-app@latest my-offline-app --template blank-typescript
cd my-offline-app
الخطوة 2: تثبيت الحزم المطلوبة
npx expo install expo-sqlite
npm install drizzle-orm
npm install -D drizzle-kit babel-plugin-inline-import
الخطوة 3: إعداد Metro و Babel
Drizzle Kit يُولّد ملفات ترحيل بصيغة .sql، و Metro لا يتعرّف عليها افتراضياً. يعني لازم نصلح هذا قبل ما نكمل.
أولاً، ملف metro.config.js:
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql");
module.exports = config;
ثانياً، حدّث ملف babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [["inline-import", { extensions: [".sql"] }]],
};
};
الخطوة 4: إعداد Drizzle Kit
أنشئ ملف drizzle.config.ts في جذر المشروع:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
driver: "expo",
});
الخيار driver: "expo" ضروري جداً — بدونه لن تعمل الترحيلات بشكل صحيح. لا تنساه.
تعريف مخطط قاعدة البيانات
لنبنِ تطبيق ملاحظات بسيط يدعم المجلدات. أنشئ ملف db/schema.ts:
import { int, sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const folders = sqliteTable("folders", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
color: text().default("#3b82f6"),
createdAt: text("created_at").notNull().default(""),
});
export const notes = sqliteTable("notes", {
id: int().primaryKey({ autoIncrement: true }),
title: text().notNull(),
content: text().default(""),
folderId: int("folder_id").references(() => folders.id, {
onDelete: "cascade",
}),
isSynced: integer("is_synced").default(0),
updatedAt: text("updated_at").notNull().default(""),
createdAt: text("created_at").notNull().default(""),
});
// أنواع TypeScript مُستنتَجة تلقائياً من المخطط
export type Folder = typeof folders.$inferSelect;
export type NewFolder = typeof folders.$inferInsert;
export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
لاحظ العمود isSynced — هذا هو قلب بنية Offline-First في مشروعنا. كل سجل يبدأ بقيمة 0 (غير مُزامَن)، وعند المزامنة الناجحة مع الخادم يتحوّل إلى 1. بسيط لكنه فعّال.
توليد الترحيلات وتطبيقها
توليد ملفات الترحيل
npx drizzle-kit generate
سيُنشئ هذا الأمر ملفات SQL داخل مجلد /drizzle تحتوي على أوامر CREATE TABLE.
تطبيق الترحيلات عند بدء التطبيق
أنشئ ملف db/client.ts لتهيئة الاتصال:
import { drizzle } from "drizzle-orm/expo-sqlite";
import { openDatabaseSync } from "expo-sqlite";
import * as schema from "./schema";
const expoDb = openDatabaseSync("notes.db", {
enableChangeListener: true,
});
export const db = drizzle(expoDb, { schema });
الخيار enableChangeListener: true ضروري لتفعيل Live Queries — بدونه لن يعمل useLiveQuery. كثير من الناس ينسون هذا السطر ثم يتساءلون لماذا لا تتحدّث الواجهة!
ثم في نقطة الدخول الرئيسية للتطبيق:
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import migrations from "./drizzle/migrations";
import { db } from "./db/client";
import { Text, View, ActivityIndicator } from "react-native";
export default function App() {
const { success, error } = useMigrations(db, migrations);
if (error) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>خطأ في ترحيل قاعدة البيانات: {error.message}</Text>
</View>
);
}
if (!success) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
<Text>جارٍ تهيئة قاعدة البيانات...</Text>
</View>
);
}
return <MainApp />;
}
الـ useMigrations يعمل بشكل متزامن — التطبيق لن يُعرَض حتى تكتمل الترحيلات. هذا يمنع أي حالة سباق (Race Condition) حيث تحاول المكوّنات الاستعلام قبل إنشاء الجداول. خلينا نكون واقعيين، هذا النوع من الأخطاء صعب اكتشافه لاحقاً.
عمليات CRUD بأمان النوع الكامل
إنشاء سجلات جديدة (Create)
import { db } from "./db/client";
import { folders, notes, NewFolder, NewNote } from "./db/schema";
// إنشاء مجلد
async function createFolder(name: string, color?: string): Promise<Folder> {
const [folder] = await db
.insert(folders)
.values({
name,
color: color ?? "#3b82f6",
createdAt: new Date().toISOString(),
})
.returning();
return folder;
}
// إنشاء ملاحظة
async function createNote(
title: string,
content: string,
folderId: number
): Promise<Note> {
const now = new Date().toISOString();
const [note] = await db
.insert(notes)
.values({
title,
content,
folderId,
isSynced: 0,
createdAt: now,
updatedAt: now,
})
.returning();
return note;
}
قراءة البيانات مع Live Queries (Read)
import { useLiveQuery } from "drizzle-orm/expo-sqlite";
import { db } from "./db/client";
import { notes, folders } from "./db/schema";
import { eq } from "drizzle-orm";
function NotesList({ folderId }: { folderId: number }) {
// هذا الاستعلام يُعاد تنفيذه تلقائياً عند أي تغيير
const { data: folderNotes } = useLiveQuery(
db.select().from(notes).where(eq(notes.folderId, folderId))
);
return (
<FlatList
data={folderNotes}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View>
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
<Text>{item.content}</Text>
<Text style={{ color: item.isSynced ? "green" : "orange" }}>
{item.isSynced ? "مُزامَن" : "بانتظار المزامنة"}
</Text>
</View>
)}
/>
);
}
الفرق الجوهري هنا: مع useLiveQuery، لا تحتاج لإعادة جلب البيانات يدوياً بعد كل عملية إدراج أو تحديث. الواجهة تتحدّث تلقائياً. هذا يُبسّط الكود بشكل كبير مقارنةً بالطرق التقليدية — وصدقني، بعد ما تجرّبه ما راح ترجع للطريقة القديمة.
تحديث البيانات (Update)
import { eq } from "drizzle-orm";
async function updateNote(
id: number,
title: string,
content: string
): Promise<void> {
await db
.update(notes)
.set({
title,
content,
isSynced: 0, // أعد تعيينه — البيانات تغيّرت ولم تُزامَن بعد
updatedAt: new Date().toISOString(),
})
.where(eq(notes.id, id));
}
حذف البيانات (Delete)
async function deleteNote(id: number): Promise<void> {
await db.delete(notes).where(eq(notes.id, id));
}
async function deleteFolder(id: number): Promise<void> {
// بفضل onDelete: "cascade" في المخطط،
// ستُحذف كل الملاحظات داخل المجلد تلقائياً
await db.delete(folders).where(eq(folders.id, id));
}
بناء طبقة المزامنة مع الخادم
هذا هو الجزء الأهم في أي تطبيق Offline-First — وأيضاً الجزء الذي يغلط فيه كثيرون. الفكرة الأساسية بسيطة: اكتب محلياً أولاً، ثم زامن مع الخادم عند توفّر الاتصال.
مراقبة حالة الشبكة
npx expo install @react-native-community/netinfo
import NetInfo from "@react-native-community/netinfo";
import { useEffect, useState } from "react";
function useNetworkStatus() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(state.isConnected ?? false);
});
return () => unsubscribe();
}, []);
return isConnected;
}
خدمة المزامنة
import { db } from "./db/client";
import { notes } from "./db/schema";
import { eq } from "drizzle-orm";
async function syncUnsyncedNotes(): Promise<{
synced: number;
failed: number;
}> {
// 1. اجلب كل الملاحظات غير المُزامنة
const unsyncedNotes = await db
.select()
.from(notes)
.where(eq(notes.isSynced, 0));
let synced = 0;
let failed = 0;
// 2. أرسلها للخادم على دفعات
for (const note of unsyncedNotes) {
try {
const response = await fetch("https://api.example.com/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: note.title,
content: note.content,
clientId: note.id,
updatedAt: note.updatedAt,
}),
});
if (response.ok) {
// 3. عند النجاح، حدّث حالة المزامنة محلياً
await db
.update(notes)
.set({ isSynced: 1 })
.where(eq(notes.id, note.id));
synced++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { synced, failed };
}
المزامنة التلقائية عند عودة الاتصال
import { useEffect, useRef } from "react";
import NetInfo from "@react-native-community/netinfo";
function useSyncOnReconnect() {
const wasPreviouslyOffline = useRef(false);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(async (state) => {
const isOnline = state.isConnected && state.isInternetReachable;
if (isOnline && wasPreviouslyOffline.current) {
console.log("الاتصال عاد — بدء المزامنة...");
const result = await syncUnsyncedNotes();
console.log(`تمت مزامنة ${result.synced} ملاحظة`);
}
wasPreviouslyOffline.current = !isOnline;
});
return () => unsubscribe();
}, []);
}
هذا النمط يضمن أن أي بيانات كُتبت أثناء انقطاع الاتصال تُرسَل للخادم فور عودة الشبكة — بدون تدخّل من المستخدم. تجربة المستخدم تكون سلسة تماماً، وهذا هو الهدف.
تحسين الأداء: نصائح عملية
قاعدة البيانات المحلية سريعة بطبيعتها، لكن هناك أشياء يمكنك فعلها لجعلها أسرع — وبعضها يحدث فرقاً ملحوظاً جداً.
تفعيل وضع WAL
وضع Write-Ahead Logging يسمح بالقراءة والكتابة المتزامنة، وهو أفضل بكثير من الوضع الافتراضي للتطبيقات المحمولة:
// في ملف db/client.ts بعد فتح قاعدة البيانات
expoDb.execSync("PRAGMA journal_mode = WAL;");
expoDb.execSync("PRAGMA foreign_keys = ON;");
استخدام المعاملات للعمليات الجماعية
import { expoDb } from "./db/client";
// بدلاً من 100 عملية إدراج منفصلة
async function bulkInsertNotes(newNotes: NewNote[]) {
await expoDb.withExclusiveTransactionAsync(async (txn) => {
for (const note of newNotes) {
await txn.runAsync(
"INSERT INTO notes (title, content, folder_id, is_synced, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
[note.title, note.content ?? "", note.folderId ?? null, note.createdAt, note.updatedAt]
);
}
});
}
المعاملة الحصرية (Exclusive Transaction) تضمن أن كل العمليات تنجح معاً أو تفشل معاً — وهي أسرع بعشرات المرات من الإدراج المنفرد. بصراحة، الفرق في التوقيت واضح حتى بالعين المجردة عند إدراج كميات كبيرة من البيانات.
إضافة فهارس للأعمدة المستعلَم عنها كثيراً
أضف فهارس في مخطط Drizzle مباشرة:
import { index } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable(
"notes",
{
id: int().primaryKey({ autoIncrement: true }),
title: text().notNull(),
content: text().default(""),
folderId: int("folder_id").references(() => folders.id, {
onDelete: "cascade",
}),
isSynced: integer("is_synced").default(0),
updatedAt: text("updated_at").notNull().default(""),
createdAt: text("created_at").notNull().default(""),
},
(table) => [
index("idx_notes_folder").on(table.folderId),
index("idx_notes_synced").on(table.isSynced),
]
);
تصحيح الأخطاء باستخدام Drizzle Studio
واحدة من أفضل ميزات هذا المزيج — وأنا جاد — هي القدرة على فحص قاعدة البيانات مرئياً أثناء التطوير:
- شغّل تطبيقك بالأمر
npx expo start - اضغط
Shift + Mفي الطرفية - اختر expo-drizzle-studio-plugin
- ستُفتح نافذة متصفح تعرض Drizzle Studio متصلاً بقاعدة بياناتك المحلية
من هنا يمكنك تصفّح الجداول، إضافة سجلات، تعديلها، حذفها، وتنفيذ استعلامات SQL مباشرة. هذا يوفّر وقتاً هائلاً مقارنةً بطباعة البيانات في الطرفية — لا أعرف كيف كنت أعمل بدونه قبل.
تشفير قاعدة البيانات باستخدام SQLCipher
إذا كان تطبيقك يتعامل مع بيانات حساسة، يمكنك تشفير قاعدة البيانات بالكامل:
// في app.json أو app.config.js
{
"expo": {
"plugins": [
[
"expo-sqlite",
{
"useSQLCipher": true
}
]
]
}
}
ثم عند فتح قاعدة البيانات:
const expoDb = openDatabaseSync("notes.db");
expoDb.execSync("PRAGMA key = 'مفتاح_التشفير_السري'");
// الآن كل القراءات والكتابات مُشفّرة
بعد تفعيل SQLCipher، أعد بناء التطبيق بالأمر npx expo prebuild لأن هذا التغيير يتطلب بناء ثنائي جديد. لا تتجاهل هذه الخطوة.
بنية المشروع الموصى بها
لتنظيم مشروع Offline-First بشكل نظيف، إليك هيكل المجلدات الذي أنصح به (وجرّبته في أكثر من مشروع):
my-offline-app/
├── db/
│ ├── schema.ts # مخطط Drizzle (الجداول والعلاقات)
│ ├── client.ts # تهيئة الاتصال بقاعدة البيانات
│ └── operations.ts # دوال CRUD القابلة لإعادة الاستخدام
├── drizzle/
│ ├── 0000_create_tables.sql
│ └── migrations.js # ملف مُولّد تلقائياً
├── hooks/
│ ├── useNetworkStatus.ts
│ └── useSyncOnReconnect.ts
├── services/
│ └── syncService.ts # منطق المزامنة مع الخادم
├── drizzle.config.ts
├── metro.config.js
└── babel.config.js
الفكرة: افصل منطق قاعدة البيانات عن منطق الواجهة. هذا يسهّل الاختبار والصيانة ويمنعك من تكرار الكود — وهو شيء ستشكر نفسك عليه بعد ستة أشهر عندما تعود للمشروع.
الأسئلة الشائعة
هل يمكنني استخدام expo-sqlite مع Expo Go؟
نعم، expo-sqlite مدعومة بالكامل في Expo Go. لكن إذا فعّلت SQLCipher أو استخدمت إضافات مخصصة، فستحتاج لبناء التطبيق باستخدام npx expo prebuild أو EAS Build لأن هذه الميزات تتطلب كود أصلي مخصص.
ما الفرق بين expo-sqlite و react-native-quick-sqlite و op-sqlite؟
كلها تستخدم SQLite تحت الغطاء، لكن: expo-sqlite هي المكتبة الرسمية من Expo وتتكامل مع نظام Expo البيئي بسلاسة. op-sqlite تركّز على الأداء الأقصى عبر JSI وتدعم ميزات متقدمة. react-native-quick-sqlite خيار أقدم. للمشاريع الجديدة مع Expo، ابدأ بـ expo-sqlite — ستغطّي احتياجات معظم التطبيقات. هل تحتاج للتبديل لاحقاً؟ ممكن، لكن في الغالب لن تحتاج.
كيف أتعامل مع تعارضات البيانات عند المزامنة؟
أبسط استراتيجية هي Last-Write-Wins (آخر كتابة تفوز) حيث تقارن الطوابع الزمنية. للتطبيقات التعاونية، استخدم CRDTs أو استراتيجيات دمج مخصصة. المفتاح: خزّن updatedAt في كل سجل محلياً وعلى الخادم، وقارن عند المزامنة.
هل Drizzle ORM يبطّئ التطبيق مقارنةً باستعلامات SQL المباشرة؟
بصراحة، الفرق في الأداء مهمل تقريباً. Drizzle يولّد استعلامات SQL محسّنة ويضيف طبقة رقيقة جداً فوق expo-sqlite. المكاسب في أمان النوع وسهولة الصيانة تفوق بكثير أي فرق أداء نظري — خصوصاً أن معظم عمليات قاعدة البيانات المحلية تستغرق أقل من ميلي ثانية.
هل يمكنني استخدام هذا النهج مع React Native بدون Expo؟
expo-sqlite مصمم لـ Expo. إذا كنت تستخدم React Native بدون Expo (Bare Workflow)، فالبديل الأنسب هو op-sqlite أو react-native-quick-sqlite مع Drizzle ORM. Drizzle يدعم كلتا المكتبتين عبر محركات مخصصة.