MFormations
Modern Frontend Engineering

Chapitre 20

Chapitre 20 — Corrections

> Corrigés détaillés des 40 exercices du chapitre 18, avec solutions complètes, explications, et notes d'optimisation.

Corrigés des Exercices

Introduction

Ce chapitre fournit les corrigés détaillés de tous les exercices du chapitre 18. Chaque corrigé comprend :

  • La solution complète avec code
  • L'explication de la démarche
  • Les erreurs fréquentes à éviter
  • Les optimisations possibles
  • Le niveau de difficulté

Niveau Débutant

Exercice 1 : Page HTML sémantique

Objectif : Créer une page HTML structurée avec les balises sémantiques.

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Mon article de blog</title>
</head>
<body>
  <header>
    <nav>
      <ul>
        <li><a href="/">Accueil</a></li>
        <li><a href="/blog">Blog</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  </header>

  <main>
    <article>
      <h1>Titre de l'article</h1>
      <p>Publié le <time datetime="2026-07-28">28 juillet 2026</time></p>
      <section>
        <h2>Introduction</h2>
        <p>Contenu de l'introduction...</p>
      </section>
      <section>
        <h2>Développement</h2>
        <p>Contenu principal...</p>
      </section>
    </article>
    <aside>
      <h2>Articles similaires</h2>
      <ul>
        <li><a href="/article-2">Article 2</a></li>
        <li><a href="/article-3">Article 3</a></li>
      </ul>
    </aside>
  </main>

  <footer>
    <p>&copy; 2026 Mon Blog</p>
  </footer>
</body>
</html>

Explication : L'utilisation de balises sémantiques (header, nav, main, article, section, aside, footer) améliore le SEO, l'accessibilité et la maintenabilité.

Erreurs fréquentes :

  • Utiliser des div pour tout (div-itis)
  • Oublier le lang dans <html>
  • Absence de main
  • Mauvais nesting des headings

Exercice 2 : Layout CSS Flexbox

Objectif : Créer une barre de navigation responsive avec Flexbox.

.nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: #1a1a2e;
  color: white;
}

.nav__logo {
  font-size: 1.5rem;
  font-weight: bold;
}

.nav__links {
  display: flex;
  gap: 1.5rem;
  list-style: none;
}

.nav__links a {
  color: white;
  text-decoration: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  transition: background 0.2s;
}

.nav__links a:hover {
  background: rgba(255, 255, 255, 0.1);
}

.nav__toggle {
  display: none;
  background: none;
  border: none;
  color: white;
  font-size: 1.5rem;
}

@media (max-width: 768px) {
  .nav__links {
    display: none;
    flex-direction: column;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: #1a1a2e;
    padding: 1rem;
  }

  .nav__links.is-active {
    display: flex;
  }

  .nav__toggle {
    display: block;
  }
}

Exercice 3 : Fonctions JavaScript

Objectif : Implémenter une fonction de debounce.

function debounce(fn, delay) {
  let timeoutId = null;
  
  return function (...args) {
    const context = this;
    
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    timeoutId = setTimeout(() => {
      fn.apply(context, args);
      timeoutId = null;
    }, delay);
  };
}

// Utilisation
const handleSearch = debounce((query) => {
  console.log('Recherche:', query);
}, 300);

handleSearch('a');
handleSearch('ab');
handleSearch('abc');
// Seul 'abc' sera loggé après 300ms

Explication : Le debounce attend que l'utilisateur arrête de taper avant d'exécuter la fonction, évitant les appels API superflus.


Exercice 4 : Manipulation du DOM

class Accordion {
  constructor(container) {
    this.container = container;
    this.items = container.querySelectorAll('.accordion-item');
    this.init();
  }

  init() {
    this.items.forEach(item => {
      const header = item.querySelector('.accordion-header');
      header.addEventListener('click', () => this.toggle(item));
    });
  }

