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

Практика 9. Практическая работа 9. Рефакторинг к MVVM и разделение ответственностей

Раздел 2. Привязка к лекции 9 («Архитектурные принципы и презентационные паттерны»). Формат — практико-кодовый: берём «God-компонент» и приводим его к MVVM.

Цели работы

  • Закрепить принцип разделения ответственностей (SoC): UI отдельно, логика отдельно, данные отдельно.
  • На практике применить паттерн MVVM в его «нативной» для React Native форме: компонент = View, кастомный хук = ViewModel.
  • Научиться выносить состояние и операции экрана в хук-вьюмодель useXxxViewModel, оставляя в компоненте только представление.
  • Получить данные через сервис/функцию, а не прямым fetch из JSX.
  • Научиться видеть архитектурные «запахи» и объяснять, как рефакторинг улучшает тестируемость.

Коротко о теории

Презентационные паттерны (MVC, MVP, MVVM) делят систему на отображение и логику представления. В Expo/React Native почти даром получается MVVM:

  • Model — данные и бизнес-правила (домен, источник данных).
  • View — функциональный компонент: рисует состояние и пробрасывает события. Декларативен.
  • ViewModel — кастомный хук со стейтом и эффектами: хранит наблюдаемое состояние и логику. Не знает о конкретной разметке.
  • Биндинг — реактивность React: компонент сам перерисовывается при смене state.

Эвристика-ориентир из лекции: если строку кода нужно протестировать без рендеринга UI — она не должна жить в JSX. Появился fetch, if (status === 401) или формула расчёта прямо в компоненте — логика «протекла» в представление.

Полезно мыслить экран как конечный автомат: Idle → Loading → Success/Empty → Error. Переходами управляет ViewModel, View лишь рисует текущее состояние.


Задание

Дан экран списка задач, написанный как God-компонент: в одном файле и UI, и сетевой запрос, и бизнес-логика. Нужно отрефакторить его по MVVM.

Шаг 0. Изучите «до»

Прочитайте код и найдите в нём смешанные ответственности.

// TodosScreen.js — God-компонент: делает всё сразу (АНТИПАТТЕРН)
import { useEffect, useState } from 'react';
import { View, Text, FlatList, ActivityIndicator, Button } from 'react-native';
export default function TodosScreen() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
// прямой запрос из экрана: знает URL, формат, заголовки
fetch('https://api.example.com/todos')
.then((r) => {
if (r.status === 401) throw new Error('Не авторизован');
return r.json();
})
.then((data) => {
// бизнес-логика прямо в компоненте: фильтрация + сортировка
const active = data
.filter((t) => !t.done)
.sort((a, b) => a.priority - b.priority);
setTodos(active);
setLoading(false);
})
.catch((e) => {
setError(e.message);
setLoading(false);
});
}, []);
if (loading) return <ActivityIndicator />;
if (error) return <Text>Ошибка: {error}</Text>;
return (
<View>
{/* подсчёт прямо в JSX — ещё одна «протёкшая» логика */}
<Text>Активных задач: {todos.filter((t) => !t.done).length}</Text>
<FlatList
data={todos}
keyExtractor={(t) => String(t.id)}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
</View>
);
}

Запахи здесь: прямой fetch из экрана, бизнес-логика (фильтр/сортировка/подсчёт) в UI, обработка HTTP-статусов в компоненте, ручное жонглирование тремя useState вместо одного состояния-автомата.

Шаг 1. Вынесите доступ к данным в сервис

Компонент не должен знать URL и формат ответа. Прячем это за функцией-фасадом.

// services/todosService.js — слой данных (Model)
export async function fetchTodos() {
const response = await fetch('https://api.example.com/todos');
if (response.status === 401) throw new Error('Не авторизован');
if (!response.ok) throw new Error('Не удалось загрузить задачи');
return response.json();
}

Шаг 2. Выделите бизнес-правила в чистые функции

Фильтрация и сортировка — это домен. Чистые функции тривиально тестируются.

domain/todos.js
export function selectActive(todos) {
return todos
.filter((t) => !t.done)
.sort((a, b) => a.priority - b.priority);
}
export function countActive(todos) {
return todos.filter((t) => !t.done).length;
}

