
Как я построил местные первые приложения с React Native + RXDB (и почему ваше приложение, вероятно, тоже нуждается в этом)
11 августа 2025 г.Вы знаете в тот момент, когда вы находитесь в глуши, ваш 4G превращается в «E», и ваше мобильное приложение решает стать очень дорогим пресс -папье? Ага. Именно поэтому я написал это.
Представьте себе драйвер доставки, смотрящий на загрузочный прядильщик, потому что приложение не может загрузить свой маршрут без Интернета. Или склад -менеджер, не может получить список инвентаризации, потому что сервер падает. Это не «причудливое поведение», это потерянное время, потерял деньги и очень злых пользователей.
Куча наших клиентов попадает в эти выбоины - удаленные сайты работы, события вне места, складские полы, логистические центры. По сути, везде, где Wi-Fi умирает. Исправление? Локальные приложения: обрабатывайте данные локально, синхронизируйте их, когда можете. Ваши пользователи остаются счастливыми, и вы не получаете полуночные звонки «приложение сломано».
В этом маленьком приключении я покажу вам, как я построил местное мобильное приложение сОтреагировать Native + RXDBПолем Вы увидите:
- Как двухсторонняя синхронизация на самом деле работает без плавления вашего сервера.
- Как справиться с «веселыми» ситуациями, такими как конфликты данных.
- Чтонетзабыть при разработке одной из этих зверей.
Кроме того - я поделюсь одним трюком «Последнее обновление побед». Не всегда правильный выбор, но в нашем случае ... поцелуй шеф -повара.
Стек (a.k.a. мое выбранное оружие)
Для этой сборки я катался с:
- Реагировать на родной-Кроссплатформенная магия: одна кодовая база, iOS + Android.
- Реактивно-н-нитро-квлит- Потому что местное хранение без SQLite похоже на пиццу без сыра.
- Rxdb-Оффлайт-первый, реактивный дБ, который хорошо играет с синхронизацией.
- Nestjs + typeorm + postgresql- Бэкэндская команда мечты.
Конечный результат: приложение работает в автономном режиме, синхронизируется позже и переживает хитрые соединения, не имея распада.
Шаг 1 - локальное хранилище через SQLite
Во -первых, мне нужна была местная база данных, которую RXDB мог бы с радостью злоупотреблять. SQLite - это очевидный выбор, но я обернул его некоторыми дополнительными способностями - проверкой и шифрованием - потому что целостность данных и конфиденциальность имеет значение (а также потому, что мое будущее я поблагодарит меня).
//storage.ts
import {
getRxStorageSQLiteTrial,
getSQLiteBasicsQuickSQLite,
} from 'rxdb/plugins/storage-sqlite';
import { open } from 'react-native-nitro-sqlite';
import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js';
const sqliteBasics = getSQLiteBasicsQuickSQLite(open);
const storage = getRxStorageSQLiteTrial({ sqliteBasics });
const validatedStorage = wrappedValidateAjvStorage({ storage });
const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({
storage: validatedStorage,
});
export { encryptedStorage };
Да, это три слоя упаковки. Как лук. Или предприятие с слишком большим количеством промежуточных слоев.
Шаг 2 - Создание экземпляра базы данных
Далее я построилRxDatabaseManager
Синглтон. Потому что, если вы думаете, что наличие нескольких экземпляров вашего БД - хорошая идея ... Вы, вероятно, также наслаждаетесь конфликтами слияния в производстве.
Вот класс во всей его славе:
//Instance.ts
import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb';
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
import { replicateRxCollection } from 'rxdb/plugins/replication';
import NetInfo from '@react-native-community/netinfo';
import {
CheckPointType,
MyDatabaseCollections,
ReplicateCollectionDto,
} from './types.ts';
import { encryptedStorage } from './storage.ts';
import { defaultConflictHandler } from './utills.ts';
import { usersApi, userSchema, UserType } from '../features/users';
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder';
import { RxDBUpdatePlugin } from 'rxdb/plugins/update';
// for support query.update method
addRxPlugin(RxDBUpdatePlugin);
// for support chained query methods
addRxPlugin(RxDBQueryBuilderPlugin);
// for enabling data migration
addRxPlugin(RxDBMigrationSchemaPlugin);
export class RxDatabaseManager {
private static instance: RxDatabaseManager;
private db: RxDatabase<MyDatabaseCollections> | null = null;
private isOnline = false;
private constructor() {}
public static getInstance(): RxDatabaseManager {
if (!RxDatabaseManager.instance) {
RxDatabaseManager.instance = new RxDatabaseManager();
}
return RxDatabaseManager.instance;
}
public async init(): Promise<RxDatabase<MyDatabaseCollections>> {
if (this.db) return this.db;
if (__DEV__) {
// needs to be added in dev mode
addRxPlugin(RxDBDevModePlugin);
}
this.db = await createRxDatabase<MyDatabaseCollections>({
name: 'myDb',
storage: encryptedStorage,
multiInstance: false, // No multi-instance support for React Native
closeDuplicates: true, // Close duplicate database instances
});
await this.db.addCollections({
users: {
schema: userSchema,
conflictHandler: defaultConflictHandler,
migrationStrategies: {
// 1: function (oldDoc: UserType) {},
},
},
});
this.setupConnectivityListener();
return this.db;
}
public getDb(): RxDatabase<MyDatabaseCollections> {
if (!this.db) {
throw new Error('Database not initialized. Call init() first.');
}
return this.db;
}
private replicateCollection<T>(dto: ReplicateCollectionDto<T>) {
const { collection, replicationId, api } = dto;
const replicationState = replicateRxCollection<WithDeleted<T>, number>({
collection: collection,
replicationIdentifier: replicationId,
pull: {
async handler(checkpointOrNull: unknown, batchSize: number) {
const typedCheckpoint = checkpointOrNull as CheckPointType;
const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
const id = typedCheckpoint ? typedCheckpoint.id : '';
const response = await api.pull({ updatedAt, id, batchSize });
return {
documents: response.data.documents,
checkpoint: response.data.checkpoint,
};
},
batchSize: 20,
},
push: {
async handler(changeRows) {
console.log('push');
const response = await api.push({ changeRows });
return response.data;
},
},
});
replicationState.active$.subscribe(v => {
console.log('Replication active$:', v);
});
replicationState.canceled$.subscribe(v => {
console.log('Replication canceled$:', v);
});
replicationState.error$.subscribe(async error => {
console.error('Replication error$:', error);
});
}
private async startReplication() {
const db = this.getDb();
this.replicateCollection<UserType>({
collection: db.users,
replicationId: '/users/sync',
api: {
push: usersApi.push,
pull: usersApi.pull,
},
});
}
private setupConnectivityListener() {
NetInfo.addEventListener(state => {
const wasOffline = !this.isOnline;
this.isOnline = Boolean(state.isConnected);
if (this.isOnline && wasOffline) {
this.onReconnected();
}
});
}
private async onReconnected() {
this.startReplication();
}
}
Этот маленький зверь:
- Устанавливает БД.
- Смотрит на Интернет, как липкий бывший.
- Синхронизируется, как только мы вернемся в Интернете.
И да, это все регистрирует. Будущее меня будет благодарен при отладке в следующий раз.
Шаг 3 - начальная загрузка DB, когда приложение запускается
Когда приложение запускается, я вращаю свойRxDatabaseManager
пример. Здесь нет магии - просто хороший старыйuseEffect
Делает свое дело.
Если что -то взрывается во время Init, я регистрирую его. Потому что притворяться, что ошибки не существует, это то, как вы получаете приложения с привидениями.
//App.tsx
useEffect(() => {
const init = async () => {
const dbManager = RxDatabaseManager.getInstance();
dbManager
.init()
.then(() => {
setAppStatus('ready');
})
.catch((error) => {
console.log('Error initializing database:', error);
setAppStatus('error');
});
};
init();
}, []);
Шаг 4 - Репликация данных (a.k.a. синхронизировать без слез)
Когда приложение переходит от «автономного пещерного режима» назад, онлайн,onReconnected()
пожары. Это начинает синхронизацию данных между локальным DB и сервером черезreplicateRxCollection
Полем
Вот базовый обработчик тяги - RXDB отправляет контрольную точку (updatedAt
Вid
) поэтому сервер знает, куда мы остановились. Потому что никто не хочет получитьвесьДБ каждый раз.
//instance.ts
async handler(checkpointOrNull: unknown, batchSize: number) {
const typedCheckpoint = checkpointOrNull as CheckPointType;
const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
const id = typedCheckpoint ? typedCheckpoint.id : '';
const response = await api.pull({ updatedAt, id, batchSize });
return {
documents: response.data.documents,
checkpoint: response.data.checkpoint,
};
},
На сервере я запрашиваю только новые/обновленные вещи с момента последней контрольной точки. Потому что пропускная способность драгоценна, как и мое терпение.
//users.query-repository.ts
async pull(dto: PullUsersDto): Promise<UserViewDto[]> {
const { id, updatedAt, batchSize } = dto;
const users = await this.users
.createQueryBuilder('user')
.where('user.updatedAt > :updatedAt', { updatedAt })
.orWhere('user.updatedAt = :updatedAt AND user.id > :id', {
updatedAt,
id,
})
.orderBy('user.updatedAt', 'ASC')
.addOrderBy('user.id', 'ASC')
.limit(batchSize)
.getMany();
return users.map(UserViewDto.mapToView);
}
А затем сервер отправляет обратно как документа, так и новая блестящая контрольная точка:
//user.service.ts
async pull(dto: PullUsersDto) {
const users = await this.usersRepository.pull(dto);
const newCheckpoint =
users.length === 0
? { id: dto.id, updatedAt: dto.updatedAt }
: {
id: users.at(-1)!.id,
updatedAt: users.at(-1)!.updatedAt,
};
return {
documents: users,
checkpoint: newCheckpoint,
};
}
Шаг 5 - Нажатие локальных изменений обратно на сервер
RXDB также отслеживает то, что изменилось локально с момента последней синхронизации, и подталкивает его. Подумайте об этом, как Dropbox для данных вашего приложения - без случайных файлов «конфликтной копии» (ну ... если только вы не справляетесь с конфликтами плохо).
//instance.ts
async handler(changeRows) {
const response = await api.push({ changeRows });
return response.data;
},
На бэкэнд я проверяю каждое входящее изменение:
- Если нет конфликта, он идет прямо в.
- Если версия сервера новее, я бросаю его в
conflicts
куча.
//user.service.ts
async push(dto: PushUsersDto) {
const changeRows = dto.changeRows;
const existingUsers = await this.usersRepository.findByIds(
changeRows.map((changeRow) => changeRow.newDocumentState.id),
);
const existingMap = new Map(existingUsers.map((user) => [user.id, user]));
const toSave: UserViewDto[] = [];
const conflicts: UserViewDto[] = [];
for (const changeRow of changeRows) {
const newDoc = changeRow.newDocumentState;
const existing = existingMap.get(newDoc.id);
const isConflict = existing && existing.updatedAt > newDoc?.updatedAt;
if (isConflict) {
conflicts.push(existing);
} else {
toSave.push(newDoc);
}
if (toSave.length > 0) {
await this.usersRepository.save(toSave);
}
}
return conflicts;
}
Шаг 6 - Резолюция конфликтов («Последнее обновление выигрывает» Гамбит)
Вот где люди обычно переполняют вещи. Да, тымогСоздайте стратегию слияния НАСА. Или… вы можете пойти с «Последним обновлением побед» и назвать это днем.
Это не всегда правильный ход, но в нашем случае - простой, быстро, достаточно хорош.
//utills.ts
export const defaultConflictHandler: RxConflictHandler<{
updatedAt: number;
}> = {
isEqual(a, b) {
return a.updatedAt === b.updatedAt;
},
resolve({ assumedMasterState, realMasterState, newDocumentState }) {
return Promise.resolve(realMasterState);
},
};
Шаг 7 - Ведение пользовательского интерфейса синхронизируется
После init статус приложения переворачивается вReady
, и мы просто ... используем это. Нет странных ручных кнопок обновления, без ерунды «Нажмите, чтобы перезагрузить».
//UsersScreen.tsx
export const UsersScreen = () => {
const users = useUsersSelector({
sort: [{ updatedAt: 'desc' }],
});
const { createUser, deleteUser, updateUser } = useUsersService();
return (
<View style={styles.container}>
{users.map(user => (
<Text key={user.id}>{user.name}</Text>
))}
<Button title={'Create new user'} onPress={createUser} />
<Button
disabled={users.length === 0}
title={'Update user'}
onPress={() => updateUser(users[0].id)}
/>
<Button
disabled={users.length === 0}
title={'Delete user'}
onPress={() => deleteUser(users[0].id)}
/>
</View>
);
};
useUsersSelector
подписывается на изменения в БД, поэтому пользовательский интерфейс обновляется. Это один из тех моментов «работает как волшебство», о которых вы не должны слишком много думать - просто наслаждайтесь этим.
//user.selector.tsx
export const useUsersSelector = (query?: MangoQuery<UserType>) => {
const userModel = RxDatabaseManager.getInstance().getDb().users;
const [users, setUsers] = useState<UserType[]>([]);
useEffect(() => {
const subscription = userModel.find(query).$.subscribe(result => {
setUsers(result);
});
return () => subscription.unsubscribe();
}, [userModel, query]);
return users;
};
Последние мысли
Мы в основном построили приложение, которое:
- Работает в автономном режиме.
- Синхронизируется автоматически.
- Не заботится о том, имеет ли ваш Wi-Fi экзистенциальный кризис.
Мы использовали эту же настройку дляSizl's Dark Kitchen Riders в Чикаго, где подключение ... давайте просто скажем «режим городских приключений». Всадники теперь могут заканчивать заказы, делать фотографии с доставкой доставки и маркировать доставку без интернета. Приложение синхронизирует позже.
Конечно, дела в реальном мире могут стать гнаровыми-несколько устройств, обновляющих одну и ту же запись, связанные с ним делеции данных, массивные наборы данных. Но шаблон держит. Вам просто нужно расширить его с помощью более умного разрешения конфликтов и более гибкой архитектуры.
Я бы порекомендовал пойти по местному первому? Абсолютно - если вам не нравятся билеты на ярость пользователя, которые начинаются с «Я был офлайн и…»
Нажмите на ссылку в моей биографии для получения дополнительной информации об этом и других проектах!
Чтобы проверить нашу панель администратора с открытым исходным кодом в React, см. НашGitHubПолем
Оригинал