  toggle(item) {
    const isOpen = item.classList.contains('is-open');
    
    // Fermer tous les autres
    this.items.forEach(i => {
      i.classList.remove('is-open');
      i.querySelector('.accordion-content').style.maxHeight = '0';
    });
    
    // Ouvrir si fermé
    if (!isOpen) {
      item.classList.add('is-open');
      const content = item.querySelector('.accordion-content');
      content.style.maxHeight = content.scrollHeight + 'px';
    }
  }
}

Exercice 5 : Git Workflow

Commandes :

git checkout -b feature/navbar
git add .
git commit -m "feat(navbar): add responsive navigation component"
git push -u origin feature/navbar
# Create PR on GitHub
git checkout main
git pull
git merge feature/navbar
git push

Niveau Intermédiaire

Exercice 6 : Custom Hook React

import { useState, useEffect, useCallback } from 'react';

interface UseApiResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useApi<T>(url: string): UseApiResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [refetchCount, setRefetchCount] = useState(0);

  const refetch = useCallback(() => {
    setRefetchCount(c => c + 1);
  }, []);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        const json = await response.json();
        if (!cancelled) {
          setData(json);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();
    return () => { cancelled = true; };
  }, [url, refetchCount]);

  return { data, loading, error, refetch };
}

Exercice 7 : TypeScript Generics

// Builder pattern générique
class QueryBuilder<T extends Record<string, unknown>> {
  private conditions: string[] = [];
  private orderField?: keyof T;
  private orderDirection: 'ASC' | 'DESC' = 'ASC';
  private limitCount?: number;

  where<K extends keyof T>(field: K, operator: string, value: T[K]): this {
    const escaped = typeof value === 'string' ? `'${value}'` : value;
    this.conditions.push(`${String(field)} ${operator} ${escaped}`);
    return this;
  }

  orderBy(field: keyof T, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.orderField = field;
    this.orderDirection = direction;
    return this;
  }

  limit(count: number): this {
    this.limitCount = count;
    return this;
  }