Шаг 3. Соберите ViewModel-хук

В хук уходит состояние (как автомат) и операции. Наружу пробрасываем только нужное: текущее состояние и команды.

// viewmodels/useTodosViewModel.js — ViewModel
import { useCallback, useEffect, useState } from 'react';
import { fetchTodos } from '../services/todosService';
import { selectActive, countActive } from '../domain/todos';
export function useTodosViewModel(service = { fetchTodos }) {
const [state, setState] = useState({ status: 'idle', items: [], error: null });
const load = useCallback(async () => {
setState({ status: 'loading', items: [], error: null });
try {
const data = await service.fetchTodos();
setState({ status: 'success', items: selectActive(data), error: null });
} catch (e) {
setState({ status: 'error', items: [], error: e.message });
}
}, [service]);
useEffect(() => { load(); }, [load]);
// наружу — только то, что нужно View
return {
status: state.status,
items: state.items,
error: state.error,
activeCount: countActive(state.items),
reload: load,
};
}

Обратите внимание: service передаётся параметром со значением по умолчанию. Это инверсия зависимостей (D из SOLID): в тесте подставим mock, в проде — реальный сервис.

Шаг 4. Оставьте в компоненте только представление («после»)

// TodosScreen.js — чистая View: ни запросов, ни бизнес-логики
import { View, Text, FlatList, ActivityIndicator, Button } from 'react-native';
import { useTodosViewModel } from './viewmodels/useTodosViewModel';
export default function TodosScreen() {
const { status, items, error, activeCount, reload } = useTodosViewModel();
if (status === 'loading' || status === 'idle') return <ActivityIndicator />;
if (status === 'error') {
return (
<View>
<Text>Ошибка: {error}</Text>
<Button title="Повторить" onPress={reload} />
</View>
);
}
return (
<View>
<Text>Активных задач: {activeCount}</Text>
<FlatList
data={items}
keyExtractor={(t) => String(t.id)}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
</View>
);
}

Теперь компонент только описывает «как нарисовать состояние». Вся логика — снаружи и отдельно тестируема.

Шаг 5. (Бонус) Набросайте тест ViewModel без UI

Покажите, что логику теперь можно проверить, не рендеря компонент: подставьте fakeService с фиксированными данными, вызовите load() и проверьте, что items отфильтрованы и отсортированы, activeCount верный, а status === 'success'.

Что обсудить в отчёте

  • Какие «запахи» из лекции (раздел 5) устранены: God-компонент, бизнес-логика в UI, прямые запросы из экрана.
  • Как изменилась тестируемость: домен — чистые функции, ViewModel — через mock-сервис, и всё это без запуска UI и сети.
  • Какие принципы сработали: SoC (UI/домен/данные), SRP и Dependency Inversion из SOLID, состояние-автомат вместо россыпи флагов.

Критерии оценки

КритерийВес
Доступ к данным вынесен в сервис/функцию (нет fetch в компоненте)20%
Бизнес-логика вынесена в чистые функции / домен20%
Создан хук-ViewModel useXxxViewModel, наружу проброшено только нужное25%
Компонент содержит только представление (нет логики в JSX)20%
Показаны код «до/после» и разбор устранённых «запахов» + тестируемость15%

Штраф до −10%: ViewModel сам превратился в «God-хук» (всё свалено в один хук без выделения домена/сервиса).


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

  1. Чем View в MVVM отличается от View в MVP? Что в RN играет роль биндинга?
  2. Почему fetch внутри компонента — это «запах»? Какой принцип SOLID лечит вынос его в сервис?
  3. Зачем моделировать состояние экрана как автомат Idle → Loading → Success → Error вместо трёх отдельных useState?
  4. Что именно нужно пробрасывать из хук-ViewModel наружу, а что стоит спрятать?
  5. Как наличие mock-сервиса (Liskov/Dependency Inversion) делает ViewModel тестируемым без UI и сети?
  6. Где проходит граница «View ↔ логика»? Приведите по два примера того, что должно остаться в компоненте и что — уйти в ViewModel/домен.

Ресурсы