Frontend Architecture Patterns: MVC, MVVM và Component-Based
So sánh các kiến trúc frontend phổ biến và khi nào nên sử dụng
Frontend Architecture Patterns: MVC, MVVM và Component-Based
Chọn đúng kiến trúc frontend là quan trọng cho scalability và maintainability của ứng dụng. Bài viết này sẽ explore các patterns phổ biến và use cases của chúng.
MVC (Model-View-Controller)
Core Concepts
User Input → Controller → Model → View → User Interface
↑ ↓
└──────────────────────┘
Implementation in Vanilla JavaScript
// Model
class UserModel {
constructor() {
this.users = [];
this.observers = [];
}
addUser(user) {
this.users.push(user);
this.notifyObservers();
}
getUsers() {
return this.users;
}
addObserver(observer) {
this.observers.push(observer);
}
notifyObservers() {
this.observers.forEach(observer => observer.update());
}
}
// View
class UserView {
constructor() {
this.app = document.getElementById('app');
this.controller = null;
}
setController(controller) {
this.controller = controller;
}
render(users) {
this.app.innerHTML = `
<div>
<h2>Users</h2>
<ul>
${users.map(user => `<li>${user.name}</li>`).join('')}
</ul>
<input type="text" id="userInput" placeholder="Enter user name">
<button onclick="this.controller.addUser()">Add User</button>
</div>
`;
}
}
// Controller
class UserController {
constructor(model, view) {
this.model = model;
this.view = view;
this.view.setController(this);
this.model.addObserver(this.view);
this.view.render(this.model.getUsers());
}
addUser() {
const input = document.getElementById('userInput');
const name = input.value.trim();
if (name) {
this.model.addUser({ name });
input.value = '';
}
}
}
// Initialize
const app = new UserController(new UserModel(), new UserView());
MVC in React (with Context API)
// Model (Context)
const UserContext = React.createContext();
const UserProvider = ({ children }) => {
const [users, setUsers] = useState([]);
const addUser = (user) => {
setUsers(prev => [...prev, user]);
};
const getUsers = () => users;
return (
<UserContext.Provider value={{ users, addUser, getUsers }}>
{children}
</UserContext.Provider>
);
};
// Controller (Custom Hook)
const useUserController = () => {
const { users, addUser } = useContext(UserContext);
const handleAddUser = (name) => {
if (name.trim()) {
addUser({ name: name.trim(), id: Date.now() });
}
};
return { users, handleAddUser };
};
// View (Component)
const UserView = () => {
const { users } = useContext(UserContext);
const { handleAddUser } = useUserController();
const [inputValue, setInputValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
handleAddUser(inputValue);
setInputValue('');
};
return (
<div>
<h2>Users</h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter user name"
/>
<button type="submit">Add User</button>
</form>
</div>
);
};
MVVM (Model-View-ViewModel)
Core Concepts
View ↔ ViewModel ↔ Model
↕ ↕ ↕
Binding Commands Data Access
Implementation with Vue.js
// Model
class ProductModel {
constructor() {
this.products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 25 }
];
}
getProducts() {
return this.products;
}
addProduct(product) {
this.products.push({ ...product, id: Date.now() });
}
updateProduct(id, updatedProduct) {
const index = this.products.findIndex(p => p.id === id);
if (index !== -1) {
this.products[index] = { ...this.products[index], ...updatedProduct };
}
}
deleteProduct(id) {
this.products = this.products.filter(p => p.id !== id);
}
}
// ViewModel (Vue Component)
<template>
<div class="product-manager">
<h2>Product Management</h2>
<!-- Product List -->
<div class="product-list">
<div v-for="product in products" :key="product.id" class="product-item">
<span>{{ product.name }} - ${{ product.price }}</span>
<button @click="editProduct(product)">Edit</button>
<button @click="deleteProduct(product.id)">Delete</button>
</div>
</div>
<!-- Add/Edit Form -->
<form @submit.prevent="saveProduct" class="product-form">
<input
v-model="currentProduct.name"
placeholder="Product name"
required
/>
<input
v-model.number="currentProduct.price"
type="number"
placeholder="Price"
required
/>
<button type="submit">
{{ editingProduct ? 'Update' : 'Add' }} Product
</button>
<button v-if="editingProduct" @click="cancelEdit" type="button">
Cancel
</button>
</form>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue'
export default {
name: 'ProductManager',
setup() {
// Model instance
const model = new ProductModel()
// ViewModel state
const products = ref([])
const currentProduct = reactive({ name: '', price: 0 })
const editingProduct = ref(false)
const editingId = ref(null)
// Computed properties
const totalValue = computed(() =>
products.value.reduce((sum, p) => sum + p.price, 0)
)
const productCount = computed(() => products.value.length)
// Commands (methods)
const loadProducts = () => {
products.value = model.getProducts()
}
const saveProduct = () => {
if (editingProduct.value) {
model.updateProduct(editingId.value, { ...currentProduct })
} else {
model.addProduct({ ...currentProduct })
}
resetForm()
loadProducts()
}
const editProduct = (product) => {
currentProduct.name = product.name
currentProduct.price = product.price
editingProduct.value = true
editingId.value = product.id
}
const deleteProduct = (id) => {
if (confirm('Are you sure you want to delete this product?')) {
model.deleteProduct(id)
loadProducts()
}
}
const cancelEdit = () => {
resetForm()
}
const resetForm = () => {
currentProduct.name = ''
currentProduct.price = 0
editingProduct.value = false
editingId.value = null
}
// Initialize
onMounted(() => {
loadProducts()
})
return {
products,
currentProduct,
editingProduct,
totalValue,
productCount,
saveProduct,
editProduct,
deleteProduct,
cancelEdit
}
}
}
</script>
MVVM with Angular
// Model (Interface)
export interface User {
id: number;
name: string;
email: string;
role: string;
}
export interface UserService {
getUsers(): Observable<User[]>;
addUser(user: Omit<User, 'id'>): Observable<User>;
updateUser(id: number, user: Partial<User>): Observable<User>;
deleteUser(id: number): Observable<void>;
}
// ViewModel (Component)
import { Component, OnInit } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
@Component({
selector: 'app-user-manager',
template: `
<div class="user-manager">
<h2>User Management</h2>
<!-- Search and Filter -->
<div class="controls">
<input
type="text"
[(ngModel)]="searchTerm"
(ngModelChange)="updateFilter()"
placeholder="Search users..."
/>
<select [(ngModel)]="selectedRole" (change)="updateFilter()">
<option value="">All Roles</option>
<option *ngFor="let role of roles" [value]="role">
{{ role }}
</option>
</select>
</div>
<!-- User Statistics -->
<div class="stats">
<p>Total Users: {{ (filteredUsers$ | async)?.length }}</p>
<p>Admins: {{ adminCount$ | async }}</p>
<p>Regular Users: {{ regularUserCount$ | async }}</p>
</div>
<!-- User List -->
<div class="user-list">
<div
*ngFor="let user of filteredUsers$ | async"
class="user-item"
[class.selected]="user.id === selectedUser?.id"
(click)="selectUser(user)"
>
<span>{{ user.name }} ({{ user.role }})</span>
<button (click)="deleteUser(user.id); $event.stopPropagation()">
Delete
</button>
</div>
</div>
<!-- User Form -->
<form (ngSubmit)="saveUser()" class="user-form">
<input
type="text"
[(ngModel)]="userForm.name"
name="name"
placeholder="Name"
required
/>
<input
type="email"
[(ngModel)]="userForm.email"
name="email"
placeholder="Email"
required
/>
<select [(ngModel)]="userForm.role" name="role" required>
<option *ngFor="let role of roles" [value]="role">
{{ role }}
</option>
</select>
<button type="submit">
{{ selectedUser ? 'Update' : 'Add' }} User
</button>
<button type="button" (click)="cancelEdit()">Cancel</button>
</form>
</div>
`,
styleUrls: ['./user-manager.component.scss']
})
export class UserManagerComponent implements OnInit {
// ViewModel state
users$ = new BehaviorSubject<User[]>([]);
filteredUsers$ = new BehaviorSubject<User[]>([]);
selectedUser: User | null = null;
searchTerm = '';
selectedRole = '';
// Form state
userForm = {
name: '',
email: '',
role: 'user'
};
// Available roles
roles = ['admin', 'user', 'moderator'];
// Computed properties as observables
adminCount$ = this.users$.pipe(
map(users => users.filter(u => u.role === 'admin').length)
);
regularUserCount$ = this.users$.pipe(
map(users => users.filter(u => u.role === 'user').length)
);
constructor(private userService: UserService) {}
ngOnInit() {
this.loadUsers();
}
// Commands
loadUsers() {
this.userService.getUsers().subscribe(users => {
this.users$.next(users);
this.updateFilter();
});
}
updateFilter() {
const filtered = this.users$.value.filter(user => {
const matchesSearch = user.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(this.searchTerm.toLowerCase());
const matchesRole = !this.selectedRole || user.role === this.selectedRole;
return matchesSearch && matchesRole;
});
this.filteredUsers$.next(filtered);
}
selectUser(user: User) {
this.selectedUser = user;
this.userForm = {
name: user.name,
email: user.email,
role: user.role
};
}
saveUser() {
if (this.selectedUser) {
this.userService.updateUser(this.selectedUser.id, this.userForm).subscribe(() => {
this.loadUsers();
this.cancelEdit();
});
} else {
this.userService.addUser(this.userForm).subscribe(() => {
this.loadUsers();
this.resetForm();
});
}
}
deleteUser(id: number) {
if (confirm('Are you sure you want to delete this user?')) {
this.userService.deleteUser(id).subscribe(() => {
this.loadUsers();
if (this.selectedUser?.id === id) {
this.cancelEdit();
}
});
}
}
cancelEdit() {
this.selectedUser = null;
this.resetForm();
}
resetForm() {
this.userForm = {
name: '',
email: '',
role: 'user'
};
}
}
Component-Based Architecture
Core Principles
// 1. Single Responsibility
const UserAvatar = ({ user, size = 'medium' }) => (
<img
src={user.avatarUrl}
alt={user.name}
className={`avatar avatar--${size}`}
/>
);
// 2. Composition over Inheritance
const Card = ({ children, header, footer, className = '' }) => (
<div className={`card ${className}`}>
{header && <div className="card__header">{header}</div>}
<div className="card__body">{children}</div>
{footer && <div className="card__footer">{footer}</div>}
</div>
);
const UserProfileCard = ({ user }) => (
<Card
header={<h3>{user.name}</h3>}
footer={<button>Follow</button>}
>
<UserAvatar user={user} size="large" />
<p>{user.bio}</p>
<UserStats user={user} />
</Card>
);
// 3. Props Interface Design
const Button = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
loading = false,
onClick,
icon,
...props
}) => {
const handleClick = (e) => {
if (!disabled && !loading && onClick) {
onClick(e);
}
};
return (
<button
className={`btn btn--${variant} btn--${size}`}
disabled={disabled || loading}
onClick={handleClick}
{...props}
>
{loading && <Spinner size="small" />}
{icon && <Icon name={icon} />}
{children}
</button>
);
};
Advanced Component Patterns
// Higher-Order Components (HOC)
function withLoading(WrappedComponent) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <LoadingSpinner />;
}
return <WrappedComponent {...props} />;
};
}
// Render Props
class DataFetcher extends React.Component {
state = { data: null, loading: true, error: null };
componentDidMount() {
this.fetchData();
}
fetchData = async () => {
try {
this.setState({ loading: true, error: null });
const response = await fetch(this.props.url);
const data = await response.json();
this.setState({ data, loading: false });
} catch (error) {
this.setState({ error, loading: false });
}
};
render() {
return this.props.children({
data: this.state.data,
loading: this.state.loading,
error: this.state.error,
refetch: this.fetchData
});
}
}
// Custom Hooks
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error saving to localStorage:', error);
}
};
return [storedValue, setValue];
}
// Compound Components
const Toggle = ({ children }) => {
const [on, setOn] = useState(false);
const toggle = () => setOn(!on);
return React.Children.map(children, child =>
React.cloneElement(child, { on, toggle })
);
};
const ToggleOn = ({ on, children }) => (on ? children : null);
const ToggleOff = ({ on, children }) => (on ? null : children);
const ToggleButton = ({ on, toggle, ...props }) => (
<button onClick={toggle} {...props}>
{on ? 'On' : 'Off'}
</button>
);
// Usage
<Toggle>
<ToggleOn>The button is on</ToggleOn>
<ToggleOff>The button is off</ToggleOff>
<ToggleButton />
</Toggle>
So Sánh và Lựa Chọn
Decision Matrix
| Pattern | Complexity | Data Flow | Testing | Use Case |
|---|---|---|---|---|
| MVC | Medium | Bidirectional | Medium | Traditional apps |
| MVVM | High | Unidirectional | High | Complex UIs |
| Component-Based | Low-Medium | Unidirectional | Easy | Modern UIs |
When to Use What
// Use MVC when:
// - Simple applications
// - Server-side rendering
// - Clear separation needed
// Use MVVM when:
// - Complex data binding
// - Rich user interfaces
// - Two-way data binding needed
// Use Component-Based when:
// - Reusable UI components
// - Modern frameworks (React, Vue, Angular)
// - Team collaboration
Hybrid Approach
// Combining patterns based on needs
const App = () => {
// Component-based architecture
return (
<UserProvider> {/* MVVM-like data binding */}
<Router>
<Routes>
<Route path="/users" element={<UserList />} />
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</Router>
</UserProvider>
);
};
// UserList component uses MVVM pattern internally
const UserList = () => {
const { users, loading, error, addUser } = useUsers(); // ViewModel
if (loading) return <Loading />;
if (error) return <Error message={error} />;
return (
<div>
<UserTable users={users} />
<AddUserForm onAddUser={addUser} />
</div>
);
};
Kết luận
Không có “one-size-fits-all” architecture. Consider:
- Team size and experience
- Application complexity
- Performance requirements
- Maintainability needs
Bài viết này là phần đầu tiên trong series về Frontend Architecture. Trong các bài tiếp theo, chúng ta sẽ explore Micro-frontends và State Management patterns.