Обработка ввода с состоянием в React

год назад·8 мин. на чтение

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

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

Сравнение декларативного пользовательского интерфейса с императивным

Когда вы проектируете взаимодействия с пользовательским интерфейсом, вы, вероятно, думаете о том, как пользовательский интерфейс изменяется в ответ на действия пользователя. Рассмотрим форму, которая позволяет пользователю отправить ответ:
  • Когда вы вводите что-то в форму, кнопка «Отправить» становится активной.
  • Когда вы нажимаете «Отправить», и форма, и кнопка блокируются, и появляется анимация ожидания.
  • Если сетевой запрос выполнен успешно, форма скрывается и появляется сообщение «Спасибо».
  • Если сетевой запрос завершается неудачно, появляется сообщение об ошибке, и форма снова становится доступной.
В императивном программировании вышеизложенное напрямую соответствует тому, как вы реализуете взаимодействие. Вы должны написать точные инструкции для управления пользовательским интерфейсом в зависимости от того, что только что произошло. Вот еще один способ подумать об этом: представьте, что вы едете рядом в машине в качестве пассажира и говорите водителю как ехать и где сворачивать и в какую сторону.
Водитель не знают, куда вы хотите поехать, он просто следуют вашим командам. (И если вы ошибетесь, вы окажетесь не в том месте) Это называется императивным подходом, потому что вы должны «командовать» каждым элементом, от счетчика до кнопки, сообщая компьютеру, как обновить пользовательский интерфейс. В этом примере императивного программирования пользовательского интерфейса форма создается без использования React. Он использует встроенный в браузер DOM:
async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() == 'istanbul') {
        resolve();
      } else {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;
<!-- index.html -->

<form id="form">
  <h2>City quiz</h2>
  <p>What city is located on two continents?</p>
  <textarea id="textarea"></textarea>
  <br />
  <button id="button" disabled>Submit</button>
  <p id="loading" style="display: none">Loading...</p>
  <p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1>

<style>
  * {
    box-sizing: border-box;
  }
  body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
  }
</style>
Императивное управление пользовательским интерфейсом работает достаточно хорошо для отдельных примеров, но в более сложных системах управлять им становится экспоненциально сложнее. Представьте, что вы обновляете страницу, полную различных форм, подобных этой. Добавление нового элемента пользовательского интерфейса или нового взаимодействия потребует тщательной проверки всего существующего кода, чтобы убедиться, что вы не внесли ошибку (например, забыли что-то показать или скрыть). React был создан, чтобы решить эту проблему. В React вы не управляете пользовательским интерфейсом напрямую — это означает, что вы не включаете, не отключаете, не показываете и не скрываете компоненты напрямую. Вместо этого вы объявляете, что хотите показать, а React выясняет, как обновить пользовательский интерфейс. Подумайте о том, чтобы сесть в такси и сказать водителю, куда вы хотите ехать, вместо того, чтобы указывать ему, где именно повернуть. Доставить вас туда — работа водителя, и они могут даже знать некоторые короткие пути, о которых вы не подумали.

Декларативный подход к пользовательскому интерфейсу

Вы видели, как императивно реализовать форму выше. Чтобы лучше понять, как мыслить в React, выполним реализацию этого пользовательского интерфейса в React:
  1. Определить различные визуальные состояния компонента
  2. Определить, что вызывает эти изменения состояния
  3. Представить состояние в памяти, используя useState
  4. Удалить все несущественные переменные состояния
  5. Подключить обработчики событий для установки состояния

Шаг 1. Определить различные визуальные состояния компонента

В информатике вы могли слышать о «машине состояний» (state machine, стейт машина, конечный автомат), находящейся в одном из нескольких «состояний». Если вы работаете с дизайнером, возможно, вы видели мокапы для разных «визуальных состояний». React стоит на стыке дизайна и информатики, поэтому обе эти идеи являются источниками вдохновения. Во-первых, вам нужно визуализировать все различные «состояния» пользовательского интерфейса, которые может видеть пользователь:
  • Пусто (empty): в форме отключена кнопка «Отправить».
  • Ввод (typing): Форма имеет активную кнопку «Отправить».
  • Отправка (submitting): Форма полностью отключена. Показана анимация ожидания.
  • Успех (success): вместо формы отображается сообщение «Спасибо».
  • Ошибка (error): то же, что и состояние ввода, но с дополнительным сообщением об ошибке.