  build(): string {
    let query = `SELECT * FROM ${this.getTableName()}`;
    
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(' AND ')}`;
    }
    
    if (this.orderField) {
      query += ` ORDER BY ${String(this.orderField)} ${this.orderDirection}`;
    }
    
    if (this.limitCount) {
      query += ` LIMIT ${this.limitCount}`;
    }
    
    return query;
  }

  private getTableName(): string {
    return 'items';
  }
}

// Utilisation
interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

const query = new QueryBuilder<User>()
  .where('age', '>=', 18)
  .where('name', 'LIKE', '%John%')
  .orderBy('name', 'ASC')
  .limit(10)
  .build();

Exercice 8 : Tests avec Vitest

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchComponent } from './SearchComponent';

describe('SearchComponent', () => {
  const mockOnSearch = vi.fn();

  beforeEach(() => {
    mockOnSearch.mockClear();
  });

  it('renders search input', () => {
    render(<SearchComponent onSearch={mockOnSearch} />);
    expect(screen.getByRole('searchbox')).toBeDefined();
  });

  it('calls onSearch when typing after debounce', async () => {
    const user = userEvent.setup();
    render(<SearchComponent onSearch={mockOnSearch} debounceMs={300} />);
    
    await user.type(screen.getByRole('searchbox'), 'hello');
    
    // Debounce attend 300ms
    await new Promise(r => setTimeout(r, 350));
    
    expect(mockOnSearch).toHaveBeenCalledWith('hello');
  });

  it('shows loading state', () => {
    render(<SearchComponent onSearch={mockOnSearch} loading={true} />);
    expect(screen.getByRole('status')).toBeDefined();
  });
});

Exercice 9 : Appels API avec gestion d'erreur

class ApiClient {
  private baseUrl: string;
  private interceptors: Array<(config: RequestInit) => RequestInit> = [];

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  addInterceptor(fn: (config: RequestInit) => RequestInit) {
    this.interceptors.push(fn);
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    let config: RequestInit = {
      headers: {
        'Content-Type': 'application/json',
      },
      ...options,
    };

    for (const interceptor of this.interceptors) {
      config = interceptor(config);
    }

    const response = await fetch(`${this.baseUrl}${endpoint}`, config);

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new ApiError(
        error.message || response.statusText,
        response.status,
        error
      );
    }

    return response.json();
  }

  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  async post<T>(endpoint: string, body: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }
}

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public data: unknown
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

Exercice 10 : State Management avec Context

import React, { createContext, useContext, useReducer, ReactNode } from 'react';

// State
interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
}

// Actions
type AuthAction =
  | { type: 'LOGIN'; payload: User }
  | { type: 'LOGOUT' }
  | { type: 'UPDATE_PROFILE'; payload: Partial<User> };

// Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'LOGIN':
      return { user: action.payload, isAuthenticated: true };
    case 'LOGOUT':
      return { user: null, isAuthenticated: false };
    case 'UPDATE_PROFILE':
      if (!state.user) return state;
      return { ...state, user: { ...state.user, ...action.payload } };
    default:
      return state;
  }
}

// Context
interface AuthContextType extends AuthState {
  login: (user: User) => void;
  logout: () => void;
  updateProfile: (data: Partial<User>) => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

// Provider
function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isAuthenticated: false,
  });

  const login = (user: User) => dispatch({ type: 'LOGIN', payload: user });
  const logout = () => dispatch({ type: 'LOGOUT' });
  const updateProfile = (data: Partial<User>) =>
    dispatch({ type: 'UPDATE_PROFILE', payload: data });

  return (
    <AuthContext.Provider value={{ ...state, login, logout, updateProfile }}>
      {children}
    </AuthContext.Provider>
  );
}

// Hook
function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Niveau Avancé

Exercice 11 : Architecture Clean Architecture

// Domain Layer - Entities
interface TodoEntity {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

// Domain Layer - Use Cases
class CreateTodoUseCase {
  constructor(private todoRepository: TodoRepository) {}

  async execute(title: string): Promise<TodoEntity> {
    if (!title || title.trim().length === 0) {
      throw new Error('Title is required');
    }
    if (title.length > 100) {
      throw new Error('Title must be less than 100 characters');
    }
    return this.todoRepository.save({
      id: crypto.randomUUID(),
      title: title.trim(),
      completed: false,
      createdAt: new Date(),
    });
  }
}

// Application Layer - Ports
interface TodoRepository {
  save(todo: TodoEntity): Promise<TodoEntity>;
  findAll(): Promise<TodoEntity[]>;
  update(id: string, data: Partial<TodoEntity>): Promise<TodoEntity>;
  delete(id: string): Promise<void>;
}

// Infrastructure Layer - Adapters
class ApiTodoRepository implements TodoRepository {
  constructor(private apiClient: ApiClient) {}

  async save(todo: TodoEntity): Promise<TodoEntity> {
    return this.apiClient.post('/todos', todo);
  }

  async findAll(): Promise<TodoEntity[]> {
    return this.apiClient.get('/todos');
  }

  async update(id: string, data: Partial<TodoEntity>): Promise<TodoEntity> {
    return this.apiClient.put(`/todos/${id}`, data);
  }

  async delete(id: string): Promise<void> {
    return this.apiClient.delete(`/todos/${id}`);
  }
}

// Presentation Layer
function TodoList() {
  const createTodo = new CreateTodoUseCase(new ApiTodoRepository(apiClient));
  const [todos, setTodos] = useState<TodoEntity[]>([]);

  const handleCreate = async (title: string) => {
    try {
      const todo = await createTodo.execute(title);
      setTodos(prev => [...prev, todo]);
    } catch (error) {
      // Gestion d'erreur UI
    }
  };
}

Exercice 12 : Performance - Virtualisation

import { useRef, useState, useCallback, useEffect, ReactNode } from 'react';

interface VirtualListProps<T> {
  items: T[];
  itemHeight: number;
  renderItem: (item: T, index: number) => ReactNode;
  overscan?: number;
}

function VirtualList<T>({
  items,
  itemHeight,
  renderItem,
  overscan = 3,
}: VirtualListProps<T>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [containerHeight, setContainerHeight] = useState(0);

  useEffect(() => {
    if (containerRef.current) {
      setContainerHeight(containerRef.current.clientHeight);
    }
  }, []);

  const handleScroll = useCallback(() => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop);
    }
  }, []);

  const totalHeight = items.length * itemHeight;
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(
    items.length,
    Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
  );

  const visibleItems = items.slice(startIndex, endIndex);

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{ height: '100%', overflow: 'auto' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        {visibleItems.map((item, index) => (
          <div
            key={startIndex + index}
            style={{
              position: 'absolute',
              top: (startIndex + index) * itemHeight,
              height: itemHeight,
              left: 0,
              right: 0,
            }}
          >
            {renderItem(item, startIndex + index)}
          </div>
        ))}
      </div>
    </div>
  );
}

Exercice 13 : SSR avec Next.js App Router

// app/page.tsx
async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function HomePage() {
  const posts = await getPosts();

  return (
    <main>
      <h1>Blog</h1>
      <div className="grid">
        {posts.map(post => (
          <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </article>
        ))}
      </div>
    </main>
  );
}

// app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Erreur de chargement</h2>
      <button onClick={reset}>Réessayer</button>
    </div>
  );
}

// app/loading.tsx
export default function Loading() {
  return <div className="skeleton">Chargement...</div>;
}

Niveau Expert

Exercice 14 : Micro-Frontends avec Module Federation

// Host - webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        products: 'products@http://localhost:3001/remoteEntry.js',
        cart: 'cart@http://localhost:3002/remoteEntry.js',
        auth: 'auth@http://localhost:3003/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^19.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
      },
    }),
  ],
};

// Remote - products/src/App.tsx
const ProductsApp = () => {
  return (
    <div>
      <h2>Catalogue Produits</h2>
      <ProductList />
    </div>
  );
};

export default ProductsApp;

Exercice 15 : WebAssembly avec Rust

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct ImageProcessor {
    width: u32,
    height: u32,
    pixels: Vec<u8>,
}

#[wasm_bindgen]
impl ImageProcessor {
    pub fn new(width: u32, height: u32, pixels: Vec<u8>) -> ImageProcessor {
        ImageProcessor { width, height, pixels }
    }

    pub fn apply_grayscale(&mut self) {
        for pixel in self.pixels.chunks_mut(4) {
            let r = pixel[0] as f32;
            let g = pixel[1] as f32;
            let b = pixel[2] as f32;
            let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
            pixel[0] = gray;
            pixel[1] = gray;
            pixel[2] = gray;
        }
    }

    pub fn apply_sepia(&mut self) {
        for pixel in self.pixels.chunks_mut(4) {
            let r = pixel[0] as f32;
            let g = pixel[1] as f32;
            let b = pixel[2] as f32;
            pixel[0] = (r * 0.393 + g * 0.769 + b * 0.189).min(255.0) as u8;
            pixel[1] = (r * 0.349 + g * 0.686 + b * 0.168).min(255.0) as u8;
            pixel[2] = (r * 0.272 + g * 0.534 + b * 0.131).min(255.0) as u8;
        }
    }
}

Grille d'évaluation

NiveauCritèresPoints
DébutantSyntaxe correcte, structure, sémantique10
IntermédiairePatterns, tests, types20
AvancéArchitecture, performance, maintenabilité30
ExpertInnovation, optimisation, production-ready40

Critères transverses

  • Qualité du code : nommage, structure, commentaires utiles
  • Tests : couverture, edge cases, mocking
  • Performance : complexité algorithmique, rendering
  • Accessibilité : ARIA, navigation clavier, contrast
  • Documentation : README, JSDoc, ADR