SOLID принципы в JavaScript: теория и практика от А до Я
SOLID — это аббревиатура пяти фундаментальных принципов объектно-ориентированного программирования, сформулированных Робертом Мартином (дядюшкой Бобом). Хотя JavaScript не является классическим ООП-языком (он использует прототипное наследование), эти принципы применимы и к нему, особенно в контексте Node.js и современных фреймворков. Соблюдение SOLID делает код более гибким, тестируемым и поддерживаемым. В этой статье мы разберем каждый принцип с практическими примерами на JavaScript.
1. S — Single Responsibility Principle (Принцип единственной ответственности)
Класс или модуль должен иметь только одну причину для изменения. Другими словами, каждая сущность должна отвечать за одну конкретную задачу.
Плохой пример: Класс User одновременно управляет данными пользователя и отправляет email-уведомления.
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
saveToDatabase() {
// Логика сохранения в БД
console.log(`Сохранение пользователя ${this.name} в БД`);
}
sendWelcomeEmail() {
// Логика отправки email
console.log(`Отправка приветствия на ${this.email}`);
}
}
Хороший пример: Разделяем ответственность на два класса.
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
class UserRepository {
save(user) {
console.log(`Сохранение пользователя ${user.name} в БД`);
}
}
class EmailService {
sendWelcome(user) {
console.log(`Отправка приветствия на ${user.email}`);
}
}
// Использование
const user = new User('Иван', 'ivan@example.com');
const repo = new UserRepository();
const emailService = new EmailService();
repo.save(user);
emailService.sendWelcome(user);
2. O — Open-Closed Principle (Принцип открытости/закрытости)
Программные сущности должны быть открыты для расширения, но закрыты для модификации. Это означает, что новую функциональность следует добавлять через новый код, а не через изменение существующего.
Плохой пример: Класс AreaCalculator требует модификации при добавлении новой фигуры.
class AreaCalculator {
calculate(shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2;
} else if (shape.type === 'rectangle') {
return shape.width * shape.height;
}
// При добавлении новой фигуры нужно менять этот метод
}
}
Хороший пример: Используем полиморфизм и наследование.
class Shape {
area() {
throw new Error('Метод area() должен быть переопределен');
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class AreaCalculator {
calculate(shape) {
return shape.area();
}
}
// Использование
const calculator = new AreaCalculator();
const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);
console.log(calculator.calculate(circle)); // 78.54
console.log(calculator.calculate(rectangle)); // 24
3. L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Объекты в программе должны быть заменяемы экземплярами их подтипов без нарушения корректности программы. Простыми словами: если у вас есть функция, которая принимает базовый класс, она должна работать и с любым его наследником.
Плохой пример: Подкласс Square нарушает поведение родительского класса Rectangle.
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(width) {
this.width = width;
this.height = width; // Нарушение LSP: изменение ширины меняет высоту
}
setHeight(height) {
this.width = height; // Нарушение LSP
this.height = height;
}
}
// Функция, которая ожидает Rectangle
function resizeRectangle(rect) {
rect.setWidth(5);
rect.setHeight(10);
console.log(`Ожидаемая площадь: 50, фактическая: ${rect.area()}`);
}
const rect = new Rectangle(2, 3);
resizeRectangle(rect); // 50 - корректно
const square = new Square(4);
resizeRectangle(square); // 100 - некорректно
Решение: Избегайте наследования, которое меняет поведение. Лучше сделать Square отдельным классом или использовать композицию.
class Square {
constructor(side) {
this.side = side;
}
area() {
return this.side ** 2;
}
}
4. I — Interface Segregation Principle (Принцип разделения интерфейса)
Клиенты не должны зависеть от методов, которые они не используют. Вместо одного большого интерфейса лучше создать несколько маленьких и специализированных.
Плохой пример: Огромный интерфейс Worker.
class Worker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Human extends Worker {
work() { console.log('Человек работает'); }
eat() { console.log('Человек ест'); }
sleep() { console.log('Человек спит'); }
}
class Robot extends Worker {
work() { console.log('Робот работает'); }
// Роботу не нужны методы eat() и sleep(), но он вынужден их реализовывать
eat() { throw new Error('Роботы не едят'); }
sleep() { throw new Error('Роботы не спят'); }
}
Хороший пример: Разделяем интерфейсы.
class Workable {
work() { throw new Error('Должен быть реализован'); }
}
class Eatable {
eat() { throw new Error('Должен быть реализован'); }
}
class Sleepable {
sleep() { throw new Error('Должен быть реализован'); }
}
class Human {
constructor() {
Object.assign(this, new Workable(), new Eatable(), new Sleepable());
}
}
Human.prototype.work = function()