Перейти к содержимому

Лекция 7. Сетевые запросы и хранение данных на устройстве

Введение

Почти любое полезное мобильное приложение взаимодействует с внешним миром: загружает ленту новостей, отправляет форму на сервер, синхронизирует профиль пользователя. При этом мобильная сеть нестабильна (метро, лифт, роуминг), а пользователь ожидает, что приложение останется отзывчивым даже при потере соединения. Поэтому две темы идут рука об руку: как корректно ходить в сеть и как хранить данные на самом устройстве.

В этой лекции разберём:

  • HTTP-запросы из приложения (fetch/axios, async/await, разбор ответа, заголовки);
  • обработку ошибок сети: коды статусов, таймауты, базовые повторные попытки (retry);
  • локальное хранение «ключ-значение» через AsyncStorage и безопасное хранение через SecureStore;
  • реляционное хранение через SQLite, критерии выбора AsyncStorage vs SQLite;
  • сериализацию JSON и вводное кеширование ответов локально.

Глубокое погружение в политики кеширования, offline-first и сложные retry-стратегии — в лекциях 12–13. Здесь — практический минимум, который нужен в каждом проекте.


1. HTTP-запросы из приложения

1.1. fetch — встроенный инструмент

В React Native (как и в браузере) доступен глобальный fetch. Он возвращает Promise, поэтому удобно использовать async/await.

async function loadTodos() {
const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
const data = await response.json(); // разбор тела ответа из JSON
return data;
}

Важно понимать две стадии:

  1. await fetch(...) — получаем объект Response (статус, заголовки), но не тело.
  2. await response.json() (или .text()) — асинхронно читаем и разбираем тело.

1.2. Заголовки, методы, тело запроса

Для POST/PUT нужно указать метод, заголовки и сериализованное тело:

async function createTodo(todo, token) {
const response = await fetch('https://api.example.com/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, // передаём токен авторизации
},
body: JSON.stringify(todo), // объект -> строка JSON
});
return response.json();
}

Ключевые заголовки:

  • Content-Type: application/json — говорит серверу, в каком формате тело запроса;
  • Accept — какой формат ответа мы ожидаем;
  • Authorization — токен/ключ для аутентификации.

1.3. fetch vs axios

fetch встроен и не требует зависимостей, но у него есть особенности:

  • он не считает ошибкой статусы 4xx/5xx (промис не отклоняется — нужно проверять вручную);
  • нет встроенного таймаута;
  • тело нужно вручную сериализовать/разбирать.

axios — популярная библиотека, которая решает это «из коробки»:

import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000, // таймаут в миллисекундах
headers: { 'Content-Type': 'application/json' },
});
const { data } = await api.get('/todos'); // тело уже разобрано в data

Преимущества axios: автоматический парсинг JSON, единый baseURL, перехватчики (interceptors) для подстановки токена и логирования, встроенный таймаут, отклонение промиса при HTTP-ошибках. Выбор между ними — вопрос масштаба проекта: для пары запросов хватит fetch, для большого API удобнее axios.


2. Обработка ошибок сети

Сеть на телефоне нестабильна, поэтому ошибки — это норма, а не исключение. Их нужно обрабатывать осознанно.

2.1. Коды статусов и базовая обёртка

Напомним семейства HTTP-статусов:

  • 2xx — успех (200 OK, 201 Created, 204 No Content);
  • 4xx — ошибка клиента (400 Bad Request, 401 Unauthorized, 404 Not Found);
  • 5xx — ошибка сервера (500, 502, 503).

Так как fetch не бросает исключение на 4xx/5xx, проверяем response.ok сами:

async function request(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
// читаем текст ошибки, но не падаем, если тело пустое
const message = await response.text().catch(() => 'Network error');
throw new Error(`${response.status}: ${message}`);
}
return response.json();
}

2.2. Таймауты

Без таймаута запрос может «висеть» бесконечно при плохой сети. С fetch таймаут реализуется через AbortController:

async function requestWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} finally {
clearTimeout(timer); // обязательно очищаем таймер
}
}

В axios таймаут задаётся параметром timeout без дополнительного кода.

2.3. Повторные попытки (retry с backoff)

Временные ошибки (таймаут, 503, обрыв связи) часто исчезают при повторе. Простейшая стратегия — экспоненциальная задержка: каждый следующий повтор ждёт дольше.

async function withRetry(fn, { retries = 3, baseDelayMs = 300 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
if (attempt >= retries) throw err; // попытки исчерпаны
const delay = baseDelayMs * Math.pow(2, attempt); // 300, 600, 1200 мс...
await new Promise(r => setTimeout(r, delay));
attempt += 1;
}
}
}
// const data = await withRetry(() => request('https://api.example.com/data'));

Важные нюансы (подробно — в лекциях 12–13):

  • повторять стоит только идемпотентные и временные ошибки (GET, 5xx, таймаут);
  • не повторять 4xx-ошибки клиента (401, 404) — повтор не поможет;
  • добавлять «джиттер» (случайный разброс), чтобы не перегружать сервер синхронными повторами.

3. Локальное хранение: AsyncStorage

3.1. Что это такое

AsyncStorage — простое асинхронное хранилище «ключ-значение», аналог localStorage в вебе, но с асинхронным API. Хранит только строки. Подходит для небольших объёмов: настройки пользователя, флаги, токен, последний открытый экран, кеш ответа.

Окно терминала
npm install @react-native-async-storage/async-storage

3.2. Основные операции

import AsyncStorage from '@react-native-async-storage/async-storage';
// Запись
export async function saveToken(token) {
await AsyncStorage.setItem('token', token);
}
// Чтение (вернёт строку или null, если ключа нет)
export async function getToken() {
return AsyncStorage.getItem('token');
}
// Удаление
export async function clearToken() {
await AsyncStorage.removeItem('token');
}

