React: как избежать типичных ошибок

Данная статья ставит перед собой целью рассказать разработчикам, которые ещё не используют JS-библиотеку React, в чём заключаются её особенности и на что нужно обращать внимание, чтобы избежать типичных ошибок.

В чём особенность React?

Библиотека React была разработана и представлена фирмой Facebook в 2013 году. С тех пор она набрала популярность, и сейчас является одним из главных средств разработчиков на языке JavaScript по всему миру.

Главный принцип, на котором строится использование React - декларативный стиль написания кода. Как известно, есть два способа написать код для решения задачи: в императивном и декларативном стиле. Императивный код в первую очередь отвечает на вопрос, как сделать те или иные изменения. Большинство фронтенд-разработчиков начинают свою карьеру с решения задач именно в таком стиле. Например, код на чистом JS:

document.getElementsByTagName("h1")[0].innerText = "Welcome to my page";


или с помощью популярной библиотеки jQuery:

$("h1")[0].text = "Welcome to my page";
if (someCondition) {
$("div").html("<p>Read some text...</p>);
} else {
$("div").html("<span class='error'>Block is empty</span>);
}

Императивный код описывает действие: что требуется сделать на странице или с данными внутри программы.

Однако, когда количество различный условий нарастает, писать программы, указываюшие, как именно поступать в том или ином случае, становится всё труднее. Допустим, если у нас имеется в программе 3 сущности, каждая из которых имеет только два возможных состояния, то в общем случаем мы получим 6 различных возможных состояний. Если мы пишем функцию, нам может понадобиться вызвать следующую в зависимости от того, каким условиям удовлетворяет ответ первой. В итоге императивный код может прийти к бесконечному ветвлению условия if...else, "аду коллбэков" и прочей лапше.

Упростить задачу написания кода поможет декларативный стиль. Мы стремимся ответить на вопрос что меняется, а не как. Декларативный код описывает сущности, которые будут изменены, а не способы, которыми это будет делаться. В написании кода в декларативном стиле на помогает библиотека React.

<h1>{this.state.greating}</h1>
<div>
{this.state.someCondition &&
<p>Read some text...</p>}
{!this.state.someCondition &&
<span classname="error">Block is empty</span>
</div>

Программа на React в первую очередь описывает сущности, зависимые от тех или иных параметров, то есть даёт нам возможность увидеть все варианты отображения.

В коде выше мы сразу видим несколько особенностей библиотеки React:

1) React-программы до некоторой степени смешивают HTML-вёрстку с переменными JS-скрипта. На самом деле, код React это только JS-программа. Мы не пишем вёрстку в отдельном файле, а декларируем, что хотим нарисовать тот или иной блок в зависимости от условий. Отрисовкой DOM-дерева непосредственно занимается сама библиотека. Принято считать, что лучшей практикой является разделение HTML (вёрстки) и JS (скриптов, обслуживающих логику взаимодействия). На самом деле, в React также создаётся изоляция логики от представления, а некоторое смешивания скриптовых переменных и вёрстки лишь поддерживает написание кода в декларативном стиле.

2) Вёрстка пишется на HTML-подобном языке, который называется JSX. На самом деле, это расширение JS, которое позволяет в том числе писать привычные теги: <h1></h1>, <div></div>, <span></span> и так далее. Атрибуты тегов следует писать в особом стиле: в листинге выше вы видите className вместо привычного class. Также существуют htmlFor вместо for, defaultValue и некоторые другие специальные атрибуты. Об этом надо помнить, когда вы пишете UI с помощью React.

3) Сущность this.state, которая содержит в себе информацию о состоянии приложения. State знает о том, в каком состоянии находится приложение. Мы хотим изменить UI - мы меняем state.


Правильно используем методы lifecycle

Методы жизненного цикла компонентов React можно условно поделить на три части: монтирование, обновление, удаление. К первой части относятся такие методы, как render(), static getDerivedStateFromProps(), componentDidMount(). Часто сюда ещё относят метод constructor(), хотя, строго говоря, это относится больше к классам в JS, нежели собственно к React. Ко второй части относят методы shouldComponentUpdate(), getSnapshotBeforeUpdate(), componentDidUpdate(). К стадии удаления относится метод componentWillUnmount().

При ненадлежащем использовании методов жизненного цикла вы, скорее всего, получите предупреждение в консоли. Однако важно помнить, что с помощью некоторых методов можно запустить программу в рекурсию, что закончится ошибкой и приостановкой работы. Это относится к методам стадии обновления, а также к render(). Чтобы избежать ошибок, важно помнить следующее.

