Веб-архитектуры
MVP
Паттерн архитектуры Model-View-Presenter
MVP — это архитектурный паттерн, который является эволюцией MVC и обеспечивает еще более четкое разделение ответственности между компонентами. В MVP View становится пассивным компонентом, а вся логика представления переносится в Presenter.
Что такое MVP?
MVP — это архитектурный паттерн, который:
- Максимально разделяет ответственность — каждый компонент имеет четкую роль
- Делает View пассивным — представление только отображает данные
- Централизует логику в Presenter — вся логика представления в одном месте
- Повышает тестируемость — Presenter можно тестировать без UI
Компоненты MVP
Model (Модель)
Модель представляет данные и бизнес-логику приложения.
Ответственности:
- Структура данных
- Бизнес-правила
- Валидация
- Взаимодействие с внешними источниками данных
View (Представление)
Представление — это пассивный пользовательский интерфейс.
Ответственности:
- Отображение данных (полученных от Presenter)
- Передача событий пользователя в Presenter
- НЕ содержит логики представления
- НЕ обрабатывает данные
Presenter (Презентер)
Презентер содержит всю логику представления и координирует взаимодействие между Model и View.
Ответственности:
- Обработка событий от View
- Вызов методов Model
- Преобразование данных для отображения
- Управление состоянием View
- Валидация пользовательского ввода
Схема взаимодействия
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ View │ │ Presenter │ │ Model │
│ │ │ │ │ │
│ • Display │◄───┤ • Logic ├───►│ • Data │
│ • Events ├───►│ • Commands │ │ • Business │
│ • Passive │ │ • State │ │ • API │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │ │
│ │ │
└───────────────────┼───────────────────┘
│
User Interaction
Примеры реализации
MVP в Vanilla JavaScript
Model
// models/UserModel.js
class UserModel {
constructor() {
this.users = [];
this.currentUser = null;
}
// Получение всех пользователей
async getAllUsers() {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Ошибка при загрузке пользователей');
}
this.users = await response.json();
return this.users;
} catch (error) {
throw new Error(`Ошибка при загрузке пользователей: ${error.message}`);
}
}
// Получение пользователя по ID
async getUserById(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Пользователь не найден');
}
this.currentUser = await response.json();
return this.currentUser;
} catch (error) {
throw new Error(`Ошибка при загрузке пользователя: ${error.message}`);
}
}
// Создание нового пользователя
async createUser(userData) {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Ошибка при создании пользователя');
}
const newUser = await response.json();
this.users.push(newUser);
return newUser;
} catch (error) {
throw new Error(`Ошибка при создании пользователя: ${error.message}`);
}
}
// Обновление пользователя
async updateUser(id, userData) {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Ошибка при обновлении пользователя');
}
const updatedUser = await response.json();
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users[index] = updatedUser;
}
return updatedUser;
} catch (error) {
throw new Error(`Ошибка при обновлении пользователя: ${error.message}`);
}
}
// Удаление пользователя
async deleteUser(id) {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Ошибка при удалении пользователя');
}
this.users = this.users.filter(user => user.id !== id);
return true;
} catch (error) {
throw new Error(`Ошибка при удалении пользователя: ${error.message}`);
}
}
// Валидация данных пользователя
validateUser(userData) {
const errors = {};
if (!userData.name || userData.name.trim().length < 2) {
errors.name = 'Имя должно содержать минимум 2 символа';
}
if (!userData.email || !this.isValidEmail(userData.email)) {
errors.email = 'Некорректный email адрес';
}
if (!userData.password || userData.password.length < 6) {
errors.password = 'Пароль должен содержать минимум 6 символов';
}
return errors;
}
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
export default UserModel;
View
// views/UserView.js
class UserView {
constructor() {
this.container = document.getElementById('user-container');
this.form = document.getElementById('user-form');
this.userList = document.getElementById('user-list');
this.errorContainer = document.getElementById('error-container');
this.loadingIndicator = document.getElementById('loading-indicator');
this.bindEvents();
}
bindEvents() {
// События формы
if (this.form) {
this.form.addEventListener('submit', (e) => {
e.preventDefault();
const formData = this.getFormData();
this.onFormSubmit(formData);
});
}
// События кнопок
const addButton = document.getElementById('add-user-btn');
if (addButton) {
addButton.addEventListener('click', () => {
this.onAddUserClick();
});
}
const cancelButton = document.getElementById('cancel-btn');
if (cancelButton) {
cancelButton.addEventListener('click', () => {
this.onCancelClick();
});
}
}
// Отображение списка пользователей
displayUsers(users) {
if (!this.userList) return;
this.userList.innerHTML = users.map(user => `
<div class="user-card" data-user-id="1">
<h3>Имя пользователя</h3>
<p>email@example.com</p>
<span class="role-badge user">user</span>
<div class="user-actions">
<button class="edit-btn" data-user-id="1">Редактировать</button>
<button class="delete-btn" data-user-id="1">Удалить</button>
</div>
</div>
`).join('');
// Привязка событий к кнопкам
this.bindUserActions();
}
bindUserActions() {
// Кнопки редактирования
const editButtons = this.userList.querySelectorAll('.edit-btn');
editButtons.forEach(button => {
button.addEventListener('click', (e) => {
const userId = e.target.getAttribute('data-user-id');
this.onEditUserClick(userId);
});
});
// Кнопки удаления
const deleteButtons = this.userList.querySelectorAll('.delete-btn');
deleteButtons.forEach(button => {
button.addEventListener('click', (e) => {
const userId = e.target.getAttribute('data-user-id');
this.onDeleteUserClick(userId);
});
});
}
// Отображение формы
showForm(user = null) {
if (!this.form) return;
this.form.style.display = 'block';
this.userList.style.display = 'none';
if (user) {
// Режим редактирования
document.getElementById('user-id').value = user.id;
document.getElementById('user-name').value = user.name;
document.getElementById('user-email').value = user.email;
document.getElementById('user-role').value = user.role;
document.getElementById('form-title').textContent = 'Редактирование пользователя';
document.getElementById('submit-btn').textContent = 'Обновить';
} else {
// Режим создания
this.clearForm();
document.getElementById('form-title').textContent = 'Создание пользователя';
document.getElementById('submit-btn').textContent = 'Создать';
}
}
// Скрытие формы
hideForm() {
if (!this.form) return;
this.form.style.display = 'none';
this.userList.style.display = 'block';
this.clearForm();
}
// Очистка формы
clearForm() {
if (!this.form) return;
document.getElementById('user-id').value = '';
document.getElementById('user-name').value = '';
document.getElementById('user-email').value = '';
document.getElementById('user-role').value = 'user';
document.getElementById('user-password').value = '';
}
// Получение данных формы
getFormData() {
return {
id: document.getElementById('user-id').value,
name: document.getElementById('user-name').value,
email: document.getElementById('user-email').value,
role: document.getElementById('user-role').value,
password: document.getElementById('user-password').value
};
}
// Отображение ошибок валидации
displayValidationErrors(errors) {
this.clearErrors();
Object.keys(errors).forEach(field => {
const errorElement = document.createElement('div');
errorElement.className = 'error-message';
errorElement.textContent = errors[field];
const fieldElement = document.getElementById('user-' + field);
if (fieldElement) {
fieldElement.parentNode.appendChild(errorElement);
fieldElement.classList.add('error');
}
});
}
// Очистка ошибок
clearErrors() {
const errorMessages = this.form.querySelectorAll('.error-message');
errorMessages.forEach(error => error.remove());
const errorFields = this.form.querySelectorAll('.error');
errorFields.forEach(field => field.classList.remove('error'));
}
// Отображение общей ошибки
displayError(message) {
if (!this.errorContainer) return;
this.errorContainer.innerHTML = `
<div class="alert alert-error">
Сообщение об ошибке
</div>
`;
}
// Очистка ошибок
clearError() {
if (!this.errorContainer) return;
this.errorContainer.innerHTML = '';
}
// Отображение состояния загрузки
showLoading() {
if (this.loadingIndicator) {
this.loadingIndicator.style.display = 'block';
}
}
// Скрытие состояния загрузки
hideLoading() {
if (this.loadingIndicator) {
this.loadingIndicator.style.display = 'none';
}
}
// Отображение сообщения об успехе
showSuccess(message) {
if (!this.errorContainer) return;
this.errorContainer.innerHTML = `
<div class="alert alert-success">
Сообщение об ошибке
</div>
`;
setTimeout(() => {
this.clearError();
}, 3000);
}
// Callback методы (вызываются Presenter'ом)
onFormSubmit(formData) {
// Переопределяется в Presenter
}
onAddUserClick() {
// Переопределяется в Presenter
}
onEditUserClick(userId) {
// Переопределяется в Presenter
}
onDeleteUserClick(userId) {
// Переопределяется в Presenter
}
onCancelClick() {
// Переопределяется в Presenter
}
}
export default UserView;
Presenter
// presenters/UserPresenter.js
import UserModel from '../models/UserModel.js';
import UserView from '../views/UserView.js';
class UserPresenter {
constructor(model, view) {
this.model = model;
this.view = view;
this.bindViewEvents();
}
bindViewEvents() {
// Привязка callback'ов к View
this.view.onFormSubmit = (formData) => this.handleFormSubmit(formData);
this.view.onAddUserClick = () => this.handleAddUserClick();
this.view.onEditUserClick = (userId) => this.handleEditUserClick(userId);
this.view.onDeleteUserClick = (userId) => this.handleDeleteUserClick(userId);
this.view.onCancelClick = () => this.handleCancelClick();
}
// Инициализация презентера
async init() {
try {
this.view.showLoading();
await this.loadUsers();
} catch (error) {
this.view.displayError('Ошибка при загрузке пользователей');
} finally {
this.view.hideLoading();
}
}
// Загрузка пользователей
async loadUsers() {
try {
const users = await this.model.getAllUsers();
this.view.displayUsers(users);
this.view.clearError();
} catch (error) {
this.view.displayError(error.message);
}
}
// Обработка отправки формы
async handleFormSubmit(formData) {
try {
this.view.showLoading();
this.view.clearErrors();
// Валидация данных
const errors = this.model.validateUser(formData);
if (Object.keys(errors).length > 0) {
this.view.displayValidationErrors(errors);
return;
}
// Сохранение пользователя
if (formData.id) {
// Обновление существующего пользователя
await this.model.updateUser(formData.id, formData);
this.view.showSuccess('Пользователь успешно обновлен');
} else {
// Создание нового пользователя
await this.model.createUser(formData);
this.view.showSuccess('Пользователь успешно создан');
}
// Обновление списка и скрытие формы
await this.loadUsers();
this.view.hideForm();
} catch (error) {
this.view.displayError(error.message);
} finally {
this.view.hideLoading();
}
}
// Обработка клика "Добавить пользователя"
handleAddUserClick() {
this.view.showForm();
this.view.clearError();
}
// Обработка клика "Редактировать пользователя"
async handleEditUserClick(userId) {
try {
this.view.showLoading();
const user = await this.model.getUserById(userId);
this.view.showForm(user);
this.view.clearError();
} catch (error) {
this.view.displayError(error.message);
} finally {
this.view.hideLoading();
}
}
// Обработка клика "Удалить пользователя"
async handleDeleteUserClick(userId) {
try {
// Подтверждение удаления
const user = this.model.users.find(u => u.id == userId);
if (!user) {
this.view.displayError('Пользователь не найден');
return;
}
if (!confirm('Вы уверены, что хотите удалить пользователя?')) {
return;
}
this.view.showLoading();
await this.model.deleteUser(userId);
this.view.showSuccess('Пользователь успешно удален');
await this.loadUsers();
} catch (error) {
this.view.displayError(error.message);
} finally {
this.view.hideLoading();
}
}
// Обработка клика "Отмена"
handleCancelClick() {
this.view.hideForm();
this.view.clearError();
}
}
export default UserPresenter;
Инициализация приложения
// app.js
import UserModel from './models/UserModel.js';
import UserView from './views/UserView.js';
import UserPresenter from './presenters/UserPresenter.js';
// Инициализация приложения
document.addEventListener('DOMContentLoaded', async () => {
const model = new UserModel();
const view = new UserView();
const presenter = new UserPresenter(model, view);
await presenter.init();
});
MVP в React (с хуками)
Model (Custom Hook)
// hooks/useUserModel.js
import { useState, useCallback } from 'react';
export const useUserModel = () => {
const [users, setUsers] = useState([]);
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const validateUser = useCallback((userData) => {
const errors = {};
if (!userData.name || userData.name.trim().length < 2) {
errors.name = 'Имя должно содержать минимум 2 символа';
}
if (!userData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userData.email)) {
errors.email = 'Некорректный email адрес';
}
if (!userData.password || userData.password.length < 6) {
errors.password = 'Пароль должен содержать минимум 6 символов';
}
return errors;
}, []);
const getAllUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Ошибка при загрузке пользователей');
}
const usersData = await response.json();
setUsers(usersData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
const getUserById = useCallback(async (id) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Пользователь не найден');
}
const userData = await response.json();
setCurrentUser(userData);
return userData;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const createUser = useCallback(async (userData) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Ошибка при создании пользователя');
}
const newUser = await response.json();
setUsers(prev => [...prev, newUser]);
return newUser;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const updateUser = useCallback(async (id, userData) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error('Ошибка при обновлении пользователя');
}
const updatedUser = await response.json();
setUsers(prev => prev.map(user =>
user.id === id ? updatedUser : user
));
return updatedUser;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteUser = useCallback(async (id) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Ошибка при удалении пользователя');
}
setUsers(prev => prev.filter(user => user.id !== id));
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
users,
currentUser,
loading,
error,
validateUser,
getAllUsers,
getUserById,
createUser,
updateUser,
deleteUser
};
};
Presenter (Custom Hook)
// hooks/useUserPresenter.js
import { useState, useCallback } from 'react';
import { useUserModel } from './useUserModel';
export const useUserPresenter = () => {
const model = useUserModel();
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
name: '',
email: '',
role: 'user',
password: ''
});
const [formErrors, setFormErrors] = useState({});
const handleLoadUsers = useCallback(async () => {
await model.getAllUsers();
}, [model]);
const handleShowCreateForm = useCallback(() => {
setEditingUser(null);
setFormData({
name: '',
email: '',
role: 'user',
password: ''
});
setFormErrors({});
setShowForm(true);
}, []);
const handleShowEditForm = useCallback(async (userId) => {
try {
const user = await model.getUserById(userId);
setEditingUser(user);
setFormData({
name: user.name,
email: user.email,
role: user.role,
password: ''
});
setFormErrors({});
setShowForm(true);
} catch (error) {
// Ошибка уже обработана в модели
}
}, [model]);
const handleHideForm = useCallback(() => {
setShowForm(false);
setEditingUser(null);
setFormData({
name: '',
email: '',
role: 'user',
password: ''
});
setFormErrors({});
}, []);
const handleFormSubmit = useCallback(async () => {
setFormErrors({});
// Валидация
const errors = model.validateUser(formData);
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
try {
if (editingUser) {
await model.updateUser(editingUser.id, formData);
} else {
await model.createUser(formData);
}
setShowForm(false);
setEditingUser(null);
setFormData({
name: '',
email: '',
role: 'user',
password: ''
});
} catch (error) {
// Ошибка уже обработана в модели
}
}, [model, formData, editingUser]);
const handleDeleteUser = useCallback(async (user) => {
if (window.confirm(`Вы уверены, что хотите удалить пользователя "${user.name}"?`)) {
await model.deleteUser(user.id);
}
}, [model]);
const handleFormDataChange = useCallback((field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Очистка ошибки для поля при изменении
if (formErrors[field]) {
setFormErrors(prev => ({
...prev,
[field]: ''
}));
}
}, [formErrors]);
return {
// Данные модели
users: model.users,
loading: model.loading,
error: model.error,
// Состояние формы
showForm,
editingUser,
formData,
formErrors,
// Методы презентера
handleLoadUsers,
handleShowCreateForm,
handleShowEditForm,
handleHideForm,
handleFormSubmit,
handleDeleteUser,
handleFormDataChange
};
};
View (React Component)
// components/UserManagement.jsx
import React, { useEffect } from 'react';
import { useUserPresenter } from '../hooks/useUserPresenter';
const UserManagement = () => {
const presenter = useUserPresenter();
useEffect(() => {
presenter.handleLoadUsers();
}, [presenter]);
return (
<div className="user-management">
<div className="header">
<h1>Управление пользователями</h1>
<button
onClick={presenter.handleShowCreateForm}
className="btn btn-primary"
>
Добавить пользователя
</button>
</div>
{presenter.loading && (
<div className="loading">Загрузка...</div>
)}
{presenter.error && (
<div className="error">{presenter.error}</div>
)}
{!presenter.loading && presenter.users.length > 0 && (
<div className="users-grid">
{presenter.users.map(user => (
<div key={user.id} className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<span className={`role-badge ${user.role}`}>
{user.role}
</span>
<div className="user-actions">
<button
onClick={() => presenter.handleShowEditForm(user.id)}
className="btn btn-secondary"
>
Редактировать
</button>
<button
onClick={() => presenter.handleDeleteUser(user)}
className="btn btn-danger"
>
Удалить
</button>
</div>
</div>
))}
</div>
)}
{presenter.showForm && (
<div className="form-modal">
<div className="form-content">
<h2>
{presenter.editingUser ? 'Редактирование' : 'Создание'} пользователя
</h2>
<form onSubmit={(e) => {
e.preventDefault();
presenter.handleFormSubmit();
}}>
<div className="form-group">
<label htmlFor="name">Имя:</label>
<input
id="name"
type="text"
value={presenter.formData.name}
onChange={(e) => presenter.handleFormDataChange('name', e.target.value)}
className={presenter.formErrors.name ? 'error' : ''}
required
/>
{presenter.formErrors.name && (
<span className="error-message">
{presenter.formErrors.name}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={presenter.formData.email}
onChange={(e) => presenter.handleFormDataChange('email', e.target.value)}
className={presenter.formErrors.email ? 'error' : ''}
required
/>
{presenter.formErrors.email && (
<span className="error-message">
{presenter.formErrors.email}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="role">Роль:</label>
<select
id="role"
value={presenter.formData.role}
onChange={(e) => presenter.handleFormDataChange('role', e.target.value)}
>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input
id="password"
type="password"
value={presenter.formData.password}
onChange={(e) => presenter.handleFormDataChange('password', e.target.value)}
className={presenter.formErrors.password ? 'error' : ''}
required={!presenter.editingUser}
/>
{presenter.formErrors.password && (
<span className="error-message">
{presenter.formErrors.password}
</span>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary">
{presenter.editingUser ? 'Обновить' : 'Создать'}
</button>
<button
type="button"
onClick={presenter.handleHideForm}
className="btn btn-secondary"
>
Отмена
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UserManagement;
Преимущества MVP
Плюсы:
- Максимальная тестируемость — Presenter можно тестировать без UI
- Четкое разделение ответственности — каждый компонент имеет свою роль
- Пассивный View — представление не содержит логики
- Легкость отладки — логика централизована в Presenter
- Переиспользование — Presenter можно использовать с разными View
Минусы:
- Больше кода — больше абстракций и слоев
- Сложность — требует понимания паттерна
- Производительность — дополнительные слои абстракции
- Кривая обучения — сложнее для начинающих
Когда использовать MVP
Подходит для:
- Сложные приложения — с множественной бизнес-логикой
- Высокие требования к тестированию — нужна максимальная тестируемость
- Командная разработка — четкое разделение ответственности
- Долгосрочные проекты — нужна поддерживаемость
Не подходит для:
- Простые приложения — избыточная сложность
- Быстрые прототипы — слишком много кода
- Высоконагруженные системы — производительность может страдать
Сравнение с другими паттернами
Вставить таблицу!
MVP — это идеальный выбор для приложений, где тестируемость и четкое разделение ответственности являются приоритетом!