Веб-архитектуры
MVVM
Паттерн архитектуры Model-View-ViewModel
MVVM — это архитектурный паттерн, который расширяет концепцию MVC, добавляя двустороннюю привязку данных между View и ViewModel. Этот паттерн особенно популярен в современных клиентских фреймворках и SPA (Single Page Applications).
Что такое MVVM?
MVVM — это архитектурный паттерн, который:
- Разделяет UI и бизнес-логику — четкое разделение ответственности
- Обеспечивает двустороннюю привязку — автоматическая синхронизация данных
- Упрощает тестирование — ViewModel можно тестировать независимо
- Повышает переиспользование — ViewModel можно использовать с разными View
Компоненты MVVM
Model (Модель)
Модель представляет данные и бизнес-логику приложения.
Ответственности:
- Структура данных
- Бизнес-правила
- Валидация
- API взаимодействие
View (Представление)
Представление — это пользовательский интерфейс, который отображает данные.
Ответственности:
- Отображение данных
- Пользовательский интерфейс
- События пользователя
- Привязка к ViewModel
ViewModel (Модель представления)
ViewModel — это посредник между Model и View, который содержит логику представления.
Ответственности:
- Преобразование данных модели для представления
- Обработка команд пользователя
- Управление состоянием UI
- Привязка данных
Схема взаимодействия
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ View │ │ ViewModel │ │ Model │
│ │ │ │ │ │
│ • UI │◄──►│ • Commands │◄──►│ • Data │
│ • Binding │ │ • State │ │ • Business │
│ • Events │ │ • Logic │ │ • API │
└─────────────┘ └─────────────┘ └─────────────┘
▲ │ │
│ │ │
└───────────────────┼───────────────────┘
│
Data Binding
Примеры реализации
MVVM в Vue.js
Model
// models/User.js
class User {
constructor(id, name, email, role = 'user') {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
this.createdAt = new Date();
}
// Валидация
validate() {
const errors = {};
if (!this.name || this.name.length < 2) {
errors.name = 'Имя должно содержать минимум 2 символа';
}
if (!this.email || !this.isValidEmail(this.email)) {
errors.email = 'Некорректный email';
}
return errors;
}
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// API методы
static async fetchAll() {
const response = await fetch('/api/users');
const data = await response.json();
return data.map(user => new User(user.id, user.name, user.email, user.role));
}
static async fetchById(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return new User(data.id, data.name, data.email, data.role);
}
async save() {
const method = this.id ? 'PUT' : 'POST';
const url = this.id ? `/api/users/${this.id}` : '/api/users';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: this.name,
email: this.email,
role: this.role
})
});
if (!response.ok) {
throw new Error('Ошибка при сохранении пользователя');
}
return await response.json();
}
async delete() {
const response = await fetch(`/api/users/${this.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Ошибка при удалении пользователя');
}
}
}
export default User;
ViewModel
// viewmodels/UserViewModel.js
import User from '../models/User.js';
class UserViewModel {
constructor() {
this.users = [];
this.currentUser = null;
this.loading = false;
this.error = null;
this.editingUser = null;
this.showForm = false;
this.formData = {
name: '',
email: '',
role: 'user'
};
this.formErrors = {};
}
// Команды
async loadUsers() {
this.loading = true;
this.error = null;
try {
this.users = await User.fetchAll();
} catch (error) {
this.error = 'Ошибка при загрузке пользователей';
console.error(error);
} finally {
this.loading = false;
}
}
async loadUser(id) {
this.loading = true;
this.error = null;
try {
this.currentUser = await User.fetchById(id);
} catch (error) {
this.error = 'Ошибка при загрузке пользователя';
console.error(error);
} finally {
this.loading = false;
}
}
showCreateForm() {
this.editingUser = null;
this.formData = {
name: '',
email: '',
role: 'user'
};
this.formErrors = {};
this.showForm = true;
}
showEditForm(user) {
this.editingUser = user;
this.formData = {
name: user.name,
email: user.email,
role: user.role
};
this.formErrors = {};
this.showForm = true;
}
hideForm() {
this.showForm = false;
this.editingUser = null;
this.formData = {
name: '',
email: '',
role: 'user'
};
this.formErrors = {};
}
async saveUser() {
this.formErrors = {};
// Создание объекта пользователя для валидации
const user = new User(
this.editingUser?.id || null,
this.formData.name,
this.formData.email,
this.formData.role
);
// Валидация
const errors = user.validate();
if (Object.keys(errors).length > 0) {
this.formErrors = errors;
return;
}
this.loading = true;
this.error = null;
try {
const savedUser = await user.save();
if (this.editingUser) {
// Обновление существующего пользователя
const index = this.users.findIndex(u => u.id === this.editingUser.id);
if (index !== -1) {
this.users[index] = savedUser;
}
} else {
// Добавление нового пользователя
this.users.push(savedUser);
}
this.hideForm();
} catch (error) {
this.error = 'Ошибка при сохранении пользователя';
console.error(error);
} finally {
this.loading = false;
}
}
async deleteUser(user) {
if (!confirm('Вы уверены, что хотите удалить пользователя?')) {
return;
}
this.loading = true;
this.error = null;
try {
await user.delete();
this.users = this.users.filter(u => u.id !== user.id);
} catch (error) {
this.error = 'Ошибка при удалении пользователя';
console.error(error);
} finally {
this.loading = false;
}
}
// Вычисляемые свойства
get hasUsers() {
return this.users.length > 0;
}
get userCount() {
return this.users.length;
}
get adminUsers() {
return this.users.filter(user => user.role === 'admin');
}
get regularUsers() {
return this.users.filter(user => user.role === 'user');
}
get isFormValid() {
return this.formData.name.length >= 2 &&
this.formData.email.includes('@') &&
Object.keys(this.formErrors).length === 0;
}
}
export default UserViewModel;
View (Vue.js компонент)
<!-- components/UserManagement.vue -->
<template>
<div class="user-management">
<div class="header">
<h1>Управление пользователями</h1>
<button @click="showCreateForm" class="btn btn-primary">
Добавить пользователя
</button>
</div>
<!-- Состояние загрузки -->
<div v-if="loading" class="loading">
Загрузка...
</div>
<!-- Ошибка -->
<div v-if="error" class="error">
{{ error }}
</div>
<!-- Список пользователей -->
<div v-if="!loading && hasUsers" class="users-list">
<div class="stats">
<p>Всего пользователей: {{ userCount }}</p>
<p>Администраторов: {{ adminUsers.length }}</p>
<p>Обычных пользователей: {{ regularUsers.length }}</p>
</div>
<div class="users-grid">
<div
v-for="user in users"
:key="user.id"
class="user-card"
>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span class="role-badge" :class="user.role">
{{ user.role }}
</span>
<div class="actions">
<button @click="showEditForm(user)" class="btn btn-secondary">
Редактировать
</button>
<button @click="deleteUser(user)" class="btn btn-danger">
Удалить
</button>
</div>
</div>
</div>
</div>
<!-- Пустое состояние -->
<div v-if="!loading && !hasUsers" class="empty-state">
<p>Пользователи не найдены</p>
<button @click="showCreateForm" class="btn btn-primary">
Добавить первого пользователя
</button>
</div>
<!-- Форма создания/редактирования -->
<div v-if="showForm" class="form-modal">
<div class="form-content">
<h2>{{ editingUser ? 'Редактирование' : 'Создание' }} пользователя</h2>
<form @submit.prevent="saveUser">
<div class="form-group">
<label for="name">Имя:</label>
<input
id="name"
v-model="formData.name"
type="text"
:class="{ 'error': formErrors.name }"
required
>
<span v-if="formErrors.name" class="error-message">
{{ formErrors.name }}
</span>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input
id="email"
v-model="formData.email"
type="email"
:class="{ 'error': formErrors.email }"
required
>
<span v-if="formErrors.email" class="error-message">
{{ formErrors.email }}
</span>
</div>
<div class="form-group">
<label for="role">Роль:</label>
<select id="role" v-model="formData.role">
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
<div class="form-actions">
<button type="submit" :disabled="!isFormValid" class="btn btn-primary">
{{ editingUser ? 'Обновить' : 'Создать' }}
</button>
<button type="button" @click="hideForm" class="btn btn-secondary">
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import UserViewModel from '../viewmodels/UserViewModel.js';
export default {
name: 'UserManagement',
data() {
return {
viewModel: new UserViewModel()
};
},
computed: {
users() { return this.viewModel.users; },
loading() { return this.viewModel.loading; },
error() { return this.viewModel.error; },
showForm() { return this.viewModel.showForm; },
editingUser() { return this.viewModel.editingUser; },
formData() { return this.viewModel.formData; },
formErrors() { return this.viewModel.formErrors; },
hasUsers() { return this.viewModel.hasUsers; },
userCount() { return this.viewModel.userCount; },
adminUsers() { return this.viewModel.adminUsers; },
regularUsers() { return this.viewModel.regularUsers; },
isFormValid() { return this.viewModel.isFormValid; }
},
methods: {
showCreateForm() { this.viewModel.showCreateForm(); },
showEditForm(user) { this.viewModel.showEditForm(user); },
hideForm() { this.viewModel.hideForm(); },
saveUser() { this.viewModel.saveUser(); },
deleteUser(user) { this.viewModel.deleteUser(user); }
},
async mounted() {
await this.viewModel.loadUsers();
}
};
</script>
<style scoped>
.user-management {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.user-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: white;
}
.role-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.role-badge.admin {
background: #ff6b6b;
color: white;
}
.role-badge.user {
background: #4ecdc4;
color: white;
}
.form-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.form-content {
background: white;
padding: 30px;
border-radius: 8px;
width: 400px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-group input.error {
border-color: #ff6b6b;
}
.error-message {
color: #ff6b6b;
font-size: 12px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
MVVM в Angular
Model
// models/user.model.ts
export interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
createdAt: Date;
}
export class UserModel {
constructor(
public id: number,
public name: string,
public email: string,
public role: 'user' | 'admin' = 'user',
public createdAt: Date = new Date()
) {}
validate(): { [key: string]: string } {
const errors: { [key: string]: string } = {};
if (!this.name || this.name.length < 2) {
errors['name'] = 'Имя должно содержать минимум 2 символа';
}
if (!this.email || !this.isValidEmail(this.email)) {
errors['email'] = 'Некорректный email';
}
return errors;
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
ViewModel (Angular Service)
// services/user.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { User, UserModel } from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class UserService {
private usersSubject = new BehaviorSubject<User[]>([]);
private loadingSubject = new BehaviorSubject<boolean>(false);
private errorSubject = new BehaviorSubject<string | null>(null);
public users$ = this.usersSubject.asObservable();
public loading$ = this.loadingSubject.asObservable();
public error$ = this.errorSubject.asObservable();
async loadUsers(): Promise<void> {
this.loadingSubject.next(true);
this.errorSubject.next(null);
try {
const response = await fetch('/api/users');
const users = await response.json();
this.usersSubject.next(users);
} catch (error) {
this.errorSubject.next('Ошибка при загрузке пользователей');
} finally {
this.loadingSubject.next(false);
}
}
async saveUser(user: UserModel): Promise<void> {
const errors = user.validate();
if (Object.keys(errors).length > 0) {
throw new Error('Ошибки валидации');
}
this.loadingSubject.next(true);
this.errorSubject.next(null);
try {
const method = user.id ? 'PUT' : 'POST';
const url = user.id ? `/api/users/${user.id}` : '/api/users';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
if (!response.ok) {
throw new Error('Ошибка при сохранении');
}
const savedUser = await response.json();
const currentUsers = this.usersSubject.value;
if (user.id) {
const index = currentUsers.findIndex(u => u.id === user.id);
currentUsers[index] = savedUser;
} else {
currentUsers.push(savedUser);
}
this.usersSubject.next([...currentUsers]);
} catch (error) {
this.errorSubject.next('Ошибка при сохранении пользователя');
} finally {
this.loadingSubject.next(false);
}
}
async deleteUser(id: number): Promise<void> {
this.loadingSubject.next(true);
this.errorSubject.next(null);
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Ошибка при удалении');
}
const currentUsers = this.usersSubject.value.filter(u => u.id !== id);
this.usersSubject.next(currentUsers);
} catch (error) {
this.errorSubject.next('Ошибка при удалении пользователя');
} finally {
this.loadingSubject.next(false);
}
}
}
View (Angular Component)
// components/user-management.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { User } from '../models/user.model';
import { UserService } from '../services/user.service';
@Component({
selector: 'app-user-management',
templateUrl: './user-management.component.html',
styleUrls: ['./user-management.component.css']
})
export class UserManagementComponent implements OnInit {
users$: Observable<User[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
showForm = false;
editingUser: User | null = null;
formData = {
name: '',
email: '',
role: 'user' as 'user' | 'admin'
};
constructor(private userService: UserService) {
this.users$ = this.userService.users$;
this.loading$ = this.userService.loading$;
this.error$ = this.userService.error$;
}
ngOnInit(): void {
this.userService.loadUsers();
}
showCreateForm(): void {
this.editingUser = null;
this.formData = { name: '', email: '', role: 'user' };
this.showForm = true;
}
showEditForm(user: User): void {
this.editingUser = user;
this.formData = { ...user };
this.showForm = true;
}
hideForm(): void {
this.showForm = false;
this.editingUser = null;
this.formData = { name: '', email: '', role: 'user' };
}
async saveUser(): Promise<void> {
try {
await this.userService.saveUser(this.formData as any);
this.hideForm();
} catch (error) {
console.error('Ошибка при сохранении:', error);
}
}
async deleteUser(user: User): Promise<void> {
if (confirm('Удалить пользователя?')) {
await this.userService.deleteUser(user.id);
}
}
}
Преимущества MVVM
Плюсы:
- Двусторонняя привязка — автоматическая синхронизация данных
- Разделение ответственности — четкое разделение логики
- Тестируемость — ViewModel легко тестировать
- Переиспользование — ViewModel можно использовать с разными View
- Реактивность — автоматическое обновление UI при изменении данных
Минусы:
- Сложность — больше абстракций и концепций
- Производительность — привязка данных может быть медленной
- Кривая обучения — требует понимания реактивного программирования
- Отладка — сложнее отлаживать привязки данных
Когда использовать MVVM
Подходит для:
- SPA приложения — Single Page Applications
- Богатые клиентские приложения — сложный UI
- Приложения с частыми обновлениями данных — реальное время
- Современные фреймворки — Angular, Vue.js, React
Не подходит для:
- Простые статические сайты — избыточная сложность
- Серверный рендеринг — MVC более подходящий
- Высоконагруженные системы — производительность может страдать
Современные реализации
Популярные фреймворки:
- Vue.js — реактивная система с двусторонней привязкой
- Angular — полнофункциональный фреймворк с MVVM
- React — компонентный подход с односторонним потоком данных
- Svelte — компилятор с реактивностью
Инструменты:
- RxJS — реактивное программирование
- MobX — управление состоянием
- Redux — предсказуемое управление состоянием
- Vuex — централизованное управление состоянием
MVVM — это современный подход к созданию интерактивных веб-приложений с богатым пользовательским интерфейсом!