Так же, как дизайнер, вы захотите создавать «макеты» для различных состояний, прежде чем добавлять логику. Например, вот макет только для визуальной части формы. Этот макет управляется пропсом, называемым status, со значением по умолчанию 'empty':
export default function Form({
  // Try 'submitting', 'error', 'success':
  status = 'empty',
}) {
  if (status === 'success') {
    return <h1>That's right!</h1>;
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form>
        <textarea disabled={status === 'submitting'} />
        <br />
        <button disabled={status === 'empty' || status === 'submitting'}>
          Submit
        </button>
        {status === 'error' && (
          <p className="Error">Good guess but a wrong answer. Try again!</p>
        )}
      </form>
    </>
  );
}
Как отобразить множество визуальных состояний компонента одновременно? Если у компонента много визуальных состояний, как у компонента Form выше, будет удобно показать их все на одной странице. Такие страницы часто называют «живыми руководствами по стилю» (living styleguides) или storybooks.

Шаг 2. Определить, что вызывает эти изменения состояния

Вы можете запускать обновления состояния в ответ на два типа входных данных:
  • Ввод пользователя, например нажатие кнопки, ввод в поле, переход по ссылке.
  • Входные данные компьютера, такие как получение сетевого ответа, завершение тайм-аута, загрузка изображения.
В обоих случаях необходимо установить переменные состояния для обновления пользовательского интерфейса. Для формы, которую вы разрабатываете, вам нужно будет изменить состояние в ответ на несколько разных входных данных:
  • Изменение ввода текста (пользователем) должно переключить его из пустого (empty) состояния в состояние ввода (typing) или обратно, в зависимости от того, пусто текстовое поле или нет.
  • Нажатие (пользователем) кнопки «Отправить» должно переключить ее в состояние «Отправка» (submitting).
  • Успешный сетевой ответ (компьютер) должен перевести его в состояние успеха (success).
  • Неудачный сетевой ответ (компьютер) должен перевести его в состояние (error) с соответствующим сообщением об ошибке.
Обратите внимание, что для ввода пользователя часто требуются обработчики событий. Чтобы визуализировать этот поток, попробуйте нарисовать каждое состояние на бумаге в виде помеченного круга, а каждое изменение между двумя состояниями — в виде стрелки. Таким образом можно набросать множество потоков и разобраться с ошибками задолго до реализации.

Шаг 3: Представить состояние в памяти, используя useState

Далее вам нужно представить визуальные состояния вашего компонента в памяти с помощью useState. Ключевым моментом является простота: каждая часть состояния является «движущейся частью», и вам нужно как можно меньше «движущихся частей». Большая сложность приводит к большему количеству ошибок. Начните с состояния, которое обязательно должно быть. Например, вам нужно сохранить ответ для ввода и ошибку (если она существует) для сохранения последней ошибки:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
Затем вам понадобится переменная состояния, представляющая, какое из визуальных состояний, описанных ранее, вы хотите отобразить. Обычно существует более одного способа представить это в памяти, поэтому вам нужно поэкспериментировать с ним. Если вы изо всех сил пытаетесь сразу придумать лучший способ, начните с добавления достаточного количества состояний, чтобы быть уверенным, что охватываются все возможные визуальные состояния:
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
Ваша первая идея, скорее всего, не будет лучшей, но это нормально — рефакторинг — это часть процесса.

Шаг 4. Удалить все несущественные переменные состояния