а) Метод render() должен оставаться чистой функцией, то есть не вызывать сайд-эффектов (в том числе изменения state компонента и вызова коллбэков в родительском компоненте). Этот метод предназначен только для отрисовки содержимого.
б) Методы стадии обновления не должны вызывать изменения state или способствовать изменению props. Это вводит вашу программу в бесконечную петлю выполнения и мгновенному переполнению стека. Метод shouldComponentUpdate() нужен для сравнения текущего состояния со следующим и отмены ненужной перерисовки компонента в зависимости от ваших условий (return false). Метод componentDidUpdate() нужен для вызова сайд-эффектов после перерисовки компонента (например, вызова сторонних модулей для обработки обновлённого списка). Метод getSnapshotBeforeUpdate() используется как вспомогательный для componentDidUpdate().
в) Метод componentWillUnmount() не должен вызывать новые действия с компонентом, т.к. он будет уничтожен сразу после завершения выполнения этого метода. Если вы вызовете setState() в этом методе, консоль выдаст ошибку, т.к. вы предложили изменить state компонента, который уже удалили. 

Управление состоянием

Состояние компонента доступно через его свойство this.state. Именно по изменению state React понимает, что нужно перерисовать компонент. Таким образом, после каждого шага вычисления бизнес-логики мы должны менять состояние компонента. Всё просто, однако есть несколько моментов, на которые стоит обратить внимание, чтобы избежать ошибок.

Не стоит менять state напрямую:

// НЕПРАВИЛЬНО!!
this.state.param = doSomething();

Для изменения состояния есть специальный метод this.setState:

const param = doSomething();
this.setState({
param
})

Дело в том, что this.setState - асинхронный метод, который создаёт очередь изменений состояния компонента. Меняя this.state напрямую, минуя очередь, мы ломаем логику обработки данных (сравнение предыдущего и следующего состояния), что может сказаться на правильной работе методов жизненного цикла React-компонента. Поведение компонента в таком случае может быть непредсказуемым.

Из этого следует ещё один момент: состояние в React меняется асинхронно. Вызвав изменение состояния однажды, мы не можем точно сказать, было ли оно донесено в тот же момент и можно ли использовать новое значение в следующей функции:

componentDidMount() {
const params = {
a : 1,
b : 2,
c : 3
};
this.setParams(params);
this.getParam("a");
}

setParams = (params) => {
this.setState({
...params
})
}
getParam = (param) => {
const p = this.state[param];
console.log(p === params.a) // ???
}

На самом деле, будет выведено false, т.к. новые поля в состоянии появятся позже, чем мы спросим одно из этих значений в следующей функции.

Избежать этого можно двумя способами. Во-первых, вызывать следующую функцию, которая будет зависима от значений конкретный полей хранилища, с таймаутом. Это не самый изящный способ, т.к. мы заведомо не знаем, через сколько точно будет обновлено состояние. Могут возникнуть паузы при работе клиента с нашим UI.

Второй способ - передавать в state не значение, а функцию, которая вернёт значение. У нас по прежнему будут сложности с тем, чтобы получить точное значение состояния в каждый конкретный момент (для этих целей лучше использовать библиотеки управления состоянием вроде Mobx). Однако мы можем вычислять следующее значение на основе предыдущего, не дожидаясь вычисления первой итерации:

componentDidMount() {
const params = {
a : 1,
b : 2,
c : 3
};
this.setParams(params);
this.setA(" + additions");
this.getParam("a");
}

setParams = (params) => {
this.setState((state) => {
return {
...params
}
})
}

setA = (a) => {
///const newA = this.state.a + a;
//this.setState({
// a
//})

this.setState((state) => {
return {
a: state.a + a
}
})
}

getParam = (param) => {
setTimeout(() => console.log(this.state.a), 300) // 1 + additions
}

Вы можете раскомментировать другой кусок кода в setA(a) и убедиться, что в случае присвоения значения, а не функции, результат будет другой.

Также надо помнить, что setState использует частичное присвоение. Это значит, что произойдёт изменение только указанного поля, остальные (если они есть) оставят свои прежние значения:

console.log(this.state); // {a : 1, b : 2, c: 3}
this.setState({
d : 4
});
setTimeout(() => console.log(this.state), 200); // {a : 1, b : 2, c: 3, d: 4}


Для очистки состояния вам нужно будет пройтись по всем его полям, либо запомнить initialState в начале работы с компонентом и обратиться к нему:

resetState = () => {
this.setState(this.initialState);
}

Работа с отдельными DOM-элементами

Иногда требуется обратиться к некоторым элементам DOM-дерева в императивном стиле. Например, перенести фокус на элемент ввода текста. Либо ваш код зависит от других библиотек, и не все данные включены в общий поток (допустим, часть модулей вашего приложения написана на jQuery, и в процессе их работы возникают сайд-эффекты, оказывающие влияние на элементы DOM). Для обращения к конкретному элементу в React есть инструмент refs. Начиная с React версии 16.3 добавлен метод React.createRef(), который мы и будем использовать в примерах. Refs, созданные с помощью устаревших методов, по сути представляют собой то же самое. Если ref используется для обращения к HTML-элементу, нужно использовать его свойство current:

import {validator} from "./helpers";

class MyComponent extends React.Component {
   constructor(props) {
      super(props);
      this.inputPhone = React.createRef();
      this.inputName = React.createRef();
   }

   componentDidMount = () => {
      this.inputPhone.current.focus();
   }
 
   validatePhone = (e) => {
      const isValidated = validator(e.currentTarget);
      if (isValidated) this.inputName.current.focus();
   }

   render() {
      return (
        <form>
          <input href={this.inputPhone} onChange={(e) => {this.validatePhone(e}} />
          <input ref={this.inputName} placeholder="Your name" type="text" />
        </form>
      )
   }
}

Также с помощью refs также можно обратиться к дочерним компонентам, например, вызвать конкретный метод в компоненте:

class ChildComponent extends React.Component {
   constructor(props) {
      super(props);
      this.makeInput = this.makeInput.bind(this);
      this.input = React.createRef();
   }

   makeInput() {
      this.input.current.focus();
   }

