- Разработка библиотеки компонентов на React + Storybook
- Разработка через тестирование в JS или как начать любить программирование
- Миграция реального проекта с Javascript на Typescript — боли и особенности
А теперь перейдём к статье.
Когда я начал изучать React, кое-что мне было непонятно. И, думаю, почти все, кто знаком с React, задаются теми же вопросами. Я уверен в этом потому, что люди создают целые библиотеки для решения насущных проблем. Вот два главных вопроса, которые, кажется, волнуют почти каждого React-разработчика:
Как один компонент получает доступ к информации (особенно к переменной состояния), которая находится в другом компоненте? Как один компонент вызывает функцию, которая находится в другом компоненте?
JavaScript-разработчики в целом (и React-разработчики в частности) в последнее время все больше тяготеют к написанию так называемых чистых функций. Функций, которые не связаны с изменениями состояния. Функций, которым не нужны внешние соединения с базами данных. Функций, которые не зависят от того, что происходит за их пределами.
Безусловно, чистые функции — это благородная цель. Но если вы разрабатываете более-менее сложное приложение, то сделать каждую функцию чистой не получится. Обязательно наступит момент, когда вам придется создать хотя бы несколько компонентов, которые так или иначе связаны с другими компонентами. Пытаться избежать этого просто смешно. Такие узы между компонентами называются зависимостями.
В целом зависимости — это плохо, и их лучше использовать, только когда это действительно необходимо. Но опять же, если ваше приложение разрослось, некоторые его компоненты обязательно будут зависеть друг от друга. Конечно, React-разработчики об этом знают, поэтому они придумали, как заставить один компонент передавать критическую информацию, или функции, своим дочерним компонентам.
Стандартный подход: используем пропсы для передачи значений
Любое значение состояния можно передать другому компоненту через пропсы. Любую функцию можно передать дочерним компонентам все через те же пропсы. Так потомки узнают, какие значения состояния хранятся вверх по дереву, и потенциально могут вызывать в родительских компонентах действия. Все это, конечно, хорошо. Но React-разработчиков заботит конкретная проблема.
Большинство приложений имеют многоуровневую структуру. В сложных приложениях структуры могут быть вложены очень глубоко. Общая архитектура может выглядеть примерно так:
App
> обращается к >ContentArea
ContentArea
> обращается к >MainContentArea
MainContentArea
> обращается к >MyDashboard
MyDashboard
> обращается к >MyOpenTickets
MyOpenTickets
> обращается к >TicketTable
TicketTable
> обращается к последовательности >TicketRow
Каждый
TicketRow
> обращается к >TicketDetail
Теоретически эта гирлянда может накручиваться еще долго. Все компоненты являются частью целого. Если точнее, частью иерархии. Но тут возникает вопрос:
Может ли компонент
TicketDetail
в примере выше считывать значения состояния, которые хранятся в ContentArea
? Или. Может ли компонент TicketDetail
вызывать функции, которые находятся в ContentArea
?Ответ на оба вопроса — да. Теоретически все потомки могут знать обо всех переменных, которые хранятся в родительских компонентах. Они также могут вызывать функции предков — но с большой оговоркой. Это возможно, только если такие значения (значения состояния или функции) в явном виде переданы потомкам через пропсы. В противном случае значения состояния или функции компонента не будут доступны его дочернему компоненту.
В небольших приложениях и утилитах это особой роли не играет. Например, если компоненту
TicketDetail
нужно обратиться к переменным состояния, которые хранятся в TicketRow
, достаточно сделать так, чтобы компонент TicketRow
> передавал эти значения своему потомку > TicketDetail
через один или несколько пропсов. Точно так же дело обстоит в случае, когда компоненту TicketDetail
нужно вызвать функцию, которая находится в TicketRow
. Компонент TicketRow
> передаст эту функцию своему потомку > TicketDetail
через проп. Головная боль начинается, когда какому-нибудь компоненту, расположенному далекоооо вниз по дереву, нужно получить доступ к состоянию или функции компонента, расположенного вверху иерархии. Для решения этой проблемы в React переменные и функции традиционно передаются на все уровни вниз. Но это загромождает код, отнимает ресурсы и требует серьезного планирования. Нам пришлось бы передавать значения на много уровней примерно так:
ContentArea
> MainContentArea
> MyDashboard
> MyOpenTickets
> TicketTable
> TicketRow
> TicketDetail
То есть для того чтобы передать переменную состояния из
ContentArea
в TicketDetail
, нам нужно проделать огромную работу. Опытные разработчики понимают, что возникает безобразно длинная цепочка передачи значений и функций в виде пропсов через промежуточные уровни компонентов. Решение настолько громоздкое, что из-за него я даже пару раз бросал изучение React.Чудовище по имени Redux
Я не единственный, кто считает, что передавать через пропсы все значения состояния и все функции, общие для компонентов, очень непрактично. Вряд ли вы найдете хоть сколько-нибудь сложное React-приложение, к которому не прилагался бы инструмент управления состоянием. Таких инструментов не так уж мало. Лично я обожаю MobX. Но, к сожалению, «отраслевым стандартом» считается Redux.
Redux — это детище создателей ядра React. То есть они сначала создали прекрасную библиотеку React. Но сразу же поняли, что с ее средствами управлять состоянием практически невозможно. Если бы они не нашли способа решить присущие этой (во всем остальном замечательной) библиотеке проблемы, многие из нас никогда бы не услышали о React.
Поэтому они придумали Redux.
Если React — это Мона Лиза, то Redux — это пририсованные ей усы. Если вы используете Redux, вам придется написать тонну шаблонного кода почти в каждом файле проекта. Устранение проблем и чтение кода становятся адом. Бизнес-логика выносится куда-то на задворки. В коде — разброд и шатание.
Но если перед разработчиками стоит выбор: React + Redux или React без каких-либо сторонних инструментов управления состоянием, они почти всегда выбирают React + Redux. Поскольку библиотеку Redux разработали авторы ядра React, она по умолчанию считается одобренным решением. А большинство разработчиков предпочитают использовать решения, которые были вот так молчаливо одобрены.
Конечно, Redux создаст целую паутину зависимостей в вашем React-приложении. Но, справедливости ради, любое универсальное средство управления состоянием сделает то же самое. Инструмент управления состоянием — это общее хранилище переменных и функций. Такие функции и переменные могут использоваться любым компонентом, у которого есть доступ к общему хранилищу. В этом есть один очевидный недостаток: все компоненты становятся зависимыми от общего хранилища.
Большинство знакомых мне React-разработчиков, которые пытались сопротивляться использованию Redux, в конце концов сдались. (Потому что… сопротивление бесполезно.) Я знаю много людей, которые сразу возненавидели Redux. Но когда перед ними ставили выбор — Redux или «мы найдем другого React-разработчика», — они, закинувшись сомой, соглашались принять Redux как неотъемлемую часть жизни. Это как налоги. Как ректальный осмотр. Как поход к стоматологу.
Новый взгляд на общие значения в React
Я слишком упрям, чтобы так просто сдаться. Взглянув на Redux, я понял, что нужно искать другие решения. Я могу использовать Redux. И я работал в командах, которые пользовались этой библиотекой. В общем, я понимаю, что она делает. Но это не значит, что Redux мне нравится.
Как я уже говорил, если без отдельного инструмента для управления состоянием не обойтись, то MobX примерно… в миллион раз лучше, чем Redux! Но меня мучает более серьезный вопрос. Он касается коллективного разума React-разработчиков:
Почему первым делом мы всегда хватаемся за инструмент управления состоянием?
Когда я только начал разрабатывать на React, я провел не одну ночь в поисках альтернативных решений. И я нашел способ, которым пренебрегают многие React-разработчики, но никто из них не может сказать, почему. Объясню.
Представим, что в гипотетическом приложении, о котором я писал выше, мы создаем такой файл:
// components.js
let components = {};
export default components;
И все. Только две короткие строчки кода. Мы создаем пустой объект — старый добрый JS-объект. Экспортируем его по умолчанию с помощью
export default
.Теперь давайте посмотрим, как может выглядеть код внутри компонента
<
ContentArea>
:// content.area.js
import components from './components';
import MainContentArea from './main.content.area';
import React from 'react';
export default class ContentArea extends React.Component {
constructor(props) {
super(props);
components.ContentArea = this;
}
consoleLog(value) {
console.log(value);
}
render() {
return <MainContentArea/>;
}
}
По большей части он выглядит, как вполне нормальный классовый React-компонент. У нас есть простая функция
render()
, которая обращается к следующему компоненту вниз по дереву. У нас есть небольшая функция console.log()
, которая выводит в консоль результат выполнения кода, и конструктор. Но… в конструкторе есть некоторые нюансы. В самом начале мы импортировали простой объект
components
. Затем в конструкторе мы добавили новое свойство к объекту components
с именем текущего React-компонента (this
).В этом свойстве мы ссылаемся на компонент this
. Теперь при каждом обращении к объекту components у нас будет прямой доступ к компоненту <
ContentArea>
.Давайте посмотрим, что происходит на нижнем уровне иерархии. Компонент
<
TicketDetail>
может быть таким:// ticket.detail.js
import components from './components';
import React from 'react';
export default class TicketDetail extends React.Component {
render() {
components.ContentArea.consoleLog('it works');
return <div>Here are the ticket details.</div>;
}
}
А происходит вот что. При каждом рендере компонента
TicketDetail
будет вызываться функция consoleLog()
, которая хранится в компоненте ContentArea
. Обратите внимание, что функция
consoleLog()
не передается по всей иерархии через пропсы. Фактически функция consoleLog()
не передается никуда — вообще никуда, — ни в один компонент. И тем не менее
TicketDetail
может вызвать функцию consoleLog()
, которая хранится в ContentArea
, потому что мы выполнили два действия:- Компонент
ContentArea
при загрузке добавил в общий объект components ссылку на себя. - Компонент
TicketDetail
при загрузке импортировал общий объектcomponents
, то есть у него был прямой доступ к компонентуContentArea
, несмотря на то что свойстваContentArea
не передавались компонентуTicketDetail
через пропсы.
Этот подход работает не только с функциями/колбэками. Его можно использовать для прямого запроса значений переменных состояния. Представим, что
<
ContentArea>
выглядит так:// content.area.js
import components from './components';
import MainContentArea from './main.content.area';
import React from 'react';
export default class ContentArea extends React.Component {
constructor(props) {
super(props);
this.state = { reduxSucks:true };
components.ContentArea = this;
}
render() {
return <MainContentArea/>;
}
}
Тогда мы можем написать
<
TicketDetail>
вот так:// ticket.detail.js
import components from './components';
import React from 'react';
export default class TicketDetail extends React.Component {
render() {
if (components.ContentArea.state.reduxSucks === true) {
console.log('Yep, Redux is da sux');
}
return <div>Here are the ticket details.</div>;
}
}
Теперь при каждом рендере компонента
<
TicketDetail
> он будет искать значение переменной state.reduxSucks
в <
ContentArea>
. Если переменная вернет значение true
, функция console.log()
выведет в консоль сообщение. Это произойдет, даже если значение переменной ContentArea.state.reduxSucks
никогда не передавалось вниз по дереву — ни одному из компонентов — через пропсы. Таким образом, благодаря одному простому базовому JS-объекту, который «обитает» за пределами стандартного жизненного цикла React, мы можем сделать так, чтобы любой потомок мог считывать переменные состояния непосредственно из любого родительского компонента, загруженного в объект components. Мы даже можем вызывать функции родительского компонента в его потомке. Возможность вызова функции непосредственно в дочерних компонентах означает, что мы можем изменять состояние родительских компонентов прямо из их потомков. Например так.
Для начала в компоненте
<
ContentArea>
создадим простую функцию, которая меняет значение переменной reduxSucks
.// content.area.js
import components from './components';
import MainContentArea from './main.content.area';
import React from 'react';
export default class ContentArea extends React.Component {
constructor(props) {
super(props);
this.state = { reduxSucks:true };
components.ContentArea = this;
}
toggleReduxSucks() {
this.setState((previousState, props) => {
return { reduxSucks: !previousState.reduxSucks };
});
}
render() {
return <MainContentArea/>;
}
}
Затем в компоненте
<
TicketDetail>
мы вызовем этот метод через объект components
:// ticket.detail.js
import components from './components';
import React from 'react';
export default class TicketDetail extends React.Component {
render() {
if (components.ContentArea.state.reduxSucks === true) {
console.log('Yep, Redux is da sux');
}
return (
<>
<div>Here are the ticket details.</div>
<button onClick={() => components.ContentArea.toggleReduxSucks()}>Toggle reduxSucks</button>
</>
);
}
}
Теперь после каждого рендера компонента
<
TicketDetail>
пользователь сможет нажимать кнопку, которая будет изменять (переключать) значение переменной ContentArea.state.reduxSucks
в режиме реального времени, даже если функция ContentArea.toggleReduxSucks()
никогда не передавалась вниз по дереву через пропсы. С таким походом родительский компонент может вызвать функцию непосредственно из своего потомка. Вот как это можно сделать. Обновленный компонент
<
ContentArea>
будет выглядеть так:// content.area.js
import components from './components';
import MainContentArea from './main.content.area';
import React from 'react';
export default class ContentArea extends React.Component {
constructor(props) {
super(props);
this.state = { reduxSucks:true };
components.ContentArea = this;
}
toggleReduxSucks() {
this.setState((previousState, props) => {
return { reduxSucks: !previousState.reduxSucks };
});
components.TicketTable.incrementReduxSucksHasBeenToggledXTimes();
}
render() {
return <MainContentArea/>;
}
}
А теперь добавим логику в компонент
<
TicketTable>
. Вот так:// ticket.table.js
import components from './components';
import React from 'react';
import TicketRow from './ticket.row';
export default class TicketTable extends React.Component {
constructor(props) {
super(props);
this.state = { reduxSucksHasBeenToggledXTimes: 0 };
components.TicketTable = this;
}
incrementReduxSucksHasBeenToggledXTimes() {
this.setState((previousState, props) => {
return { reduxSucksHasBeenToggledXTimes: previousState.reduxSucksHasBeenToggledXTimes + 1};
});
}
render() {
const {reduxSucksHasBeenToggledXTimes} = this.state;
return (
<>
<div>The `reduxSucks` value has been toggled {reduxSucksHasBeenToggledXTimes} times</div>
<TicketRow data={dataForTicket1}/>
<TicketRow data={dataForTicket2}/>
<TicketRow data={dataForTicket3}/>
</>
);
}
}
В результате компонент
<
TicketDetail>
не изменился. Он все еще выглядит так:// ticket.detail.js
import components from './components';
import React from 'react';
export default class TicketDetail extends React.Component {
render() {
if (components.ContentArea.state.reduxSucks === true) {
console.log('Yep, Redux is da sux');
}
return (
<>
<div>Here are the ticket details.</div>
<button onClick={() => components.ContentArea.toggleReduxSucks()}>Toggle reduxSucks</button>
</>
);
}
}
Вы заметили странность, связанную с этими тремя классами? В иерархии нашего приложения
ContentArea
— это родительский компонент для TicketTable
, который является родительским компонентом для TicketDetail
. Это означает, что когда мы монтируем компонент ContentArea
, он еще «не знает» о существовании TicketTable
.А функция toggleReduxSucks()
, записанная в ContentArea
, неявно вызывает функцию потомка: incrementReduxSucksHasBeenToggledXTimes()
.Получается, код работать не будет, так?А вот и нет.
Смотрите. Мы создали в приложении несколько уровней, и есть только один путь вызова функции
toggleReduxSucks()
. Вот так.- Монтируем и рендерим
ContentArea
. - В ходе этого процесса в объект components загружается ссылка на
ContentArea
. - В результате монтируется и рендерится
TicketTable
. - В ходе этого процесса в объект components загружается ссылка на
TicketTable
. - В результате монтируется и рендерится
TicketDetail
. - У пользователя появляется кнопка «Изменить значение reduxSucks» (Toggle reduxSucks).
- Пользователь нажимает кнопку «Изменить значение reduxSucks».
- Нажатие кнопки вызывает функцию
toggleReduxSucks()
, которая записана в компонентеContentArea
. - Это в свою очередь вызывает функцию
incrementReduxSucksHasBeenToggledXTimes()
из компонентаTicketTable
. - Все работает, потому что к тому моменту, когда пользователь сможет нажать кнопку «Изменить значение reduxSucks», ссылка на компонент
TicketTable
будет загружена в объект components. А функцияtoggleReduxSucks()
при вызове изContentArea
сможет найти ссылку на функциюincrementReduxSucksHasBeenToggledXTimes()
, записанную вTicketTable
, в объекте components.
Получается, что иерархия нашего приложения позволяет добавить в компонент
ContentArea
алгоритм, который будет вызывать функцию из дочернего компонента несмотря на то, что компонент ContentArea
при монтировании не знал о существовании компонента TicketTable
. Инструменты управления состоянием — на свалку
Как я уже объяснил, я глубоко уверен в том, что Redux не идет ни в какое сравнение с MobX. И когда мне выпадает честь работать над проектом с нуля (к сожалению, нечасто), я всегда агитирую за MobX. Не за Redux. Но когда я разрабатываю собственные приложения, я вообще редко использую сторонние инструменты управления состоянием — практически никогда. Вместо этого я просто-напросто кеширую объекты/компоненты, когда это возможно. А если это подход не работает, я частенько возвращаюсь к решению, которое используется в React по умолчанию, то есть просто передаю функции/переменные состояния через пропсы.
Известные «проблемы» этого подхода
Я прекрасно понимаю, что моя идея кешировать базовый объект
components
не всегда подходит для решения проблем с общими состояниями/функциями. Иногда этот подход может...сыграть злую шутку. Или вообще не сработать. Вот о чем следует помнить.- Лучше всего он работает с одиночками.
Например, в нашей иерархии в компоненте <TicketTable> находятся компоненты <TicketRow> со связью «ноль-ко-многим». Если вы захотите кешировать ссылку на каждый потенциальный компонент внутри компонентов <TicketRow> (и их дочерних компонентов <TicketDetail>) в кеш components, вам придется сохранить их в массив, и тут могут возникнуть сложности. Я всегда избегал этого. - При кешировании объекта components предполагается, что мы не можем использовать переменные/функции из других компонентов, если они не были загружены в объект components. Это очевидно.
Если архитектура вашего приложения делает такой подход нецелесообразным, то не надо его использовать. Он идеально подходит для одностраничных приложений, когда мы уверены в том, что родительский компонент всегда монтируется раньше потомка. Если вы решили сослаться на переменные/функции потомка непосредственно из родительского компонента, создавайте такую структуру, которая будет выполнять эту последовательность только после загрузки потомка в кеш components. - Можно считывать переменные состояния из других компонентов, ссылки на которые хранятся в кеше
components
, но если вы захотите обновить такие переменные (черезsetState()
), вам придется вызвать функциюsetState()
, которая записана в соответствующем компоненте.
Ограничение ответственности
Теперь, когда я рассказал о своем подходе и некоторых его ограничениях, я обязан предупредить вас. С тех пор как я открыл этот подход, я то и дело рассказываю о нем людям, которые считают себя профессиональными React-разработчиками. Каждый раз они отвечают одно и то же:
Хм… Не делай этого. Они морщатся и ведут себя так, будто я только что испортил воздух. Что-то в моем подходе кажется им… неправильным. И при этом еще никто не объяснил мне, исходя из своего богатого практического опыта, что именно не так. Просто все считают мой подход… кощунством.
Поэтому даже если вам нравится этот подход или если он кажется вам удобным в некоторых ситуациях, я не рекомендую говорить о нем на собеседовании, если вы хотите получить должность React-разработчика. Думаю, что, даже просто разговаривая с другими React-разработчиками, нужно миллион раз подумать, прежде чем сказать об этом способе, а может, лучше вообще ничего не говорить.
Я обнаружил, что JS-разработчики — и, в частности, React-разработчики — бывают слишком категоричны. Иногда они действительно объясняют, почему подход А «неправильный», а подход Б «правильный». Но в большинстве случаев они просто смотрят на фрагмент кода и объявляют, что он «плохой», — даже если сами не могут объяснить, почему.
Так почему же этот подход так раздражает React-разработчиков?
Как я уже говорил, ни один из моих коллег не смог обоснованно ответить, чем плох мой способ. А если кто-нибудь и готов удостоить меня ответом, то обычно это одна из следующих отговорок (их мало).
- С таким подходом о чистых функциях можно забыть, он захламляет приложение жесткими зависимостями.
Окей...Понял. Но те самые люди, которые с ходу отвергли этот подход, с удовольствием будут использовать Redux (или MobX, или любое другое средство управления состоянием) почти со всеми классами/функциями в своих React-приложениях. Я не отрицаю, что иногда без инструментов управления состоянием действительно сложно обойтись. Но любой такой инструмент по своему характеру — это гигантский генератор зависимостей. Каждый раз, когда вы используете инструменты управления состоянием с функциями/классами, вы захламляете приложение зависимостями. Обратите внимание: я не говорил, что нужно отправлять каждую функцию или класс в кеш объектаcomponents
. Вы самостоятельно решаете, какие именно функции/классы будут кешироваться вcomponents
, а какие функции/классы будут обращаться к тому, что вы поместили в кеш components. Если вы пишете чистую вспомогательную функцию/класс, то наверняка моя идея с кешемcomponents
вам не подходит, потому что для кеширования в components компоненты должны знать о других компонентах приложения. Если вы пишете компонент, который будет использоваться в разных фрагментах кода вашего приложения или даже в разных приложениях, не применяйте этот подход. Но опять же, если вы создаете такой глобальный компонент, в нем не нужно использовать ни Redux, ни MobX, ни какое-либо еще средство управления состоянием. - Просто в React «так не делается». Или… Это не соответствует отраслевым стандартам.
Ага… Это мне говорили не раз. И знаете что? Когда я это слышу, я даже немножко перестаю уважать своего собеседника. Если единственная причина — это какое-то размытое «так не делается» или «отраслевой стандарт», который сегодня один, а завтра другой, то разработчик просто чертов лентяй. Когда появилась React, у нас не было вообще никаких инструментов управления состоянием. Но люди начали изучать эту библиотеку и решили, что они нужны. И их создали.Если вы действительно хотите соответствовать «отраслевым стандартам», просто передавайте все переменные состояния и все обратные вызовы функций через пропсы.Но если вам кажется, что базовая реализация React не удовлетворяет ваши потребности на 100 %, откройте глаза (и разум) и взгляните повнимательней на нестандартные решения, которые не были одобрены лично господином Дэном Абрамовым.
Итак, что скажете ВЫ?
Я написал этот пост, потому что уже годами использую этот подход (в личных проектах). И он работает превосходно. Но каждый раз, когда я вылезаю из своего личного «пузыря» и пытаюсь вести интеллектуальную беседу об этом подходе с другими, сторонними React-разработчиками, я сталкиваюсь только с категоричными заявлениями и бестолковыми суждениями об «отраслевых стандартах».
Этот подход действительно плох? Ну правда. Я хочу знать. Если это действительно «антипаттерн», я буду безмерно благодарен тем, кто обоснует его неправильность. Ответ «я к такому не привык» меня не устроит. Нет, я не зациклился на этом методе. Я не утверждаю, что это панацея для React-разработчиков. И я признаю, что он подходит не для всех ситуаций. Но может хоть кто-нибудь объяснить мне, что в нем не так?
Мне очень хочется узнать ваше мнение по этому поводу — даже если вы разнесете меня в пух и прах.
Alexandroppolus
Ден Абрамов написал Redux ещё до присоединения к команде Реакта, если не ошибаюсь (по крайней мере, он не был сотрудником FB)
MobX потихоньку тоже становится стандартом.
noodles
подтверждаю, последние два года попадается только Mobx слава богу)
strannik_k
Хотелось бы, чтобы MobX был стандартном.
Но я не встречал ни одного готового шаблона админки или фреймворка с Mobx(
Если какие-то готовые компоненты используют менеджер состояний, то это почти всегда redux(
Еще recoil появился. Из-за того, что он от разрабов фейсбука, у него большой шанс стать стандартом в будущем.
Плюс в современных примерах а-ля «есть context и хуки, наконец-то можем проще, без redux» по-прежнему по привычке тянут эти редьюсеры, диспетчеры. Эх, испортил Ден фронтенд основательно и надолго(