Вы хотите избежать дублирования в содержании состояния, чтобы отслеживать только то, что необходимо. Потратив немного времени на рефакторинг вашей структуры состояния, вы облегчите понимание своих компонентов, уменьшите дублирование и предотвратите непреднамеренное значение. Ваша цель — предотвратить случаи, когда состояние в памяти не представляет никакого допустимого пользовательского интерфейса, который вы хотели бы видеть пользователю. (Например, вы никогда не хотите показывать сообщение об ошибке и одновременно отключать ввод, иначе пользователь не сможет исправить ошибку.) Вот несколько вопросов, которые вы можете задать про свои переменные состояния:
  • Вызывает ли это состояние парадокс? Например, isTyping и isSubmitting не могут одновременно быть истинными. Парадокс обычно означает, что состояние недостаточно ограничено. Существует четыре возможных комбинации двух логических значений, но только три соответствуют действительным состояниям. Чтобы удалить невозможное состояние, вы можете объединить их в состояние, которое должно иметь одно из трех значений: «ввод», «отправка» или «успех».
  • Доступна ли та же информация в другой переменной состояния? Еще один парадокс: isEmpty и isTyping не могут быть истинными одновременно. Делая их отдельными переменными состояния, вы рискуете рассинхронизировать их и вызвать ошибки. К счастью, вы можете удалить isEmpty и вместо этого проверить answer.length === 0.
  • Можно ли получить ту же информацию из инверсии другой переменной состояния? isError не нужен, потому что вместо этого вы можете проверить error !== null.
После этой очистки у вас осталось 3 (вместо 7!) основных переменных состояния:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
Вы знаете, что они необходимы, потому что вы не можете удалить ни один из них, не нарушив функциональность.

Как устраненить «невозможные» состояния с помощью редьюсера

Эти три переменные являются достаточно хорошим представлением состояния этой формы. Тем не менее, есть еще некоторые промежуточные состояния, которые не имеют полного смысла. Например, ненулевая ошибка не имеет смысла, когда статус равен «успех». Чтобы точнее смоделировать состояние, вы можете извлечь его в редьюсер. Редьюсеры позволяют объединить несколько переменных состояния в один объект и объединить всю связанную логику.

Шаг 5: Подключить обработчики событий для установки состояния

Наконец, создайте обработчики событий для установки переменных состояния. Ниже приведена окончательная форма со всеми подключенными обработчиками событий:
import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>;
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={answer.length === 0 || status === 'submitting'}>
          Submit
        </button>
        {error !== null && <p className="Error">{error.message}</p>}
      </form>
    </>
  );
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima';
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}
Хотя этот код длиннее исходного императивного примера, он намного менее подвержен ошибкам. Выражение всех взаимодействий в виде изменений состояния позволяет позже вводить новые визуальные состояния, не нарушая существующие. Это также позволяет вам изменить то, что должно отображаться в каждом состоянии, не меняя логику самого взаимодействия.

Резюме

  • Декларативное программирование означает описание пользовательского интерфейса для каждого визуального состояния, а не микроуправление пользовательским интерфейсом (императивное).
  • При разработке компонента:
    1. Определите все его визуальные состояния.
    2. Определите триггеры пользователя и компьютера для изменения состояния.
    3. Смоделируйте состояние с помощью useState.
    4. Удалите несущественное состояние, чтобы избежать ошибок и парадоксов.
    5. Подключите обработчики событий для установки состояния.

Рендеринг и фиксация в React

год назад·1 мин. на чтение

Прежде чем ваши компоненты отобразятся на экране, они должны быть обработаны React. Понимание шагов этого процесса поможет вам понять, как выполняется ваш код, и объяснить его поведение.

Содержание туториала по React Прежде чем ваши компоненты отобразятся на экране, они должны быть обработаны React. Понимание шагов этого процесса поможет вам понять, как выполняется ваш код, и объяснить его поведение. Представьте, что ваши компоненты — повара на кухне, собирающие вкусные блюда из ингредиентов. В этом сценарии React — это официант, который принимает запросы от клиентов и приносит им их заказы. Этот процесс запроса и обслуживания пользовательского интерфейса состоит из трех этапов:
  1. Запуск рендера (доставка заказа гостя на кухню)
  2. Рендер компонента (подготовка заказа на кухне)
  3. Фиксация (commit) в DOM (размещение заказа на столе)

Шаг 1. Запуск рендеринга

Есть две причины для рендеринга компонента:
  • Это начальный рендер компонента.
  • Состояние компонента (или одного из его предков) было обновлено.

Начальный рендер

Когда ваше приложение запускается, вам нужно запустить первоначальный рендеринг. Фреймворки и песочницы иногда скрывают этот код, но это делается путем вызова createRoot с целевым узлом DOM, а затем вызова его метода рендеринга с вашим компонентом:
// index.js

