L'acronyme S.O.L.I.D a été inventé par Michael Feathers à partir des principes de programmation orientée objet identifiés par Robert Cecil Martin AKA Uncle Bob ( Clean Code Handbook / Clean Coder )
Ces principes visent à rendre le code plus lisible (propre), facile à maintenir, facile à faire évoluer, réutilisable et sans duplication de code.
Par « facile », il faut comprendre que cela signifie que le coût nécessaire pour effectuer un changement à l’application devrait toujours être inférieur aux bénéfices apportés directement par ce changement.
En tant que développeurs, en suivant ces principes, nous pouvons concevoir des applications robuste, flexible, extensibles et plus faciles à maintenir.
Appliquer ces principes contribuent nettement à avoir un code de qualité et à assurer une meilleure gestion du cycle de vie de nos applications.
Note :
Comprendre les 5 principes SOLID et de les utiliser, nous permettra :
Utiliser ces principes doit devenir une habitude dans votre quotidien.
Le principe de responsabilité unique stipule qu'une classe ne doit avoir qu'une seule raison de changer, c'est-à-dire qu'une classe (composant, fonction) devrait avoir une seule responsabilité.
Cela nous permet de nous assurer qu’une fonction ne fait qu’une seule et unique chose, mais qu’elle le fait bien._ (cf. SOC : Separation Of Concern).
Mauvais exemple :
Le composant gère à la fois l'affichage et la logique de gestion des tâches.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
fetch('https://api.example.com/todos')
.then(response => response.json())
.then(data => setTodos(data))
.catch(error => console.error(error));
};
useEffect(() => {
fetchTodos(); // Récupère les tâches depuis une API
}, []);
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
{todo.title}
</div>
))}
</div>
);
};
Bon exemple :
Diviser le composant en deux :
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
fetch('https://api.example.com/todos')
.then(response => response.json())
.then(data => setTodos(data))
.catch(error => console.error(error));
};
useEffect(() => {
fetchTodos(); // Récupère les tâches depuis une API
}, []);
return (
<TodoListItems todos={todos} />
);
};
const TodoListItems = ({ todos }) => {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
);
};
Dans le mauvais exemple, le composant TodoListWidget gère à la fois la récupération des tâches depuis une API et l'affichage des tâches.
Cela viole le principe de responsabilité unique car un composant devrait faire qu'une seule et unique chose.
Dans le bon exemple, nous avons divisé le composant TodoListWidget en deux composants distincts : TodoListWidget et TodoListItems.
Le composant TodoListWidget est responsable de la récupération des tâches depuis l'API et il rend le composant TodoListItems qui est responsable de l'affichage des tâches.
Le code est plus clair, maintenable et respecte ce principe, chaque composant a une seule responsabilité.
On peux aller encore plus loin, car le composant TodoListItems lui aussi viole le principe de responsabilité unique car il s'occupe également d'afficher un item.
Celui-ci pourrait donc faire l'objet d'un composant supplémentaire TodoItem.
Question :
Pourquoi utiliser le principe de responsabilité unique ?
Le principe d'Ouverture/Fermeture préconise que les entités logicielles, telles que les classes et les modules, devraient être ouvertes pour extension, mais fermées pour modification.
Une classe ou un module devrait pouvoir être étendu pour ajouter de nouvelles fonctionnalités sans modifier son code source existant.
Cela facilite l'ajout de nouvelles méthodes sans perturber le comportement existant.
Plus simplement
- Cela signifie que l'on doit favoriser l'extension du code plutôt que sa modification.
- Cela nous demande de ne pas modifier le comportement d’une action en fonction d’un paramètre, mais plutôt d’étendre les capacités de ce paramètre grâce à une fonction définie en amont.
Mauvais exemple :
Le composant TodoListItems effectue directement l'ajout d'une nouvelle tâche.
const TodoListItems = () => {
const [todos, setTodos] = useState([]);
/* ... */
const addTodo = (newTodo) => {
setTodos([...todos, newTodo]);
};
return (
<>
<div>
<label htmlFor="new-todo-item">Add Todo</label>
<div>
<input type="text" placeholder="New Todo" value={newTodoTitle}
onChange={handleTitleChange}/>
<button
onClick={addTodo}>Add
</button>
</div>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
</>
);
};
Bon exemple :
Utiliser un gestionnaire externe pour l'ajout de nouvelles tâches.
const TodoListItems = ({ todos, addTodoHandler }) => {
/* logique necessaire au bon fonctionnement */
return (
<>
<div>
<label htmlFor="new-todo-item">Add Todo</label>
<div>
<input type="text" placeholder="New Todo" value={newTodoTitle}
onChange={handleTitleChange}/>
<button
onClick={addTodoHandler}>Add
</button>
</div>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
</>
);
};
// Gestionnaire externe pour les tâches
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const handleAddTodo = (newTodo) => {
setTodos([...todos, newTodo]);
};
return (
<>
<TodoListItems todos={todos} addTodoHandler={handleAddTodo} />
</>
);
};
Dans le mauvais exemple, le composant TodoListItems effectue directement l'ajout d'une nouvelle tâche en utilisant la fonction addTodo.
Cela viole le principe d'Ouverture/Fermeture car le code est ouvert à la modification directe pour ajouter de nouvelles fonctionnalités, ce qui peut entraîner des effets de bord indésirables.
Dans le bon exemple, nous utilisons un gestionnaire externe TodoListWidget pour gérer les tâches. Ce gestionnaire contient la logique pour ajouter de nouvelles tâches (addTodoHandler). Le composant TodoListItems reçoit les tâches et la fonction addTodoHandle en tant que props, sans se soucier de la façon dont la gestion des tâches est effectuée.
Le composant TodoListItems est fermé pour les modifications directes et ouvert à l'extension pour ajouter de nouvelles fonctionnalités. En utilisant le gestionnaire externe, on respecte le principe d' Ouverture/Fermeture.
Notez bien que dans ce bon exemple, le composant TodoListItems viole également le 1er principe de responsabilité unique comme vu précédemment. Ce n'est donc pas 100% correcte.
Le principe de Substitution de Liskov stipule qu'un objet d'une classe dérivée doit pouvoir être substitué (remplaçable) à un objet de la classe de base sans affecter la cohérence du programme.
En d'autres termes, les classes enfants ne peuvent pas faire plus ou moins que leur parent. On peut ainsi interchanger les enfants sans que cela ait d’incidence sur l’exécution du code.
La hiérarchie d'héritage est solide et évite les surprises ou les erreurs lors de l'utilisation de sous-classes.
Pour être conforme a ce principe, il y a quelques grandes conditions à respecter :
- La signature des fonctions (paramètres et retour) doit être identique entre l’enfant et le parent.
- Les paramètres de la fonction de l’enfant ne peuvent pas être plus nombreux que ceux du parent.
- Le retour de la fonction doit retourner le même type que le parent.
- Les exceptions/erreurs retournées doivent être les mêmes.
Mauvais exemple :
Le gestionnaire de tâches ne prend en charge que les tâches de type chaîne de caractères.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const addTodo = (newTodo) => {
if (typeof newTodo === 'string') {
setTodos([...todos, newTodo]);
} else {
throw new Error('Invalid todo type');
}
};
return (
<TodoListItems todos={todos} addTodo={addTodo} />
);
};
Bon exemple :
Le gestionnaire de tâches accepte tous les types de tâches valides.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const addTodo = (newTodo) => {
setTodos([...todos, newTodo]);
};
return (
<TodoListItems todos={todos} addTodo={addTodo} />
);
};
Dans le mauvais exemple, le gestionnaire de tâches TodoListWidget ne prend en charge que les tâches de type chaîne de caractères. Si un autre type de donnée est passé, il lance une erreur.
Cela viole le principe de Substitution de Liskov car le gestionnaire ne peut pas être substitué par un autre qui accepte d'autres types de tâches.
Dans le bon exemple, le gestionnaire de tâches TodoListWidget accepte n'importe quel type de tâche. Il ne fait pas de vérification de type avant d'ajouter la nouvelle tâche à la liste.
Cela respecte le principe de Substitution de Liskov car le gestionnaire peut être substitué par un autre qui accepte des types de tâches similaires sans changer son comportement global.
Question :
Pourquoi utiliser le principe de Substitution de Liskov ?
Le principe d'interface de ségrégation recommande de diviser les interfaces en fonctions spécifiques aux besoins.
Cela signifie que nous ne devons pas implémenter des méthodes ou utiliser des informations dont on n'a pas besoin. À la place, on passe un contrat avec l’entité qui en a besoin.
En séparant les interfaces (composants, fonctions ...) en de plus petites interfaces spécifiques, on évite le fardeau des méthodes inutiles et on rend l'interface plus cohérente avec les besoins spécifiques.
Mauvais exemple :
Tous les details de la tâche todo sont passés au composant TodoItemCheckbox
const TodoItemCheckbox = ({todo}) => {
return (
<>
<input type="checkbox" {todo.isCompleted ? "checked" : ""}>
</>
)
}
const TodoItem = ({todo}) => {
return (
<li>
<TodoItemCheckbox todo={todo}/>
<span>todo.title</span>
</li>
)
}
const TodoListItems = ({ todos }) => {
return (
<ul>
{todos.map(todo => (
<TodoListItem key={todo.id} todo={todo}/>
))}
</ul>
);
};
Bon exemple :
Seule l'information nécessaire est passée au composant TodoItemCheckbox
const TodoItemCheckbox = ({isChecked}) => {
return (
<>
<input type="checkbox" {isChecked ? "checked" : ""}>
</>
)
}
const TodoItem = ({todo}) => {
return (
<li>
<TodoItemCheckbox isChecked={todo.isCompleted}/>
<span>todo.title</span>
</li>
)
}
const TodoListItems = ({ todos }) => {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo}/>
))}
</ul>
);
};
Dans le mauvais code, tous les details de la tâche sont passés au composant TodoItemCheckbox alors que cela n'est pas nécessaire. On ajoute des risques d'erreurs et une complexité inutiles au composant. Cela peut être problématique car le composant se retrouve avec des fonctionnalités et/ou des informations inutiles.
Il viole le principe de "Séparation" des Interfaces qui recommande de diviser les "interfaces" en parties distinctes pour chaque fonctionnalité.
Dans le bon code, le composant TodoItemCheckbox reçois uniquement l'information requise isChecked qui correspond à son besoin au lieu de tous les détails de la tâche.
On évite ainsi d'avoir des fonctionnalités/informations inutiles . Cela respecte le principe de Ségrégation des interfaces.
Question :
Pourquoi utiliser le principe de ségrégation d'interface ?
Pour ce dernier on le traduit également par dependency injection, injection de dépendance en français.
Le principe d'inversion de dépendance, est surement l'un des plus important avec le principe de responsabilité unique. La règle veut que les classes, modules de haut niveau doivent dépendre de leurs abstractions et non pas de leurs implémentations.
Les abstractions ne doivent pas dépendre des détails, ce sont les détails qui doivent dépendre des abstractions.
Plus simplement.
On évite de passer des objets en paramètre lorsqu’une interface est disponible. On par ce fait, certain que l’objet que l'on manipule, peu importe son type, celui-ci aura les bonnes méthodes/types associés.
Cela nous demande de passer des abstractions en paramètre. Des contrats notamment grâce aux interfaces (pour les languages Orienté Objet), plutôt que les objets eux-mêmes
Mauvais exemple :
Le composant TodoListWidget dépend directement de la fonction fetchTodos.
const TodoListWidget = () => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
fetch('https://api.example.com/todos')
.then(response => response.json())
.then(data => setTodos(data))
.catch(error => console.error(error));
};
useEffect(() => {
fetchTodos(); // Récupère les tâches depuis une API
}, []);
// Utilisation des tâches récupérées
};
Bon exemple :
Utilisation d'un gestionnaire (fonction) externe pour inverser la dépendance
entre TodoListWidget et la récupération des tâches.
const TodoListWidget = ({fetchCallback}) => {
const [todos, setTodos] = useState([]);
const fetchTodos = () => {
// Code pour récupérer les tâches depuis une API ou autre
const todoItems = fetchCallback(); // appel a notre fonction callback injectée
setTodos(todoItems);
};
useEffect(() => {
fetchTodos()
}, []);
// Autres traitements
};
Dans le mauvais exemple, le composant TodoListWidget dépend directement de la fonction fetchTodos pour récupérer les tâches depuis une API. Le composant est étroitement couplé à la logique de récupération des tâches. Ce qui rend difficile le remplacement de cette logique et pour effectuer des tests unitaires.
Dans le bon exemple, nous utilisons une fonction de rappel (callback) dans fetchTodos pour inverser la dépendance entre TodoListWidget et la récupération des tâches.
Le composant TodoListWidget ne se soucie plus de la façon dont les tâches sont récupérées, mais se contente de fournir une fonction de rappel qui sera appelée lors de la récupération des données initiales.
Il est plus flexible et est indépendant de la source de données. On facilite, la mise en place de tests et nous permettra de remplacer facilement la logique de récupération des données si nécessaire. Cela respecte le principe d'Inversion de Dépendance.
Question :
Pourquoi utiliser le principe d' inversion de dépendance ?
Certain d'entre vous l'on remarqué, dans les "bon exemples" les composants TodoListWidget et TodoListItems (ce n'est pas une erreur, c'est voulu) ne respectent pas les principes de Substitution de Liskov, d' Open/Closed et de Dependency Inversion.
En effet dans le composant TodoListItems on ne peux pas remplacer le composant TodoItem. Et dans TodoListWidget on ne peux pas remplacer le composant TodoListItems.
Alors comment faire avec React pour que notre composant TodoListWidget soit extensible et personnalisable ?
C'est assez simple en fait. Nous allons nous servir majoritairement du principe d'inversion de dépendance pour pouvoir interchanger nos composants TodoListItems et TodoItem pour pouvoir respecter les principes de substitution de Liskov et d'ouverture/fermeture.
Pour pouvoir réaliser ce petit tour de passe-passe, nous allons, à notre composant TodoWidget, ajouter en paramètre des "providers". Ces "providers" sont sous forme de fonction. Ils seront chargés de nous renvoyer le rendu de nos "sous-composants" personnalisés.
Voici comment le faire pour un Item de la liste des tâches.
export const TodoListWidget = ({ fetchCallback, customListItemProvider }) => {
const [todos, setTodos] = useState([])
const fetchTodos = () => {
// Code pour récupérer les tâches depuis une API ou autre
const todoItems = fetchCallback();
setTodos(todoItems);
};
const handleAddTodo = (newTodo) => {
setTodos([...todos, newTodo])
}
const handleDeleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const handleToggleCompleted = (id) => {
setTodos(todos.map(todo => {
if (todo.id === id) {
return {...todo, completed: !todo.completed}
}
return todo
}))
}
useEffect(() => {
fetchTodos()
}, []);
return (
<div>
<h1>Todo List Widget</h1>
<hr/>
<TodoListItems todos={todos}
deleteTodoHandler={handleDeleteTodo}
toggleTodoCompletedHandler={handleToggleCompleted}
customListItemProvider={customListItemProvider}/>
</div>
)
}
export const TodoListItems = ({todos, deleteTodoHandler, toggleTodoCompletedHandler, customListItemProvider}) => {
return (
<div>
{todos.length === 0 && <p className={'text-center'}>Aucune tâche à afficher</p>}
{todos.length > 0 &&
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{customListItemProvider(todo, deleteTodoHandler, toggleTodoCompletedHandler)}
</li>
))}
</ul>
}
</div>
)
}
export const TodoItem = ({item, deleteTodoHandler, toggleTodoCompletedHandler}) => {
return (
<div>
<input id={`check-${item.id}`} type="checkbox" defaultChecked={item.completed}
onClick={() => toggleTodoCompletedHandler(item.id)}/>
<label htmlFor={`check-${item.id}`}>{item.title}</label>
<button onClick={() => deleteTodoHandler(item.id)}>X</button>
</div>
)
}
Et dans notre App.jsx
import {TodoListWidget} from "./components/Todolist/TodoListWidget.jsx";
import {getData} from "./data/todoListData.js";
import {getData as getDataExt} from "./data/todoListDataExt.js";
import {TodoItemExt} from "./components/Todolist/TodoListExtended/TodoItemExt.jsx";
import {TodoItem} from "./components/Todolist/TodoItem.jsx";
function App() {
const todoItemProvider = (todo, deleteTodoHandler, toggleTodoCompletedHandler) => {
return <TodoItem item={todo}
deleteTodoHandler={deleteTodoHandler}
toggleTodoCompletedHandler={toggleTodoCompletedHandler}/>
}
const todoItemExtProvider = (todo, deleteTodoHandler, toggleTodoCompletedHandler) => {
return <TodoItemExt item={todo}
deleteTodoHandler={deleteTodoHandler}
toggleTodoCompletedHandler={toggleTodoCompletedHandler}/>
}
return (
<div>
<div">
<TodoListWidget fetchCallback={getData} customListItemProvider={todoItemProvider}/>
<TodoListWidget fetchCallback={getDataExt} customListItemProvider={todoItemExtProvider}/>
</div>
</div>
)
}
export default App
Vous l'avez peut-être remarqué dans le code ci-dessus, j'ai supprimé la props addTodoHandler du composant TodoListItems pourquoi ? Comme mentionné précédemment, le principe de Responsabilité Unique était enfreint. Ce n'est pas le role du composant TodoListItems d'ajouter une tâche dans nos données. Son unique rôle c'est d'afficher la liste des tâches.
Nous devons donc créer un nouveau composant TodoAddForm et utiliser celui-ci dans le composant TodoListWidget pour continuer à respecter les principes SOLID.
Je vous laisse faire ce petit changement par vous même.
Retouvez un exemple complet de ce TodoListWidget respectant les principes SOLID sur mon Github : react-solid-todolist
Si vous voulez creuser encore plus ce domaine, allez donc jeter un oeil à notre formation React JS !
Que vous essayiez de scaler votre start-up, de créer votre premier site internet ou de vous reconvertir en tant que développeur, Believemy est votre nouvelle maison. Rejoignez-nous, évoluez et construisons ensemble votre projet.