Nội dung

DaiPhan

DaiPhan

Full-Stack Developer

Full-stack developer passionate about modern web technologies, best practices, and sharing knowledge with the community.

Skills & Expertise

JavaScript TypeScript React Node.js DevOps
150+
Articles
50k+
Readers
4.9
Rating

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

PatternComplexityData FlowTestingUse Case
MVCMediumBidirectionalMediumTraditional apps
MVVMHighUnidirectionalHighComplex UIs
Component-BasedLow-MediumUnidirectionalEasyModern 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.