Практика 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. Выделите бизнес-правила в чистые функции
Фильтрация и сортировка — это домен. Чистые функции тривиально тестируются.
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 — ViewModelimport { 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-хук» (всё свалено в один хук без выделения домена/сервиса).
Вопросы для самопроверки
- Чем View в MVVM отличается от View в MVP? Что в RN играет роль биндинга?
- Почему
fetchвнутри компонента — это «запах»? Какой принцип SOLID лечит вынос его в сервис? - Зачем моделировать состояние экрана как автомат
Idle → Loading → Success → Errorвместо трёх отдельныхuseState? - Что именно нужно пробрасывать из хук-ViewModel наружу, а что стоит спрятать?
- Как наличие mock-сервиса (Liskov/Dependency Inversion) делает ViewModel тестируемым без UI и сети?
- Где проходит граница «View ↔ логика»? Приведите по два примера того, что должно остаться в компоненте и что — уйти в ViewModel/домен.
Ресурсы
- Лекция 9 «Архитектурные принципы и презентационные паттерны» (разделы 2–5).
- React: правила хуков и кастомные хуки — https://react.dev/learn/reusing-logic-with-custom-hooks
- React Native: списки
FlatList— https://reactnative.dev/docs/flatlist - Martin Fowler. Presentation Model / GUI Architectures — https://martinfowler.com/eaaDev/PresentationModel.html
- О разделении ответственностей и SOLID — материалы лекции, раздел 2.