Журнал об IT-бизнесе, технологиях и цифровой трансформации

Разработка на React: как избежать ошибок Mail.ru Cloud Solutions
Mail.ru Cloud Solutions
  • 14 января
  • Разработка

Разработка на React: как избежать ошибок

Автор: Максим Слепов
Популярное
Ликбез
Что такое озера данных и зачем там хранят big data
Тренды
Эволюция квантовых вычислений: от гипотез до реальных компьютеров
Бизнес
Защита персональных данных в облаке: как сделать все по закону 152-ФЗ

Расскажу разработчикам, которые еще не используют 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>);
}

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

Допустим, у нас в программе три сущности, у каждой есть только два возможных состояния, значит, в общем случаем мы получим 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().

Чтобы избежать ошибок, важно помнить следующее:

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

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

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

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

НЕПРАВИЛЬНО!

this.state.param = doSomething();

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

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

Второй момент: состояние в 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>
        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-элементам. Таким образом, поток данных вашей программы может распасться на несколько частей. Поэтому всегда, когда вы видите возможность использовать refs, подумайте, как добиться того же эффекта, используя изменение состояния. Это позволит сохранить декларативную логику приложения и контроль над его данными.

Еще одна возможная ошибка при работе с 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) {  
   let inputName = React.createRef();
   return (
      <form>
         <input ref="{inputName}" type="text" />      
      </form>
   );
}

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

Все компоненты React условно можно поделить на два типа:

  • «умные» компоненты — те, которые имеют свой state и позволяют изнутри изменять свое состояние;
  • «глупые» компоненты — те, которые не имеют state.

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

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

class Footer extends React.Component {
  render() {
     return (
        <div classname="{&quot;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 и так далее.

Ссылка скопирована!

Что еще почитать про ИТ-бизнес