Поскольку хранятся только строки, объекты нужно сериализовать:

export async function saveUser(user) {
await AsyncStorage.setItem('user', JSON.stringify(user)); // объект -> строка
}
export async function getUser() {
const raw = await AsyncStorage.getItem('user');
return raw ? JSON.parse(raw) : null; // строка -> объект
}

Полезные методы: multiSet/multiGet (пакетные операции), getAllKeys, clear.

3.3. Рекомендации и безопасность

  • Не храните секреты в открытом виде. AsyncStorage не шифруется: токены доступа, пароли, ключи API в нём лежат как обычный текст, доступный при компрометации устройства.

  • Для чувствительных данных используйте expo-secure-store (Keychain на iOS, Keystore на Android):

    import * as SecureStore from 'expo-secure-store';
    await SecureStore.setItemAsync('token', token); // хранится в защищённом хранилище ОС
    const token = await SecureStore.getItemAsync('token');
  • Минимизируйте число ключей, используйте неймспейсы (auth:token, cache:todos).

  • Все операции асинхронны — не забывайте await и обрабатывайте ошибки.


4. Реляционное хранение: SQLite

4.1. Когда AsyncStorage уже мало

Когда данных много и они связаны между собой, нужны выборки, сортировки, фильтры и агрегаты — хранилище «ключ-значение» становится неудобным. Тогда используют встроенную реляционную БД SQLite. В Expo для этого есть пакет expo-sqlite.

Окно терминала
npx expo install expo-sqlite

4.2. Набросок работы с SQLite

import * as SQLite from 'expo-sqlite';
// Открываем (или создаём) базу
const db = await SQLite.openDatabaseAsync('app.db');
// Создание таблицы
export async function initDb() {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
done INTEGER DEFAULT 0
);
`);
}
// Вставка с параметрами (защита от SQL-инъекций)
export async function addTodo(title) {
await db.runAsync('INSERT INTO todos (title, done) VALUES (?, ?);', [title, 0]);
}
// Выборка
export async function getTodos() {
return db.getAllAsync('SELECT * FROM todos ORDER BY id DESC;');
}

Важно: значения подставляйте через параметры ?, а не конкатенацией строк — это защищает от SQL-инъекций.

4.3. AsyncStorage vs SQLite

КритерийAsyncStorageSQLite (expo-sqlite)
Модель данныхКлюч-значение (только строки)Реляционная (таблицы, строки, столбцы)
ЗапросыНет; читаем по ключуПолноценный SQL: WHERE, ORDER BY, JOIN, агрегаты
Объём данныхНебольшой (настройки, токен, мелкий кеш)Средний/большой, тысячи записей
Связи между сущностямиНетДа (внешние ключи, JOIN)
Сложность APIОчень простойВыше: схема, миграции, транзакции
ТранзакцииНетДа
Типичные сценарииФлаги, тема, последний экран, токенСписок задач, офлайн-каталог, история, заметки

Критерии выбора: если нужно сохранить пару значений или небольшой объект — берите AsyncStorage (или SecureStore для секретов). Если данные структурированы, их много, и по ним нужны выборки/сортировки/связи — берите SQLite. Альтернативы для сложных случаев: WatermelonDB, Realm (выходят за рамки лекции).


5. Сериализация и вводное кеширование

5.1. JSON.stringify / JSON.parse

JSON — основной формат обмена с серверами и хранения структур на устройстве:

  • JSON.stringify(obj) — объект в строку (для отправки в теле запроса или записи в AsyncStorage);
  • JSON.parse(str) — строка обратно в объект.

Помните об ограничениях: функции, undefined, Date сериализуются не «как есть» (Date превращается в строку ISO, и при parse останется строкой). Всегда оборачивайте JSON.parse в обработку ошибок — данные на диске могут оказаться повреждёнными.

5.2. Кеширование ответов локально (вводно)

Простейший кеш — сохранять последний успешный ответ в AsyncStorage и показывать его при старте, пока грузятся свежие данные:

const CACHE_KEY = 'cache:todos';
export async function getTodosCached() {
try {
const fresh = await request('https://api.example.com/todos');
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh)); // обновляем кеш
return fresh;
} catch (err) {
// сеть недоступна — отдаём последнее сохранённое
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (raw) return JSON.parse(raw);
throw err; // кеша тоже нет — пробрасываем ошибку
}
}

Это упрощённый паттерн. Зрелые подходы (Cache-First, Network-First, Stale-While-Revalidate, инвалидация по времени, фоновая синхронизация) и инструменты вроде TanStack Query разберём в лекциях 12–13.


Краткие итоги

  • fetch встроен и работает с async/await; ответ читается в два шага (Response, затем .json()).
  • fetch не бросает ошибку на 4xx/5xx — проверяйте response.ok вручную; axios делает это сам и поддерживает таймаут/перехватчики.
  • Сеть нестабильна: добавляйте таймауты (через AbortController) и повторы с экспоненциальной задержкой, но повторяйте только временные ошибки.
  • AsyncStorage — простое хранилище «ключ-значение» (только строки); объекты сериализуйте через JSON. Секреты в нём не храните — используйте SecureStore.
  • SQLite нужен, когда данных много и они связаны: даёт SQL-запросы, сортировки, фильтры, транзакции. Выбор между ними определяется структурой и объёмом данных.
  • JSON (stringify/parse) — основа и для обмена с сервером, и для локального кеша последнего успешного ответа.

Вопросы для самопроверки

  1. В чём разница между AsyncStorage и SQLite? Когда использовать каждый подход?
  2. Опишите принципы работы с AsyncStorage. Какие есть рекомендации по безопасности?