Lapa Knowledge Base
Веб-архитектуры

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 — это современный подход к созданию интерактивных веб-приложений с богатым пользовательским интерфейсом!

Copyright © 2026