   render() {
      return (
         <form>
            <input ref={this.input} defaultValue="" />
         </form>
      )
   }
}

class ParentComponent extends React.Component {
   constructor(props) {
super(props);
this.child = React.createRef(); } componentDidMount = () => { this.child.current.makeInput(); } render() { return ( <ChildComponent ref={this.child} /> ) } }

Основные ошибки возникают в использовании обращения к отдельным DOM-элементам возникают из-за того, что нарушается логика декларативного построения программы. Если вы долго писали в императивном стиле или на таких библиотеках, как jQuery, вам может показаться, что refs - это инструмент для использования почти в любой ситуации, которые позволит "спрямить углы" и не заниматься постоянным обновлением состояния приложения. Это опасная иллюзия: конечно, для одно-два обращения напрямую к элементам вряд ли сломают логику вашей программы. Однако потом вы поймёте, что не можете наверняка управлять состоянием приложения тогда, когда это нужно. Возможно даже, ваша бизнес-логика начнёт зависеть от сайд-эффектов, которые вызываются прямыми обращениями к DOM-элементам. Таким образом, поток данных вашей программы может распасться на несколько частей. Поэтому всегда, когда вы видите возможность использовать ref, подумайте, как добиться того же эффекта, используя изменение состояния. Это позволит сохранить декларативную логику приложения и контроль над его данными.

Ещё одна возможная ошибка при работе с DOM-элементами напрямую - попытаться обратиться через refs к компоненту, написанному в функциональном стиле. Такое обращение закончится ошибкой:

function ChildComponent() {
  return <Input type="text" />;
}

class Parent extends React.Component {
   constructor(props) {
      super(props);
      this.child = React.createRef();
   }
   render() {   
      return (
         <ChildComponent ref={this.child} />
      );
   }
}

Хотя внутри самого функционального компонента refs будут работоспособны:

function CustomInput(props) {  
   const inputName = React.createRef();
   return (
      <form>
         <input ref={inputName} type="text" />      
      </form>
   );
}

Умные и глупые компоненты

Все компоненты React условно можно поделить на два типа: те, которые имеют свой state и которые позволяют изнутри изменять своё состояние, и те, которые не имеют state. Первые принято называть "умными" компонентами, вторые - "глупыми".

При написании React-приложения следует часть компонентов выделить в "глупые". Если вы решите сделать все компоненты "умными", на определённом этапе станет сложно поддерживать такой код. По мере роста приложения вам всё труднее будет следить за адекватностью реализации бизнес-логики. Код компонентов обрастёт лапшой из функций обратного вызова, с помощью которых вы будете прокидывать изменение состояний от компонента к компоненту.

Очевидно, в первую очередь стоит выделять в "глупые" компоненты, занимающиеся отрисовкой типовых элементов, которые можно переиспользовать много раз:

class Footer extends React.Component {
render() {
return (
<div className="common-footer">
<ul className="menu">
{this.props.menu.map((item, index) => {
return (
<li key={index}>
<a href={item.href} title={item.title}>{item.title}</a>
</li>
)
})}
</ul>
<div className="copyright">
{this.props.copyrightText}
</div>
</div>
)
}
}
...
<Footer
footerClass="main"
menu={[
{
title : "Статьи",
href : "/articles"
},
{
title : "Фото",
href : "/photo"
}
]}
copyrightText="Все права принадлежат нашей фирме (c)"
/>

Также следует задуматься о разбиении одного крупного компонента на "умный" и "глупый". В первый вынести бизнес логику, во второй - только отображение, UI. Тогда "умный" будет состоять почти целиком из состояния и функций, второй - только из блока render(). Очевидно, что "глупый" можно будет частично собрать из готовых мини-компонентов (как в примере выше), тем самым вынести набор таких View в отдельную папку, сделав удобный и быстрый конструктор для развития своего приложения.

Вложенные компоненты, и где должны храниться данные

Вложенные компоненты позволяют инкапсулировать логику, касающуюся части приложения, в одном компоненте, собирать достаточно большие компоненты из более мелких вложенных. Сам механизм включения вложенных компонентов в React прост. Используются два метода: вставка и специализация. С помощью первого мы передаём внутреннее содержимое через поле props.children, с помощью второго мы модифицируем компонент через поля в props. React не рекомендует выполнять композицию вложенных элементов путём наследования, что избавляет разработчика от возможных ошибок при работе с наследованием в JavaScript.

// вставка
function Wrapper(props) {
  return (
    <div className={'wrapper wrapper-style-' + props.color}>
      {props.children}
    </div>
  );
}

function WrappedComponent() {
  return (
    <Wrapper color="green">
      <h1>Greatings</h1>
      <p>Some text<</p>
    </Wrapper>
  );
}

ReactDOM.render(
  <WrappedComponent />,
  document.getElementById('root')
);

// специализация function Wrapper(props) { return ( <div className={'wrapper wrapper-style-' + props.color}> {props.children} </div> ); } function CommonComponent(props) { return ( <Wrapper color="green"> <h1>{props.title}</h1> <p>{props.message}</p> ); } function WrappedComponent() { return ( <CommonComponent title="Greatings!" message="Some text" /> ); } ReactDOM.render( <WrappedComponent />, document.getElementById('root') );

Сложности могут возникнуть там, где реализуется бизнес-логика приложения.

Если у нас есть дерево вложенных компонентов, "источник истины" требуется поместить в старший компонент-предок. При этом старший компонент не обязан содержать все данные о состоянии вложенных компонентов: часть данных может использоваться только внутри них и не требоваться для использования где-то ещё, либо требоваться в пределах своей ветви дерева компонентов. При написании кода на чистом React вам нужно будет решать самому, какие данные передавать выше или ниже по иерархии вложенности компонентов, а какие данные оставлять внутри компонента. Существуют также специальные библиотеки и фреймворки для управления потоком данных (Redux, Mobx), используя которые, можно облегчить взаимодействие с данными.

При написании приложения на React вам следует хорошо подумать, как будут перемещаться данные внутри приложения, какие будут ветви дерева его компонентов. Концентрация всех данных на верхнем уровне, в главном компоненте заставит вас прокидывать состояние посредством props через всё дерево компонентов, что приведёт к написанию избыточного кода. Возможна также путаница при перехвате отдельных значений с целью обработки их в нижестоящих компонентах. С другой стороны, дерево, состоящее всё из полноценных компонентов, каждый из которых хранит целиком своё состояние, означает, что бизнес-логика будет ровным слоем размазана по всему приложению. Это может доставить немало трудностей при поддержке или развитии приложения. Кроме того, скорее всего, вы однажды окажетесь в ситуации, когда старший компонент и вложенный обладают разными значениями одного и того же свойства, что может непредсказуемым образом сказаться на отрисовке view.

Поэтому чаще всего с данными на чистом React работают при создании небольших и средних приложений. Как только у вас появляется достаточно большой набор вложенных компонентов, приложение начинает развиваться дальше вслед за новыми требованиями заказчика, более разумным является подход с использованием специальных инструментов работы с состоянием. Разработка ведётся в связке React + Redux, React + Mobx, React + Relay и так далее.

Теги: