Skip to main content

Що таке архітектурний патерн MVC?

MVC (Model-View-Controller) - архітектурний патерн, що ділить додаток на три компоненти: Model відповідає за дані і бізнес-логіку, View за інтерфейс, Controller стоїть між ними і обробляє ввід користувача.

Теорія

TL;DR

  • Model = шар даних (запити, валідація, стан); View = те що бачить користувач; Controller = диспетчер між ними
  • Аналогія: ресторан. Кухня (Model) готує їжу. Тарілка з їжею (View) потрапляє до гостя. Офіціант (Controller) приймає замовлення і з'єднує кухню з гостем.
  • Потік: користувач діє у View, Controller оновлює Model, Model сповіщає View
  • Використовуй MVC коли UI-логіка і логіка даних мають рости незалежно
  • Пропускай для скриптів до ~500 рядків або чистих API без View

Швидкий приклад

javascript
// Model: тільки логіка даних const UserModel = { users: [{ id: 1, name: 'Alice' }], getUser(id) { return this.users.find(u => u.id === id); } }; // View: тільки рендеринг const UserView = { render(user) { document.getElementById('app').innerHTML = `<h1>${user.name}</h1>`; } }; // Controller: обробляє ввід, з'єднує інших двох const UserController = { load(id) { const user = UserModel.getUser(id); UserView.render(user); // Результат: <h1>Alice</h1> } }; UserController.load(1);

Кожна частина робить одне. Model не знає як виглядає UI. View не знає звідки дані. Controller знає обох, але тільки щоб їх з'єднати.

Як три компоненти взаємодіють насправді

Model зберігає стан і надає методи для читання або зміни даних. Вона нічого не знає ні про View, ні про Controller. View отримує дані і рендерить їх, без жодного уявлення про їхнє джерело. Controller слухає події користувача, викликає методи Model, потім передає результат у View.

Напрямок завжди один: дія користувача потрапляє в Controller, Controller звертається до Model, дані повернулись, Controller передає їх у View. Ніяких скорочень. View ніколи не викликає Model напряму.

Ця однонаправлена дисципліна і відрізняє MVC від старого підходу де UI і дані були перемішані. Щойно ти ігноруєш це правило, компоненти починають самостійно тягнути дані, робити побічні ефекти при рендері і ламатися при кожній зміні шару даних.

Коли використовувати MVC

  • Веб-додаток з реальним UI і бекендом: MVC (Rails, ASP.NET MVC, Spring MVC)
  • Express.js API з шаблонами: підходить чудово (роути діють як Controllers, EJS або Handlebars як Views)
  • React SPA зі складним станом: краще Flux або Redux (MVC незручний з двонаправленим станом React)
  • CLI-інструмент або невеликий скрипт: пропусти патерн, звичайні функції справляться
  • Команда з 5+ розробників: MVC дозволяє паралельно працювати на окремих шарах без конфліктів
  • Мікросервіс без UI: тільки Model і Controller, шар View не потрібен

Порівняння: MVC, моноліт і Flux

АспектMVCМонолітFlux/Redux
Потік данихController → Model → ViewЧасто двонаправленийСтрого однонаправлений
ТестуванняШари ізолюються незалежноПотрібні моки всього додаткуUnit-тести actions і reducers
Паралельна розробкаКоманди на окремих шарахОдин розробник, вузьке місцеГлобальний стан, складніше ділити
Найкраще дляТрадиційні веб-додатки (Rails, ASP.NET)Прототипи до 1k рядківReact зі складним клієнтським станом

Як це працює під капотом

У браузері клік потрапляє до Controller першим через addEventListener. Controller викликає метод Model, який може виконати асинхронний запит до бази через fetch або ORM на зразок Sequelize. Коли дані готові, Model може видати кастомний event або використати pub/sub. View слухає і перерендерює.

У Rails це все відбувається на рівні HTTP: запит потрапляє до Controller-дії, яка викликає методи Model, а результат передається у View-шаблон (ERB, Handlebars, Thymeleaf). Той самий патерн, тільки через HTTP замість DOM-подій.

Типові помилки

1. View самостійно отримує дані

javascript
// Погано: View прив'язаний до Model function UserView() { const user = UserModel.getUser(1); // зламається коли Model стане async return `<div>${user.name}</div>`; } // Добре: Controller посередник UserController.load(1); // Controller викликає Model, передає результат у View

View що самостійно тягне дані здається зручним, поки джерело даних не зміниться. Тоді редагуєш кожен View замість одного методу Model.

2. Товстий Controller (God object)

Controller в підсумку робить запити до бази, валідацію, бізнес-логіку і рендеринг. 500+ рядків, неможливо тестувати. Рішення просте: Controller тільки викликає методи Model і методи View. Вся логіка залишається в Model.

3. Застарілий View після оновлення Model

javascript
// Погано: Model оновилась, View не знає Model.updateUser(id, data); View.render(null); // користувач бачить застарілі дані // Добре: Model видає event, View перерендерює Model.updateUser(id, data); // спрацьовує 'change' event model.addEventListener('change', (e) => View.render(e.detail));

Це найпоширеніша проблема "MVC View not updating" на Stack Overflow. Model оновилась, але ніхто не сповістив View.

4. Синхронний Model у Node.js