import Image from './Image.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(<Image />);
// Image.js

export default function Image() {
  return <img src="https://example.com/image.jpg" alt="image" />;
}

Рендеринг при обновлении состояния

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

Шаг 2: React визуализирует ваши компоненты

После запуска рендеринга React вызывает ваши компоненты, чтобы выяснить, что отображать на экране. «Рендеринг» — это React, вызывающий ваши компоненты.
  • При первоначальном рендеринге React вызовет корневой компонент.
  • Для последующих рендеров React будет вызывать функциональный компонент, обновление состояния которого инициировало рендеринг.
Этот процесс является рекурсивным: если обновленный компонент возвращает какой-то другой компонент, React отрисовывает этот компонент следующим, а если этот компонент также что-то возвращает, он отрисовывает этот компонент следующим и так далее. Процесс будет продолжаться до тех пор, пока не останется вложенных компонентов и React точно не будет знать, что должно отображаться на экране. В следующем примере React вызовет Gallery() и Image() несколько раз:
// Gallery.js

export default function Gallery() {
  return (
    <section>
      <h1>Inspiring Sculptures</h1>
      <Image />
      <Image />
      <Image />
    </section>
  );
}

function Image() {
  return <img src="https://example.com/image.jpg" alt="image" />;
}
// index.js

import Gallery from './Gallery.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(<Gallery />);
  • Во время первоначального рендеринга React создаст узлы DOM для тегов <section>, <h1> и трех тегов <img>.
  • Во время повторного рендеринга React вычислит, какие из их пропсов, если таковые имеются, изменились с момента предыдущего рендеринга. Он ничего не будет делать с этой информацией до следующего шага, фазы фиксации.
Рендеринг всегда должен быть чистым:
  • При тех же входах, тот же выход. При одинаковых входных данных компонент всегда должен возвращать один и тот же JSX. (Когда кто-то заказывает салат с помидорами, он не должен получать салат с луком.)
  • Он думает только о своих делах. Он не должен изменять какие-либо объекты или переменные, существовавшие до рендеринга. (Один заказ не должен изменять чей-либо другой заказ.)
В противном случае вы можете столкнуться с запутанными ошибками и непредсказуемым поведением по мере усложнения вашей кодовой базы. При разработке в «строгом режиме» React дважды вызывает функцию каждого компонента, что может помочь обнаружить ошибки, вызванные нечистыми функциями.

Оптимизация производительности

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

Шаг 3: React фиксирует изменения в DOM

После рендеринга (вызова) ваших компонентов React изменит DOM.
  • Для начального рендеринга React будет использовать DOM API appendChild() для размещения на экране всех созданных DOM-узлов.
  • Для повторного рендеринга React применит минимально необходимые операции (рассчитанные во время рендеринга), чтобы привести DOM в соответствие с последним результатом рендеринга.
React изменяет DOM узлы только в том случае, если между рендерами есть разница. Например, вот компонент, который каждую секунду перерисовывается с разными пропсами, переданными от его родителя. Обратите внимание, как вы можете добавить некоторый текст в <input>, обновив его значение, но текст не исчезнет при повторном рендеринге компонента:
// Clock.js

export default function Clock({ time }) {
  return (
    <>
      <h1>{time}</h1>
      <input />
    </>
  );
}
Это работает, потому что на этом последнем шаге React только обновляет содержимое <h1> новым значением времени. Он видит, что <input> появляется в JSX в том же месте, что и в прошлый раз, поэтому React не касается <input> или его значения.

Отрисовка браузером

После того, как рендеринг завершен и React обновил DOM, браузер перерисует экран. Хотя этот процесс известен как "рендеринг в браузере", мы будем называть его "отрисовкой", чтобы избежать путаницы в остальной части этого руководства.

Резюме

Любое обновление экрана в приложении React происходит в три этапа:
  • Запуск
  • Рендеринг
  • Фиксация
Вы можете использовать строгий режим для поиска ошибок в ваших компонентах. React не затрагивает DOM, если результат рендеринга такой же, как и в прошлый раз.