Недавно мы публиковали материал о методологии SOLID. Сегодня мы представляем вашему вниманию перевод статьи, которая посвящена применению принципов SOLID при разработке приложений с использованием популярной библиотеки React.
Автор статьи говорит, что здесь, ради краткости, он не показывает полную реализацию некоторых компонентов.
Принцип единственной ответственности (S)
Принцип единственной ответственности (Single Responsibility Principle) говорит нам о том, что у модуля должна быть одна и только одна причина для изменений.
Представим, что мы занимаемся разработкой приложения, которое выводит список пользователей в таблице. Вот код компонента App
:
class App extends Component {
state = {
users: [{name: 'Jim', surname: 'Smith', age: 33}]
};
componentDidMount() {
this.fetchUsers();
}
async fetchUsers() {
const response = await fetch('http://totallyhardcodedurl.com/users');
const users = await response.json();
this.setState({users});
}
render() {
return (
<div className="App">
<header className="App-header">
// тут опущено огромное количество кода заголовка
</header>
<table>
<thead>
<tr>
<th>First name</th>
<th>Last name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{this.state.users.map((user, index) => (
<tr key={index}>
<td><input value={user.name} onChange={/* update name in the state */}/></td>
<td><input value={user.surname} onChange={/* update surname in the state*/}/></td>
<td><input value={user.age} onChange={/* update age in the state */}/></td>
</tr>
))}
</tbody>
</table>
<button onClick={() => this.saveUsersOnTheBackend()}>Save</button>
</div>
);
}
saveUsersOnTheBackend(row) {
fetch('http://totallyhardcodedurl.com/users', {
method: "POST",
body: JSON.stringify(this.state.users),
})
}
}
У нас имеется компонент, в состоянии которого хранится список пользователей. Мы загружаем этот список по HTTP с некоего сервера, список поддаётся редактированию. Наш компонент нарушает принцип единственной ответственности, так как у него есть больше одной причины для изменений.
В частности, я могу увидеть четыре причины для изменения компонента. А именно, компонент меняется в следующих случаях:
- Каждый раз, когда нужно изменить заголовок приложения.
- Каждый раз, когда нужно добавить в приложение новый компонент (подвал страницы, например).
- Каждый раз, когда нужно изменить механизм загрузки данных о пользователях, например — адрес сервера или протокол.
- Каждый раз, когда нужно изменить таблицу (например — изменить форматирование столбцов или выполнить какие-то другие действия, подобные этому).
Как решить эти проблемы? Нужно, после того, как идентифицированы причины для изменения компонента, попытаться их устранить, вывести из исходного компонента, создавая подходящие абстракции (компоненты или функции) для каждой такой причины.
Решим проблемы нашего компонента App
, занявшись его рефакторингом. Его код, после разбиения его на несколько компонентов, будет выглядеть так:
class App extends Component {
render() {
return (
<div className="App">
<Header/>
<UserList/>
</div>
);
}
}
Теперь, если нужно изменить заголовок, мы меняем компонент Header
, а если надо добавить в приложение новый компонент, мы меняем компонент App
. Тут мы решили проблемы №1 (изменение заголовочной части приложения) и проблему №2 (добавление в приложение нового компонента). Сделано это благодаря перемещению соответствующей логики из компонента App
в новые компоненты.
Займёмся теперь решением проблем №3 и №4, создав класс UserList
. Вот его код:
class UserList extends Component {
static propTypes = {
fetchUsers: PropTypes.func.isRequired,
saveUsers: PropTypes.func.isRequired
};
state = {
users: [{name: 'Jim', surname: 'Smith', age: 33}]
};
componentDidMount() {
const users = this.props.fetchUsers();
this.setState({users});
}
render() {
return (
<div>
<UserTable users={this.state.users} onUserChange={(user) => this.updateUser(user)}/>
<button onClick={() => this.saveUsers()}>Save</button>
</div>
);
}
updateUser(user) {
// обновить сведения о пользователе в состоянии
}
saveUsers(row) {
this.props.saveUsers(this.state.users);
}
}
UserList
— это наш новый компонент-контейнер. Благодаря ему мы решили проблему №3 (изменение механизма загрузки пользователей), создав функции-свойства fetchUser
и saveUser
. В результате теперь, когда нам надо поменять ссылку, применяемую для загрузки списка пользователей, мы обращаемся к соответствующей функции и вносим в неё изменения.
Последняя проблема, идущая у нас под номером 4 (изменение таблицы, выводящей список пользователей), решена благодаря введению в проект презентационного компонента UserTable
, который инкапсулирует формирование HTML-кода и стилизацию таблицы с пользователями.
Принцип открытости-закрытости (O)
Принцип открытости-закрытости (Open Closed Principle) гласит, что программные сущности (классы, модули, функции) должны быть открыты для расширения, но не для модификации.
Если взглянуть на компонент UserList
, описанный выше, можно заметить, что если надо отобразить список пользователей в другом формате, нам придётся модифицировать метод render
этого компонента. Это — нарушение принципа открытости-закрытости.
Привести программу в соответствие с этим принципом можно, воспользовавшись композицией компонентов.
Взгляните на код компонента UserList
, который подвергся рефакторингу:
export class UserList extends Component {
static propTypes = {
fetchUsers: PropTypes.func.isRequired,
saveUsers: PropTypes.func.isRequired
};
state = {
users: [{id: 1, name: 'Jim', surname: 'Smith', age: 33}]
};
componentDidMount() {
const users = this.props.fetchUsers();
this.setState({users});
}
render() {
return (
<div>
{this.props.children({
users: this.state.users,
saveUsers: this.saveUsers,
onUserChange: this.onUserChange
})}
</div>
);
}
saveUsers = () => {
this.props.saveUsers(this.state.users);
};
onUserChange = (user) => {
// обновить сведения о пользователе в состоянии
};
}
Компонент UserList
, в результате модификации, оказался открытым для расширения, так как он выводит дочерние компоненты, что облегчает изменение его поведения. Этот компонент закрыт для модификации, так как все изменения выполняются в отдельных компонентах. Мы даже можем разворачивать эти компоненты независимо.
Посмотрим теперь на то, как, с использованием нового компонента, выводится список пользователей.
export class PopulatedUserList extends Component {
render() {
return (
<div>
<UserList>{
({users}) => {
return <ul>
{users.map((user, index) => <li key={index}>{user.id}: {user.name} {user.surname}</li>)}
</ul>
}
}
</UserList>
</div>
);
}
}
Тут мы расширяем поведение компонента UserList
, создавая новый компонент, который знает о том, как выводить список пользователей. Мы даже можем загрузить более подробные сведения о каждом из пользователей в этом новом компоненте, не касаясь компонента UserList
, а именно в этом и заключалась цель рефакторинга этого компонента.
Принцип подстановки Барбары Лисков (L)
Принцип подстановки Барбары Лисков (Liskov Substitution Principle) указывает на то, что объекты в программах должны быть заменяемы на экземпляры их подтипов без нарушения правильности работы программы.
Если это определение кажется вам слишком вольно сформулированным — вот более строгий его вариант.
Принцип подстановки Барбары Лисков: если нечто выглядит как утка и крякает как утка, но нуждается в батарейках — вероятно, выбрана неправильная абстракция
Взгляните на следующий пример:
class User {
constructor(roles) {
this.roles = roles;
}
getRoles() {
return this.roles;
}
}
class AdminUser extends User {}
const ordinaryUser = new User(['moderator']);
const adminUser = new AdminUser({role: 'moderator'},{role: 'admin'});
function showUserRoles(user) {
const roles = user.getRoles();
roles.forEach((role) => console.log(role));
}
showUserRoles(ordinaryUser);
showUserRoles(adminUser);
У нас имеется класс User
, конструктор которого принимает роли пользователей. На основе этого класса мы создаём класс AdminUser
. После этого мы создали простую функцию showUserRoles
, которая принимает объект типа User
в качестве параметра и выводит в консоль все роли, назначенные пользователю.
Мы вызываем эту функцию, передавая ей объекты ordinaryUser
и adminUser
, после чего сталкиваемся с ошибкой.
Ошибка
Что случилось? Объект класса AdminUser
похож на объект класса User
. Он, определённо, «крякает» как User
, так как у него имеются те же методы, что и у User
. Проблема заключается в «батарейках». Дело в том, что создавая объект adminUser
, мы передали ему пару объектов, а не массив.
Тут нарушен принцип подстановки, так как функция showUserRoles
должна правильно работать с объектами класса User
и с объектами, созданными на основе классов-наследников этого класса.
Исправить эту проблему несложно — достаточно передать конструктору AdminUser
массив вместо объектов:
const ordinaryUser = new User(['moderator']);
const adminUser = new AdminUser(['moderator','admin']);
Принцип разделения интерфейса (I)
Принцип разделения интерфейса (Interface Segregation Principle) указывает на то, что программы не должны зависеть от того, в чём они не нуждаются.
Этот принцип особенно актуален в языках со статической типизацией, в которых зависимости в явном виде заданы интерфейсами.
Рассмотрим пример:
class UserTable extends Component {
...
render() {
const user = {id: 1, name: 'Thomas', surname: 'Foobar', age: 33};
return (
<div>
...
<UserRow user={user}/>
...
</div>
);
}
...
}
class UserRow extends Component {
static propTypes = {
user: PropTypes.object.isRequired,
};
render() {
return (
<tr>
<td>Id: {this.props.user.id}</td>
<td>Name: {this.props.user.name}</td>
</tr>
)
}
}
Компонент UserTable
рендерит компонент UserRow
, передавая ему, в свойствах, объект с полной информацией о пользователе. Если же проанализировать код компонента UserRow
, то окажется, что зависит он от объекта, содержащего все сведения о пользователе, но нужны ему лишь свойства id
и name
.
Если вы напишете тест для этого компонента и при этом будете применять TypeScript или Flow, вам придётся создавать имитацию для объекта user
со всеми его свойствами, иначе компилятор выдаст ошибку.
На первый взгляд подобное не выглядит проблемой в том случае, если пользуются чистым JavaScript, но если когда-нибудь в вашем коде поселится TypeScript, это внезапно приведёт к отказу тестов из-за необходимости назначать все свойства интерфейсов, даже если используются лишь некоторые из них.
Как бы там ни было, но программа, удовлетворяющая принципу разделения интерфейса, оказывается более понятной.
class UserTable extends Component {
...
render() {
const user = {id: 1, name: 'Thomas', surname: 'Foobar', age: 33};
return (
<div>
...
<UserRow id={user.id} name={user.name}/>
...
</div>
);
}
...
}
class UserRow extends Component {
static propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
};
render() {
return (
<tr>
<td>Id: {this.props.id}</td>
<td>Name: {this.props.name}</td>
</tr>
)
}
}
Помните о том, что этот принцип применяется не только к типам свойств, передаваемым компонентам.
Принцип инверсии зависимостей (D)
Принцип инверсии зависимостей (Dependency Inversion Principle) говорит нам о том, что объектом зависимости должна быть абстракция, а не что-то конкретное.
Рассмотрим следующий пример:
class App extends Component {
...
async fetchUsers() {
const users = await fetch('http://totallyhardcodedurl.com/stupid');
this.setState({users});
}
...
}
Если проанализировать этот код, то становится понятно, что компонент App
зависит от глобальной функции fetch
. Если описать взаимоотношения этих сущностей на языке UML, то получится следующая диаграмма.
Взаимоотношения компонента и функции
Высокоуровневый модуль не должен зависеть от низкоуровневых конкретных реализаций чего-либо. Он должен зависеть от абстракции.
Компонент App
не должен знать о том, как загружать сведения о пользователях. Для того чтобы решить эту проблему, нам нужно инвертировать зависимости между компонентом App
и функцией fetch
. Ниже представлена UML-диаграмма, иллюстрирующая это.
Инверсия зависимостей
Вот реализация этого механизма.
class App extends Component {
static propTypes = {
fetchUsers: PropTypes.func.isRequired,
saveUsers: PropTypes.func.isRequired
};
...
componentDidMount() {
const users = this.props.fetchUsers();
this.setState({users});
}
...
}
Теперь можно сказать, что компонент отличается слабой связанностью, так как у него нет сведений о том, какой конкретно протокол мы используем — HTTP, SOAP или какой-то ещё. Компонент это совершенно не заботит.
Соблюдение принципа инверсии зависимостей расширяет наши возможности по работе с кодом, так как мы можем очень легко поменять механизм загрузки данных, а компонент App
при этом совершенно не изменится.
Кроме того, это упрощает тестирование, так как несложно создать функцию, имитирующую функцию загрузки данных.
Итоги
Вкладывая время в написание качественного кода, вы заслужите благодарность своих коллег и себя самого, когда, в будущем, вам придётся снова с этим кодом столкнуться. Внедрение принципов SOLID в разработку React-приложений — это стоящее вложение времени.
Уважаемые читатели! Пользуетесь ли вы принципами SOLID при разработке React-приложений?
Автор: ru_vds