Блокування event loop синхронними запитами до бази зупиняє весь сервер. Завжди async/await для методів Model. Controller перехоплює помилки відхилених Promise і передає стан помилки у View.

Де зустрічається в реальних проєктах

  • Ruby on Rails 7: app/models/user.rb, app/views/users/show.html.erb, app/controllers/users_controller.rb
  • ASP.NET Core 8: Models/User.cs, Views/User/Index.cshtml, Controllers/UserController.cs
  • Spring MVC (Java): @Service для логіки Model, Thymeleaf для View, @Controller для роутингу
  • Backbone.js 1.4: побудований навколо MVC, model.bind() з'єднує з view.render() напряму
  • Django використовує варіант MVT (Model-View-Template), де "View" поводиться ближче до Controller

Питання на співбесіді

Q: Опиши MVC-потік для форми редагування користувача.
A: Користувач заповнює і відправляє форму (View). Controller перехоплює onSubmit, валідує дані, викликає Model.save(). Model зберігає і видає подію change. Controller отримує подію і просить View відрендерити повідомлення про успіх.

Q: Як тестувати кожен шар MVC незалежно?
A: Для Model: тестова база або мок ORM. Для Controller: заглушка Model через sinon.js і перевірка що викликані потрібні методи View. Для View: передаєш фіктивні дані напряму і знімаєш snapshot.

Q: У чому різниця між MVC і MVVM?
A: У MVVM (Vue.js, Angular) ViewModel прив'язується до View двонаправлено. Зміни у View автоматично оновлюють ViewModel і навпаки. У MVC Controller не прив'язаний до даних View, він передає дані явно через виклики методів.

Q: Як обробляти помилки Model у View?
A: Controller огортає виклик Model у try/catch, або обробляє відхилення Promise, потім викликає View.renderError(error) замість View.render(data). View отримує об'єкт стану помилки, а не сирий exception.

Q: Як масштабувати MVC на micro-frontends?
A: Кожен мікрофронтенд отримує свій Controller і View. Спільний стан Model передається через event bus (RxJS Subject або власний EventEmitter). Shadow DOM ізолює View від витоку стилів і подій між мікрододатками.

Q: Чи актуальний MVC у 2024 з React hooks?
A: Hooks по суті є логікою Controller, винесеною у функції. useEffect обробляє сайд-ефекти так само як Controller. Next.js App Router має структуру близьку до MVC: серверні компоненти як Model і Controller, клієнтські як View. Патерн живий, просто не завжди так і називається.

Приклади

Базовий MVC: завантаження користувача на vanilla JS

javascript
// Model const UserModel = { users: [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' } ], getUser(id) { return this.users.find(u => u.id === id) || null; } }; // View const UserView = { render(user) { const app = document.getElementById('app'); if (!user) { app.innerHTML = '<p>Користувача не знайдено</p>'; return; } app.innerHTML = `<h1>${user.name}</h1><p>${user.email}</p>`; } }; // Controller const UserController = { init() { document.getElementById('load-btn').addEventListener('click', () => { const user = UserModel.getUser(1); UserView.render(user); // Результат: Alice / alice@example.com }); } }; UserController.init();

Model не знає що є кнопка або DOM. View не знає звідки дані. В цьому і весь сенс розділення.

Середній рівень: Express.js REST Controller

javascript
const express = require('express'); const Database = require('better-sqlite3'); const db = new Database('app.db'); const app = express(); app.use(express.json()); // Model: тільки робота з базою даних const UserModel = { getUser(id) { return db.prepare('SELECT * FROM users WHERE id = ?').get(id); }, updateUser(id, data) { db.prepare('UPDATE users SET name = ? WHERE id = ?').run(data.name, id); return this.getUser(id); } }; // Controller: Express route handlers і є шар Controller app.get('/users/:id', (req, res) => { const user = UserModel.getUser(req.params.id); if (!user) return res.status(404).json({ error: 'Not found' }); res.json(user); // Результат: { id: 1, name: 'Alice' } }); app.put('/users/:id', (req, res) => { const updated = UserModel.updateUser(req.params.id, req.body); res.json(updated); // Результат: { id: 1, name: 'Bob' } }); // View: JSON-відповідь і є View у REST API

У REST API View це JSON-відповідь. Розділення залишається: Model розмовляє з базою, Controller обробляє запит і відповідь, View складається з даних які Controller надає.

Просунутий рівень: Model з pub/sub, View підписується напряму

javascript
// Model з нотифікацією через events (не знає хто слухає) class UserModel extends EventTarget { constructor() { super(); this.data = { id: 1, name: 'Alice' }; } update(name) { this.data.name = name; this.dispatchEvent(new CustomEvent('change', { detail: { ...this.data } })); } } const model = new UserModel(); // View підписується на події Model (Controller налаштовує це при ініціалізації) model.addEventListener('change', (e) => { document.getElementById('username').textContent = e.detail.name; // Результат: DOM оновлюється миттєво без повного перерендеру }); // Controller document.getElementById('save-btn').addEventListener('click', () => { const newName = document.getElementById('name-input').value; model.update(newName); // спрацьовує 'change' event вище });

Одне спостереження з практики: View що підписується на Model events виглядає чисто у vanilla JS, але в React 18+ обходить reconciler. Оберни оновлення від Model у useEffect, або використовуй Zustand, щоб залишитись у циклі рендерингу React.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?