Русский
Русский
English
Статистика
Реклама

React.js

Перевод Мифы о useEffect

07.10.2020 10:05:19 | Автор: admin


Доброго времени суток, друзья!

Представляю вашему вниманию перевод небольшой заметки Kent C. Dodds, в которой он делится своими соображениями относительно правильного использования хука useEffect.

Я обучил React тысячи разработчиков. Как до, так и после релиза
хуков. Одной из вещей, которые я заметил, является не очень четкое понимание
назначения и механизма работы хука useEffect. В этой статье я хочу немного об этом рассказать.

Никакого отношения к стадиям жизненного цикла синхронизация дополнительных эффектов


Разработчики, которые имеют опыт работы с классовыми компонентами и стадиями
жизненного цикла, такими как constructor, componentDidMount,
componentDidUpdate и componentWillUnmount, иногда пытаются реализовать
аналогичное поведение в функциональных компонентах с помощью хуков. Это большая
ошибка. Позвольте мне это продемонстрировать. Вот пример забавного
пользовательского интерфейса:



Вот реализация компонента DogInfo с помощью классов:

  class DogInfo extends React.Component {    controller = null;    state = { dog: null };    fetchDog() {      this.controller?.abort();      this.controller = new AbortController();      getDog(this.props.dogId, { signal: this.controller.signal }).then(        (dog) => {          this.setState({ dog });        },        (error) => {          // обработка ошибок        }      );    }    componentDidMount() {      this.fetchDog();    }    componentDidUpdate(prevProps) {      // обработка изменения dogId      if (prevProps.dogId !== this.props.dogId) {        this.fetchDog();      }    }    componentWillUnmount() {      // отмена запроса      this.controller?.abort();    }    render() {      return <div>{/* рендеринг информации о собаке */}</div>;    }  }

Это стандартный компонент для такого вида интеракции. В нем
используются стадии жизненного цикла constructor, componentDidMount,
componentDidUpdate и componentWillUnmount. Вот что получится, если мы
обернем эти стадии в хуки:

  function DogInfo({ dogId }) {    const controllerRef = React.useRef(null);    const [dog, setDog] = React.useState(null);    function fetchDog() {      controllerRef.current?.abort();      controllerRef.current = new AbortController();      getDog(dogId, { signal: controllerRef.current.signal }).then(        (d) => setDog(d),        (er) => {          // обработка ошибок        }      );    }    // didMount    React.useEffect(() => {      fetchDog();      // eslint-disable-next-line react-hooks/exhaustive-deps    }, []);    // didUpdate    const prevDogId = usePrevious(dogId);    useUpdate(() => {      if (prevDogId !== dogId) {        fetchDog();      }    });    // willUnmount    React.useEffect(() => {      return () => {        controllerRef.current?.abort();      };    }, []);    return <div>{/* рендеринг информации о собаке */}</div>;  }  function usePrevious(value) {    const ref = useRef();    useEffect(() => {      ref.current = value;    }, [value]);    return ref.current;  }

Здесь имеется некоторая несогласованность между хуками. Если бы они
предназначались для использования таким образом, я тоже был бы их противником.
Но правда в том, что useEffect это не стадия жизненного цикла. Это механизм
синхронизации дополнительных эффектов с состоянием приложения. В
приведенном примере наша задача состоит в том, чтобы запрашивать информацию о
собаке при изменении dogId. Учитывая это, useEffect становится намного проще:

  function DogInfo({ dogId }) {    const [dog, setDog] = useState(null);    useEffect(() => {      const controller = new AbortController();      getDog(dogId, { signal: controller.signal }).then(        (d) => setDog(d),        (er) => {          // обработка ошибок        }      );      return () => controller.abort();    }, [dogId]);    return <div>{/* рендеринг информации о собаке */}</div>;  }

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

Запомните высказывание Ryan Florence:
Вопрос не в том, когда запускать эффект, вопрос в том, с каким состоянием
он должен синхронизироваться

useEffect(fn) // все состояния
useEffect(fn, []) // отсутствие состояний
useEffect(fn, [these, states]) // указанные состояния

Могу я игнорировать eslint-plugin-react-hooks/exhaustive-deps?


Ну, технически да. И иногда для этого существуют хорошие причины. Однако, в
большинстве случаев это является плохой идеей, игнорирование этого правила почти наверняка приведет к ошибкам. Обычно, люди после этого говорят: Но я только
хотел, чтобы это запускалось после рендеринга!. Вот опять. Думать о хуках в
категориях жизненного цикла неправильно. Если ваш колбэк в useEffect имеет
зависимость, необходимо убедиться, что он выполняется при каждом изменении
этой зависимости. В противном случае, эффект не будет синхронизирован с
состоянием приложения. Короче говоря, у вас будут проблемы. Не игнорируйте
данное правило.

Один большой useEffect


Честно говоря, я давно такого не видел. Но такое порой все-таки случается. То,
что мне действительно нравится в useEffect, так это возможность разделения задачи на любое количество подзадач. Вот простой пример:



Вот некоторый всевдокод для этого демо:

  class ChatFeed extends React.Component {    componentDidMount() {      this.subscribeToFeed();      this.setDocumentTitle();      this.subscribeToOnlineStatus();      this.subscribeToGeoLocation();    }    componentWillUnmount() {      this.unsubscribeFromFeed();      this.restoreDocumentTitle();      this.unsubscribeFromOnlineStatus();      this.unsubscribeFromGeoLocation();    }    componentDidUpdate(prevProps, prevState) {      // ... сранение пропсов, повторной подписки и т.д.    }    render() {      return <div>{/* интерфейс чата */}</div>;    }  }

Видите эти 4 задачи? Они смешаны. Что, если вы захотите поделиться частью функционала? Я имею ввиду, что пропсы рендеринга отличная вещь, но хуки
все же лучше. Я видел, как некоторые люди создают огромный useEffect, отвечающий
за все:

  function ChatFeed() {    React.useEffect(() => {      // подписка на ленту      // установка заголовка документа      // перевод статуса в онлайн      // определения местоположения      return () => {        // отписка от ленты        // восстановление заголовка        // перевод статуса в офлайн        // отключение определения местоположения      };    });    return <div>{/* интерфейс чата */}</div>;  }

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

  function ChatFeed() {    React.useEffect(() => {      // подписка на ленту      return () => {        // отписка от ленты      };    });    React.useEffect(() => {      // установка заголовка документа      return () => {        // восстановление заголовка      };    });    React.useEffect(() => {      // перевод статуса в онлайн      return () => {        // перевод статуса в офлайн      };    });    React.useEffect(() => {      // определения местоположения      return () => {        // отключение определения местоположения      };    });    return <div>{/* интерфейс чата */}</div>;  }

Самодостаточность хуков дает массу преимуществ.

Внешние функции


Я видел такое несколько раз. Позвольте мне просто привести код до и после:

  // до. Не делайте так  function DogInfo({ dogId }) {    const [dog, setDog] = React.useState(null);    const controllerRef = React.useRef(null);    const fetchDog = React.useCallback((dogId) => {      controllerRef.current?.abort();      controllerRef.current = new AbortController();      return getDog(dogId, { signal: controller.signal }).then(        (d) => setDog(d),        (error) => {          // обработка ошибок        }      );    }, []);    React.useEffect(() => {      fetchDog(dogId);      return () => controller.current?.abort();    }, [dogId, fetchDog]);    return <div>{/* рендеринг информации о собаках */}</div>;  }

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

  function DogInfo({ dogId }) {    const [dog, setDog] = React.useState(null);    React.useEffect(() => {      const controller = new AbortController();      getDog(dogId, { signal: controller.signal }).then(        (d) => setDog(d),        (error) => {          // обработка ошибок        }      );      return () => controller.abort();    }, [dogId]);    return <div>{/* рендеринг информации о собаках */}</div>;  }

Я пытаюсь сказать, что определение функции за пределами колбэка useEffect это плохая идея. Поскольку такая функция является внешней по отношению к useEffect, нам приходится добавлять ее в список зависимостей. Также нам приходится кэшировать ее во избежание бесконечного цикла. Кроме того, мы вынуждены создавать ref для контроллера.

Запомните: при необходимости определения функции, вызываемой в эффекте, определяйте ее внутри колбэка данного эффекта, а не за его пределами.

Заключение


Когда Dan Abramov представил хуки, такие как useEffect, он сравнил компоненты с атомами, а хуки с электронами. Хуки представляют собой низкоуровневые примитивы, и именно это делает их такими мощными. Красота этих примитивов заключается в абстрагировании стадий жизненного цикла, с которыми мы имели дело раньше. С момента релиза хуков мы наблюдаем взрыв инноваций и появление хороших идей и библиотек на основе этих примитивов, что помогает нам, как разработчикам, создавать более качественные приложения.
Подробнее..

React Используйте стандартные пропсы для потока данных

11.11.2020 22:08:58 | Автор: admin


Давайте поговорим про поток данных React приложения состоящего из набора форм.

Предполагается, что читатель знаком с react, react-хуками, функциональными компонентами, мемоизацией хорошо знает javascript и не пугается spread операторов (три точки которые).

Я постараюсь подвести вас к логичному для меня выводу, что зачастую компонентам, которые отображают форму или часть формы, достаточно иметь всего три пропса.

Представьте себе сложную форму которая состоит из нескольких частей, а те в свою очередь состоят из других частей.

Например форма редактирования данных о пользователе:

const UserForm = () =>  <FormBlock>    <UserInfo/>    <Experience/>    <Education/>  </FormBlock>

В компоненте UserInfo редактируются поля firstName, lastName.

В компоненте Experience редактируются поля positionName, positionDescription.

В компоненте Education редактируются поля name, description.

Попробуем реализовать компонент UserInfo


Иногда я встречаю такую реализацию:

const UserInfo = ({  firstName,  onChangeFirstName,  lastName,  onChangeLastName,}) =>  <FormBlock>    <Label>First Name</Label>    <Input       value={firstName}      onChange={({ target: { value } }) => onChangeFirstName(value)}    />    <Label>Last Name</Label>    <Input      value={lastName}      onChange={({ target: { value } }) => onChangeLastName(value)}    />  </FormBlock>

И такой вызов из UserForm:

const UserForm = ({  firstName,  onChangeFirstName,  lastName,  onChangeLastName,}) =>  <FormBlock>    <UserInfo      firstName={firstName}      onChangeFirstName={onChangeFirstName}      lastName={lastName}      onChangeLastName={onChangeLastName}    />  </FormBlock>

Но не делайте так. Если пойдет в таком духе, то на входе у UserForm будут все пропсы из компонентов UserInfo, Experience и Education. Мне даже лень этот код вам писать.

Обычно всем тоже лень и в итоге просто вместо прописывания всех пропсов используют spread оператор:

const UserForm = (props) =>  <FormBlock>    <UserInfo {...props} />    <Experience {...props} />    <Education {...props} />  </FormBlock>

И дальше надеются, что каждый компонент сам выберет себе нужные пропсы.

Пожалуйста, так тоже не делайте. Вы подвергаете свой код неявным ошибкам. Мало ли что может залететь в UserForm что не желательно чтобы было в Education. Ну например пропс className или style которые пол года назад использовались чтобы стилизовать UserForm, потом в UserForm это убрали, а в Education такой пропс добавили. И вот кто-то забыл почистить код и где-то остались вызовы UserForm с className. Теперь неожиданно для всех className прокинется в Education.

Всегда явно прокидуйте пропсы чтобы это видно было по коду какие пропсы в какие компоненты попадают.

Что мы можем с этим всем сделать?


Давайте посмотрим на обычные поля ввода которые перекочевали в реакт из HTML. Огромное спасибо разработчикам реакта, что сохранили тот же интерфейс привычный всем, а не, как в ангуляре, напридумывали своих конструкций.

Возьмем, например, тег input. У него есть знакомые всем пропсы: value, onChange и, внимание, name .

По сути это все три пропсы достаточные для передачи потока данных.

Вот так например будет выглядеть теперь UserInfo:

const UserInfo = ({  name,  value,  onChange,}) =>  <FormBlock>    <Label>First Name</Label>    <Input       name={'firstName'}       value={value['firstName']}       onChange={({ target }) => onChange({target: { name, value: { ...value, [target.name]: target.value } }})}    />    <Label>Last Name</Label>    <Input      value={lastName}      onChange={({ target: { value } }) => onChangeLastName(value)}    />  </FormBlock>

Тут я в компоненте UserInfo использую стандартные три пропса. И что немаловажно повторил интерфейс вызова события onChange. Он так же само возвращает информацию о изменениях как это делает стандартный input используя target, name, value. С одной стороны target добавляет еще уровень вложенности, но так уж исторически сложилось у стандартного события onChange, с этим уже ничего не поделаешь. Зато мы получаем очень важное преимущество одинаковое поведение всех полей ввода и частей формы.

То есть мы можем теперь переписать UserForm.

Если у нас данные хранятся как такой объект:

{ firstName, lastName, positionName, positionDescription, name, description }

То пишем так:

const UserForm = ({  name,  value,  onChange,}) =>  <FormBlock>    <UserInfo       value={value}       onChange={({ target }) => onChange({target: { name, value: target.value }})}    />   .......  </FormBlock>

Если у нас данные хранятся как такой объект:

{  userInfo: { firstName, lastName },  position: { positionName, positionDescription },  education: { name, description }}

То пишем так:

const UserForm = ({  name,  value,  onChange,}) =>  <FormBlock>    <UserInfo       name={'userInfo'}       value={value['userInfo']}       onChange={({ target }) => onChange({target: { name, value: { ...value, [target.name]: target.value } }})}    />   .......  </FormBlock>

Как видим, количество пропсов на входе UserForm уменьшилось с 2*N до всего трех.
Но это только часть выгоды.

Как сделать код компактней и читабельней?


Так как у нас теперь везде одинаковый интерфейс, то теперь можно написать вспомогательные функции, которые будут работать со всеми такими компонентами.

Например, представим функцию getInnerProps, которая мапит вложенные данные на вложенные компоненты. Тогда код компонентов становится намного лаконичней:

const UserInfo = ({ name, value, onChange }) => {   const innerProps = getInnerProps({name, value, onChange})   return <FormBlock>    <Label>First Name</Label>    <Input {...innerProps.forInput('firstName')} />    <Label>Last Name</Label>    <Input {...innerProps.forInput('lastName')} />  </FormBlock>}const UserForm = ({  name,  value,  onChange,}) => {  const innerProps = getInnerProps({name, value, onChange})  return <FormBlock>    <UserInfo {...innerProps.forInput('userInfo')} />    <Experience {...innerProps.forInput('position')} />    <Education {...innerProps.forInput('education')} />  </FormBlock>}

Обратите внимание, что одна и та же функция innerProps.forInput() формирует пропсы name, value и onChange и для стандартного поля ввода Input и для компонента UserInfo. Все благодаря одному интерфейсу потока данных.

Усложним пример


Допустим, пользователю нужно ввести несколько образований (education). Один из вариантов решения (на мой взгляд неправильного):

const UserForm = ({  educations,  onChangeEducation,}) =>  <FormBlock>    {Object.entries(educations).map(([id, education]) => <Education      name={name}      description={description}      onChangeName={(name) => onChangeEducation(id, { ...education, name })}      onChangeDescription={(description) => onChangeEducation(id, { ...education, description })}    />}  </FormBlock>

Обработчик onChangeEducation будет менять в нужном месте стора education по его id. Тут есть небольшое противоречие. На вход прилетает коллекция educations, а на событие изменения возвращается один education.

Можно часть кода перенести из Redux в компонент. Тогда станет все логичней. На вход UserForm приходит коллекция educations и на событие изменения уходит тоже коллекция educations:

const UserForm = ({  educations,  onChangeEducations,}) =>  <FormBlock>    {Object.entries(educations).map(([id, education]) => <Education      name={name}      description={description}      onChangeName={(name) => onChangeEducations({ ...educations, [id]: { ...education, name } })}      onChangeDescription={(description) => onChangeEducations({ ...educations, [id]: { ...education, description } })}    />}  </FormBlock>

Немного остановимся на том как мы передаем обработчик в onChangeName и onChangeDescription. Я сознательно не обращал на это внимание для минимизации примеров. Но сейчас это важно.

В реальности компонент Education будет скорее всего мемоизированный (React.memo()). Тогда мемоизация не будет иметь смысла из-за того, что каждый раз мы передаем новую ссылку на функцию. Чтобы не создавать каждый раз новую ссылку используют хук useCallback или useConstant (отдельный npm модуль).

Если в остальных примерах это решало проблему, то тут цикл, а хуки нельзя использовать внутри условий и циклов.

А вот используя name и ожидая от Education стандартного поведения onChange уже можно применить хук useConstant:

const UserForm = ({  name,  value,  onChange,}) => {  const onChangeEducation=({ target }) => useConstant(onChange({    target: {      name,      value: {        ...value,        educations: { ...value.educations, [target.name]: target.value ] }      }    }  }))  return <FormBlock>  {Object.entries(educations).map(([id, education]) => <Education      name={id}      value={education}       onChange={onChangeEducation}    />  )}  </FormBlock>

А теперь сделаем с помощью функции getInnerProps:

const Education = ({ name, value, onChange }) => {   const innerProps = getInnerProps({name, value, onChange})   return <FormBlock>    <Label>Name</Label>    <Input {...innerProps.forInput('name')} />    <Label>Description</Label>    <Input {...innerProps.forInput('description')} />  </FormBlock>}const Educations = ({ name, value, onChange }) => {   const innerProps = getInnerProps({name, value, onChange})   return Object.keys(value).map((id) =>     <Education {...innerProps.forInput('id')} />  )}const UserForm = ({  name,  value,  onChange,}) => {  const innerProps = getInnerProps({name, value, onChange})  return <FormBlock>    <UserInfo {...innerProps.forInput('userInfo')} />    <Experience {...innerProps.forInput('position')} />    <Educations {...innerProps.forInput('educations')} />  </FormBlock>}

Вроде как достаточно красивый и понятный код получился.

Несколько слов про стейт


Подключим stateless компонент UserInfo к стейту и замкнем поток данных. В качестве примера возьмем Redux.

Вот так иногда реализовывают редюсер:

const reducer = (state = initState, action) {  switch(action.type) {    case CHANGE_FIRST_NAME:       return { ...state, userInfo: { ...state.userInfo, firstName: action.payload } }    case CHANGE_LAST_NAME:       return { ...state, userInfo: { ...state.userInfo, lastName: action.payload } }   ........  }}

В этом подходе я вижу два сомнительных плюса и один большой минус.

Первый плюс это то, что можно написать тест для этого редсера. Сомнителен потому что вряд ли этот тест сильно поможет.

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

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

Да, в документации по редаксу говорят, что надо писать редюсеры не такие у которых только set, а такие в которых побольше екшенов. Типа чем больше экшенов в реюсере, тем больше тестов можно написать. Больше тестов меньше ошибок.

Но я думаю меньше ошибок там, где меньше кода, а много экшенав нужно писать только там где это надо.

Для себя я решил что для форм в редаксе, везде где это возможно, использую только один action какой-нибудь SET.

const reducer = (state = initState, action) {  switch(action.type) {    case SET_USER_FORM_DATA:       return { ...state, value: action.payload }     ........  }}

А уже на UI (т.е. в реакте) я определяю какие поля в какой части данных меняются.

const UserFormContainer = () => {  const dispatch = useDispatch()  return <UserForm    value={useSelector(({ userForm }) => userForm?.value)}    onChange={({target: { value } }) => dispatch(userFormActions.set(value)}  />}

Потому как логику специфических полей не опишешь в редаксе. Например поле ввода номера телефона это может быть сложный реакт компонент компонент, а не просто изменение значение в стейте.

Когда применять описанный подход


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

Если у вас приложение со сложным интерфесом в котором разные компоненты взаимодействуют друг с другом описанное в статье мало что вам даст. Как раз в этом случае логично каждый компонент подключать к стору.

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

Резюме


Давайте использовать одинаковые пропсы для потока данных, пропсы которые уже давно есть в HTML:

  • name
  • value,
  • onChange({target: { name, value }})

Старайтесь в onChange придерживаться той же структуры, как и в реактовском onChange.

Старайтесь на onChange в target.value возвращать ту же сущность что на вход в value.

Тогда, за счет использования стандартного подхода и общих хелперных функций для этого подхода, код станет лаконичней и понятней.
Подробнее..

Перевод Заметка о том, как React обновляет состояние

22.12.2020 14:23:50 | Автор: admin


Доброго времени суток, друзья!

Хук useState() управляет состоянием в функциональных компонентах React. В классовых компонентах состояние хранится в this.state, а для обновления вызывается метод this.setState().

Обычно, в работе с состоянием нет ничего сложного. Тем не менее, существует один важный нюанс, связанный с его обновлением.

Каким образом состояние обновляется: незамедлительно (синхронно) или отложенно (асинхронно)? Читайте дальше, чтобы узнать ответ.

1. Обноление состояния с помощью useState()


Допустим у нас имеется такой функциональный компонент:

import { useState } from 'react'function DoubleIncreaser() {  const [count, setCount] = useState(0)  const doubleIncreaseHandler = () => {    setCount(count + 1)    setCount(count + 1)  }  return (    <>      <button onClick={doubleIncreaseHandler}>        Double Increase      </button>      <div>Count: {count}</div>    </>  )}

const [count, setCount] = useState(0) определяет начальное состояние компонента. count переменная, содержащая текущее состояние, а setCount функция обновления этого состояния.

Компонент содержит кнопку Double Increase. При нажатии на эту кнопку вызывается обработчик doubleIncreaseHandler, осуществляющий два последовательных обновления count: setCount(count + 1) и затем еще раз setCount(count + 1).

Каким будет состояние компонента после нажатия кнопки, 1 или 2?

Откройте это демо и нажмите на кнопку. Значение count будет увеличиваться на 1 после каждого клика.

Когда setCount(count + 1) обновляет состояние, значение count не изменяется сразу. Вместо этого, React планирует обновление состояния и при следующем рендеринге в выражении const [count, setCount] = useState(0) хук присваивает count новое значение.

Например: если значением переменной count является 0, то вызов setCount(count + 1); setCount(count + 1) оценивается как setCount(0 + 1); setCount(0 + 1) что приводит к 1 как значению состояния при следующем рендеринге.

Таким образом, обновление состояния с помощью setValue(newValue) в выражении [value, setValue] = useState() осуществляется асинхронно.

Однако, функция обновления состояния может принимать коллбэк в качестве аргумента для вычисления нового состояния на основе текущего. В нашем случае мы можем использовать setCount(actualCount => actualCount + 1):

import { useState } from 'react'function DoubleIncreaser() {  const [count, setCount] = useState(0)  const doubleIncreaseHandler = () => {    setCount(actualCount => actualCount + 1)    setCount(actualCount => actualCount + 1)  }  return (    <>      <button onClick={doubleIncreaseHandler}>        Double Increase      </button>      <div>Count: {count}</div>    </>  )}

При обновлении состояния с помощью такой функции аргумент actualCount будет содержать актуальное значение состояния.

Откройте это демо и нажмите на кнопку. Значение count увеличится до 2, как и ожидается.

Разумеется, мы всегда можем создать промежуточную переменную:

import { useState } from 'react'function DoubleIncreaser() {  const [count, setCount] = useState(0)  const doubleIncrease = () => {    let actualCount = count    actualCount = actualCount + 1    actualCount = actualCount + 1    setCount(actualCount)  }  return (    <>      <button onClick={this.doubleIncrease}>        Double Increase      </button>      <div>Count: {count}</div>    </>  )}

let actualCount = count это промежуточная переменная, которую можно обновлять как угодно. Эта переменная используется для обновления состояния с помощью setCount(actualCount).

2. Состояние иммутабельно (неизменяемо) и доступно только для чтения


Если вы забудете о том, что состояние обновляется при следующем рендеринге, вы можете попытаться прочитать значение сразу после его изменения. К сожалению, у вас ничего не выйдет:

function FetchUsers() {  const [users, setUsers] = useState([])  useEffect(() => {    const startFetching = async () => {      const response = await fetch('/users')      const fetchedUsers = await response.json()      setUsers(fetchedUsers)      console.log(users)        // => []      console.log(fetchedUsers) // => ['John', 'Jane', 'Alice', 'Bob']    }    startFetching()  }, [])  return (    <ul>      {users.map(user => <li>{user}</li>)}    </ul>  )}

Компонент FetchUsers отправляет запрос при монтировании startFetching().

При получении данных setUsers(fetchedUsers) обновляет состояние. Тем не менее, изменения не происходят сразу.

Переменная users иммутабельна и доступна только для чтения. Только хук useState() может присвоить ей новое значение. Напрямую этого делать нельзя:

  function FetchUsers() {    const [users, setUsers] = useState([])    useEffect(() => {      const startFetching = async () => {        const response = await fetch('/users')        const fetchedUsers = await response.json()        users = fetchedUsers       // Неправильно! users доступна только для чтения        users.push(...fetchedUsers) // Неправильно! users иммутабельна        setUsers(fetchedUsers)     // Правильно!      }      startFetching()    }, [])    return (      <ul>        {users.map(user => <li>{user}</li>)}      </ul>    )  }

3. Обновление состояния в классовом компоненте


Асинхронное обновление состояния характерно и для классовых компонентов.

Рассмотрим пример:

import { Component } from 'react';class DoubleIncreaser extends Component {  state = {    count: 0  };  render() {    return (      <>        <button onClick={this.doubleIncrease}>          Double Increase        </button>        <div>Count: {this.state.count}</div>      </>    );  }  doubleIncrease = () => {    // Работает!    this.setState(({ count }) => ({      count: count + 1    }));    this.setState(({ count }) => ({      count: count + 1    }));    // Не работает!    // this.setState({ count: this.state.count + 1 });    // this.setState({ count: this.state.count + 1 });  }}

Обратите внимание на обработчик doubleIncrease(): для обновления состояния в нем используется функция обратного вызова.

Откройте это демо и нажмите на кнопку. Значение this.state увеличится до 2.

В классовых компонентах this.state также не обновляется моментально. При вызове this.setState(newState) React откладывает обновление this.state до следующего рендеринга.

Таким образом, this.setState(newState) обновляет this.state асинхронно.

4. Заключение


Хук useState() и this.setState() (внутри классового компонента) обновляют значение переменной и состояние компонента асинхронно.

Запомните простое правило: вызов сеттера setState(newValue) хука useState() (или this.setState()) обновляет состояние не сразу, а при очередном рендеринге компонента.

Вы заметили, что React теперь достаточно импортировать только один раз (в index.js)? В компонентах этого делать больше не нужно.

Благодарю за внимание и хорошего дня.
Подробнее..

Делаем схему выбора мест в кинозале на React о canvas, красивом дизайне и оптимизации

25.12.2020 18:10:30 | Автор: admin

В богатой экосистеме Тинькофф есть лайфстайл-сервисы. Купить билеты на различные мероприятия - в кино, театры, на концерты, спортивные события можно на https://www.tinkoff.ru/entertainment/, а также в мобильном приложении.

Меня зовут Вадим и я расскажу вам, как мы это делали в команде Развлечений в Тинькофф Банке.


Что нужно, чтобы купить билет в кино?

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

дизайн схемы выбора местдизайн схемы выбора мест

Мы придумали три варианта реализации такой схемы.

1. Сделать все на старом добром HTML

Схема на HTML. Найдено на просторах ИнтернетаСхема на HTML. Найдено на просторах Интернета

Плюсы:

  • Удобно стилизовать.

  • Удобно работать в React.

  • Все доступно (A11Y).

Минусы:

  • Растет количество DOM-нод и глубина DOM-дерева (пример на изображении выше).

  • Проблемы с производительностью при взаимодействии с пользователем (перемещение схемы).

2. Использовать SVG

Плюсы и минусы примерно такие же, как и с HTML.

Получилось найти только схему метро на SVGПолучилось найти только схему метро на SVG

3. Canvas

Стильно. Ярко. Производительно.Стильно. Ярко. Производительно.

Плюсы:

  • Удобно стилизовать (можно нарисовать что угодно).

  • Меньше проблем с производительностью.

Минусы:

  • Не получится совместить с Server Side Rendering.

  • Проблемы с A11Y (нет из коробки).

Мы решили делать схему на canvas, потому что нам важно, чтобы все было красиво и с приятным UX для пользователя. Также с технической стороны у нас пропадают проблемы с глубиной DOM-дерева и количеством нод в нем. Тем более что canvas без проблем работает даже в Internet Explorer 11.

Конечно, на наше решение повлияло и то, что использовать canvas намного интереснее, чем просто работать с SVG- и HTML-решениями.

Экосистема вокруг canvas

Итак, мы отправились выбирать библиотеку для более удобной работы с canvas. Как оказалось, их существует достаточно большое количество, из самых популярных Konva, PixiJS, Fabric.js и Phaser.

Из этого многообразия мы выбрали PixiJS. Ключевые особенности Pixi: он быстрый, гибкий и производительный. Также наши коллеги активно рекомендовали использовать именно его.

Простой код на PixiJS. Мы инстанцируем Pixi.Appс заданным конфигом (например, ширину, высоту, цвет фона, разрешение). Добавляем объекты на сцену (Stage в терминологии Pixi), пишем простой цикл и получаем сетку 5 5 из кроликов, которые вращаются вокруг своей оси пример с официального сайта Pixi

Структура и читаемость

Этот код достаточно простой для понимания, но и делает он не так уж много. Если мы хотим создать что-то наподобие схемы выбора мест, код становится достаточно объемным и плохо воспринимается.

Выше не слишком крупная программа, но мы уже дошли до 100 строк кода и, просто глядя на этот код, тяжело понять, что же происходит.

Вписываем в React

Кроме сложности понимания кода возникает другой вопрос: как это вписать в парадигму React?

Сначала мы решили сделать свои обертки для разных примитивов. Но впоследствии обнаружили, что за нас все уже придумали.

Одно из решений, которое понравилось нам, библиотека react-pixi-fiber. Ее плюс в том, что мы пишем привычный нам JSX, а под капотом происходит взаимодействие с Pixi и мы получаем наш canvas.

В этой библиотеке у нас уже есть обертки для всех нативных объектов Pixi. К примеру, вместо инстанцирования класса Pixi.Textмы используем react-элемент <Text />.

Также есть удобное АПИ для создания своих объектов CustomPIXIComponent

Приблизительно так теперь выглядит код для нашей схемы выбора мест. Здесь уже нет никаких инстансов Pixi, у нас обычный JSX: компоненты Stage, Container, посадочные места, привычный маппинг данных на react-компоненты.

А вот как выглядит создание своего компонента. Он немного отличается от привычных react-компонентов, но, если разобраться, по сути тут все то же самое. У нас есть ссылка на отображаемый компонент graphics и привычное слово props. Также почти привычным образом мы можем использовать обработчики событий, например ховер, клик и так далее.

Применяем все на практике

Какие у нас были вводные для отрисовки кресел?

У нас была информация в виде массива объектов. В каждом данные, необходимые для отрисовки сиденья: размеры, координаты, номер места и ряда.

Наша задача сделать так, чтобы в любых сочетаниях кресла смотрелись красиво.

В зависимости от ценовой категории кресло может иметь различный цвет. А также кресла могут быть разных размеров: например, это сдвоенные места диванчики.

Вариант с загрузкой кресла как простой текстуры мы сразу отбросили: были проблемы с отображением на retina-экранах и в целом с изменением размеров без визуальной деформации. А с SVG в то время были проблемы у PixiJS: некорректно работала подгрузка ассетов в SVG.

Поэтому мы решили сами рисовать каждое кресло.

Рисуем кресло на PixiJS

Для удобства мы разделили кресло на сектора:

A полукруглые края подлокотников.
B подлокотник.
C кривая от подлокотников до спинки кресла.
D спинка кресла.
E верхняя часть кресла.
F средняя часть кресла.
G нижняя часть кресла.

Ширина одной клетки width / 22.
Высота одной клетки height / 16.
Кресло в макете у нас имеет размер 22 пикселя на 16, таким образом, каждая черточка или буковка это пиксель в сетке.

Затем мы разделили эту сетку на зоны: подлокотники, спинка и так далее. И отрисовали все по частям, используя PixiJS и CustomPIXIComponent.

Теперь все по порядку и каждому разработчику, который приходит в этот код, сразу понятно, где, как и что.

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

Схемы секторов

Когда вы покупаете билет на крупное мероприятие, например на хоккей или на концерт в Олимпийском, скорее всего, сначала вы выбираете сектор, в котором хотите сидеть, а потом уже места в этом секторе. С появлением задач по концертам нам тоже нужно было это реализовать.

От наших партнеров приходила такая схема секторов.
Собственно массив секторов в поле sectors с информацией о каждом секторе, название площадки, а также строка hallScheme, которая занимает почти 236 килобайт.

Как оказалось, это схема секторов площадки в SVG и закодирована в base64.

Что же нам с этим делать?

Первым нашим решением было парсить этот SVG и как-то перевести на PixiJS.

Второй вариант просто вставить это как HTML, повесить обработчики через стандартные методы.

Рассмотрев эти варианты и взвесив плюсы и минусы, мы решили пойти дальше и сделать третий вариант парсить эту SVG и превращать ее в react-элементы.

Выбором для парсера стал html-react-parser. Эта библиотека парсит любой валидный HTML в react-элементы. Работает как на стороне Node.js, так и на стороне браузера. Но решающим стало то, что любой элемент из оригинальной разметки можно заменить на что угодно.

Передаем всю разметку схемы в функцию parseHtmlToReact а также через опции задаем функцию, которая будет заменять элементы на наши.

Здесь уже опять привычный нам JSX и полный контроль над всем, что нужно: обработчики событий, стилизация и так далее.

Вот так теперь выглядит ВТБ Арена.Вот так теперь выглядит ВТБ Арена.

Поговорим об оптимизации

Во время работы над схемами мы заметили, что даже для маленьких схем загрузка процессора держится на 20%, а в больших достигает 8090%. Визуально это незаметно для пользователя, но может привести к проблемам на слабых мобильных устройствах и быстрому разряду батареи.

Используя инструменты разработчика, мы видим, что даже при простое приблизительно каждые 16 мс вызывается одна и та же таска. Сразу видно некий Ticker_tick

В замечательной документации по Pixi можно найти упоминание об этом тикере. Как понятно из описания, это некий цикл, который выполняет что-то за некий интервал времени, в нашем случае приблизительно каждые 16 мс.

Но почему именно 16 миллисекунд?

Вспомним понятие 60 кадров в секунду - это нужно, чтобы обеспечить плавность анимации и перемещений. Также, новые фильмы снимают в 60 кадров в секунду, что дает более плавную картинку.

Чтобы получить такую частоту обновления, нужно каждую секунду обновлять изображение 60 раз: 1000 мс 60 = 16,6666 мс.

Как раз этот цикл из класса Pixi.Tickerобеспечивает обновление 60 раз в секунду всего canvas, и у нас все плавно и красиво. В нашем случае при большом количестве объектов перерисовка выходит достаточно дорогой. При этом чаще всего схема абсолютно статичная, а плавность нужна только при взаимодействии.

Так как мы не работаем напрямую с Pixi, получить доступ к регулированию цикла обновления мы не могли.

Исходный код компонента Stage из react-pixi-fiberИсходный код компонента Stage из react-pixi-fiber

Как видно, вся работа с Pixi происходит внутри компонента Stage от react-pixi-library. К сожалению, официальных способов от создателей react-pixi-library по работе с Ticker нет.

В нашем случае выходом стало применение опции sharedTickerдля Pixi. По сути, эта опция включает использование всех инстансов pixi-приложений общего Ticker. Общий Ticker доступен простым импортом из пакета.

Мы сразу отключили автоматический старт цикл обновлений Ticker с инициализацией приложения. А дальше мы связали это с ререндером react-компонента, так как при взаимодействии пользователя со схемой меняются props данного компонента.

Соответственно, цикл обновления запускается, только когда нужно. В остальных случаях canvas статичен, выглядит как картинка и не нагружает ресурсы.

Пока мы все это изучали, обнаружили, что у Pixi на Github есть целая wiki, где очень много интересной информации:

Забавно, что на оффициальном сайте Pixi ссылку на эту wiki не найти.

Главный совет по оптимизации заключается в том, что инстансы объектов Pixi.Graphics стоят дорого и не кэшируются, в отличие от текстур, спрайтов и так далее. А наши кресла, как сложные объекты, как раз и являются инстансами Pixi.Graphics.

Выводы

Какие выводы из этого всего можно сделать?

  1. Чем меньше оберток тем более гибко мы можем оптимизировать приложение.

  2. Работа с canvas отличается от обычных рутинных задач.

  3. Pixi заточен под более интерактивные вещи, например игры.

  4. При разработке желательно сразу иметь большой объем данных. Например, в нашем случае надо было сразу получить схему какого-нибудь огромного концертного зала вместо зала кинотеатра.

Подробнее..

Изучаем React 300 вопросов для подготовки к собеседованию

21.01.2021 08:19:12 | Автор: admin


Доброго времени суток, друзья!

Представляю вашему вниманию первую версию репозитория с большим количеством вопросов (с ответами, разумеется) по React и связанным с ним технологиям. А много это сколько? Это 322.

Почему первая версия? Потому что будет еще и вторая, примерно с таким же количеством вопросом, но с более развернутыми ответами. Когда? Надеюсь, что скоро. Зачем нужны две версии? Ну, во-первых, повторенье etc во-вторых, одни приветствуют лаконичность, другие жаждут подробностей.

Текущая версия является адаптированным, исправленным и дополненным переводом этого репозитория.

Вопросник включает в себя следующие разделы:

  • Ключевые концепции React
  • React Router
  • Интернационализация в React
  • Тестирование в React
  • React Redux
  • React Native
  • Библиотеки для React
  • Разное (много разного, включая хуки)


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

Планируется, что со временем репозиторий превратится в хороший русскоязычный ресурс по изучению React и наиболее популярных инструментов, предназначенных для работы с ним (решил сделать 2021 год годом React и TypeScript). В ближайшее время в нем появятся краткие руководства по Create React App, стилям в React, включая Styled Components, и React Router. Постепенно ресурс обретет формат прогрессивного веб и, возможно, мобильного приложения.

Буду рад любой форме обратной связи. Если заметили какую-то опечатку, неточность или, не дай бог, ошибку, не стесняйтесь писать в личку и делать пуллы.

Благодарю за внимание и хорошего дня.
Подробнее..

Перевод Почему Context это не инструмент управления состоянием

26.01.2021 16:05:52 | Автор: admin


TL;DR


Context и Redux это одно и тоже?

Нет. Это разные инструменты, делающие разные вещи и используемые в разных целях.

Является ли контекст инструментом управления состоянием?

Нет. Контекст это форма внедрения зависимостей (dependency injection). Это транспортный механизм, который ничем не управляет. Любое управление состоянием осуществляется вручную, как правило, с помощью хуков useState()/useReducer().

Являются ли Context и useReducer() заменой Redux?

Нет. Они в чем-то похожи и частично пересекаются, но сильно отличаются в плане возможностей.

Когда следует использовать контекст?

Когда вы хотите сделать некоторые данные доступными для нескольких компонентов, но не хотите передавать эти данные в виде пропов на каждом уровне дерева компонентов.

Когда следует использовать Context и useReducer()?

Когда вам требуется управление состоянием умеренно сложного компонента в определенной части приложения.

Когда следует использовать Redux?

Redux наиболее полезен в следующих случаях:

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


Понимание Context и Redux


Для правильного использования инструмента критически важно понимать:

  • Для чего он предназначен
  • Какие задачи он решает
  • Когда и зачем он был создан

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

Неразбериха вокруг Context и Redux связана, в первую очередь, с непониманием того, для чего данные инструменты предназначены, и какие задачи они решают. Поэтому, прежде чем говорить о том, когда их следует использовать, необходимо определить, что они из себя представляют и какие проблемы решают.

Что такое контекст?


Начнем с определения контекста из официальной документации:

Контекст позволяет передавать данные через дерево компонентов без необходимости передавать пропы на промежуточных уровнях.

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

Обратите внимание, в данном определении ни слова не говорится об управлении, только о передаче и распределении.

Текущее API контекста (React.createContext()) впервые было представлено в React 16.3 в качестве замены устаревшего API, доступного в ранних версиях React, но имеющего несколько недостатков дизайна. Одной из главных проблемой являлось то, что обновления значений, переданных через контекст, могли быть заблокированы, если компонент пропускал рендеринг через shouldComponentUpdate(). Поскольку многие компоненты прибегали к shouldComponentUpdate() в целях оптимизации, передача данных через контекст становилась бесполезной. createContext() был спроектирован для решения этой проблемы, поэтому любое обновление значения отразится на дочерних компонентах, даже если промежуточный компонент пропускает рендеринг.

Использование контекста

Использование контекста в приложении предполагает следующее:

  • Вызываем const MyContext = React.createContext() для создания экземпляра объекта контекста
  • В родительском компоненте рендерим &ltMyContext.Provider value={someValue}>. Это помещает некоторые данные в контекст. Эти данные могут быть чем угодно: строкой, числом, объектом, массивом, экземпляром класса, обработчиком событий и т.д.
  • Получаем значение контекста в любом компоненте внутри провайдера, вызывая const theContextValue = useContext(MyContext)

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

Обычно, значением контекста является состояние компонента:

import { createContext } from 'react'export const MyContext = createContext()export function ParentComponent({ children }) {  const [counter, setCounter] = useState(0)  return (    <MyContext.Provider value={[counter, setCounter]}>      {children}    </MyContext.Provider>  )}

После этого дочерний компонент может вызвать хук useContext() и прочитать значение контекста:

import { useContext } from 'react'import { MyContext } from './MyContext'export function NestedChildComponent() {  const [counter, setCounter] = useContext(MyContext)  // ...}

Цель и случаи использования контекста

Мы видим, что контекст, в действительности, ничем не управляет. Вместо этого, он представляет собой своего рода тоннель (pipe). Вы помещаете данные в начало (наверх) тоннеля с помощью <MyContext.Provider>, затем эти данные опускаются вниз до тех пор, пока компонет не запросит их с помощью useContext(MyContext).

Таким образом, основная цель контекста состоит в предотвращении бурения пропов (prop-drilling). Вместо передачи данных в виде пропов на каждом уровне дерева компонентов, любой компонент, вложенный в <MyContext.Provider>, может получить к ним доступ посредством useContext(MyContext). Это избавляет от необходимости писать код, реализующий логику передачи пропов.

Концептуально, это является формой внедрения зависимостей. Мы знаем, что потомок нуждается в данных определенного типа, но он не пытается создавать или устанавливать эти данные самостоятельно. Вместо этого, он полагается на то, что некоторый предок передаст эти данные во время выполнения (runtime).

Что такое Redux?


Вот о чем гласит определение из Основ Redux:

Redux это паттерн проектирования и библиотека для управления и обновления состояния приложения, использующая события, именуемые операциями (действиями, actions). Redux выступает в роли централизованного хранилища состояния приложения, следюущего правилам, позволяющим обеспечить предсказуемое обновление состояние.

Redux позволяет управлять глобальным состоянием состоянием, которое трубется нескольким частям приложения.

Паттерны и инструменты, предоставляемые Redux, позволяют легче определять где, когда, почему и как было обновлено состояние, и как отреагировало приложение на это изменение.

Обратите внимание, что данное описание указывает на:

  • Управление состоянием
  • Цель Redux определение того, почему и как произошло изменение состояния

Изначально Redux являлся реализацией архитектуры Flux, паттерна проектирования, разработанного Facebook в 2014 году, через год после появления React. После появления Flux, сообщество разработало множество библиотек, по-разному реализующих данную концепцию. Redux появился в 2015 году и быстро стал победителем в этом соревновании благодаря продуманному дизайну, решению наиболее распространенных проблем и отличной совместимости с React.

Архитектурно, Redux подчеркнуто использует принципы функционального программирования, что позволяет писать код в форме предсказуемых функций-редукторов (reducers), и обособлять идею какое событие произошло от логики, определяющей как обновляется состояние при возгникновении данного события. В Redux также используется промежуточное программное обеспечение (middleware) как способ расширения возможностей хранилища, включая обработку побочных эффектов (side effects).

Redux также предоставляет инструменты разработчика, позволяющие изучать историю операций и изменение состояния в течение времени.

Redux и React

Сам по себе Redux не зависит от UI вы можете использовать его с любым слоем представления (view layer) (React, Vue, Angular, ванильный JS и т.д.) либо без UI вообще.

Однако, чаще всего, Redux используется совместно с React. Библиотека React Redux это официальный связывающий слой UI, позволяющий React-компонентам взаимодействовать с хранилищем Redux, получая значения из состояния Redux и инициализируя выполнение операций. React-Redux использует контекст в своих внутренних механизмах. Тем не менее, следует отметить, что React-Redux передает через контекст экземпляр хранилища Redux, а не текущее значение состояния! Это пример использования контекста для внедрения зависимостей. Мы знаем, что наши подключенные к Redux компоненты нуждаются во взаимодействии с хранилищем Redux, но мы не знаем или нам неважно, что это за хранилище, когда мы определяем компонент. Настоящее хранилище Redux внедряется в дерево во время выполнения с помощью компонента <Provider>, предоставляемого React-Redux.

Следовательно, React-Redux также может быть использован для предотвращения бурения (по причине внутреннего использования контекста). Вместо явной передачи нового значения через <MyContext.Provider>, мы можем поместить эти данные в хранилище Redux и затем получить их в нужном компоненте.

Цель и случаи использования (React-)Redux

Основное назначение Redux согласно официальной документации:

Паттерны и инструменты, предоставляемые Redux, облегчают понимание того, когда, где, почему и как произошло изменение состояния, а также того, как на это отреагировало приложение.

Существует еще несколько причин использования Redux. Одной из таких причин является предотвращение бурения.

Другие случаи использования:

  • Полное разделение логики управления состоянием и слоя UI
  • Распределение логики управления состоянием между разными слоями UI (например, в процессе перевода приложения с AngularJS на React)
  • Использование возможностей Redux middleware для добавления дополнительной логики при инициализации операций
  • Возможность сохранения частей состояния Redux
  • Возможность получения отчетов об ошибках, которые могут быть воспроизведены другими разработчиками
  • Возможность быстрой отладки логики и UI во время разработки

Дэн Абрамов перечислил эти случаи в статье 2016 года Почему вам может быть не нужен Redux.

Почему контекст не является инструментом управления состоянием?


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

David Khourshid, автор библиотеки XState и специалист по управлению состоянием, в одном из своих твитов отметил, что:

Управление состоянием это изменение состояния в течение времени.

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

  • Сохранение начального значения
  • Получение текущего значения
  • Обновление значения

Также, как правило, существует способ получения уведомлений об изменении текущего значения состояния.

React-хуки useState() и useReducer() являются отличными примерами управления состоянием. С помощью этих хуков мы можем:

  • Сохранять начальное значение путем вызова хука
  • Получать текущее значение также посредством вызова хука
  • Обновлять значение, вызывая функцию setState() или dispatch(), соответственно
  • Узнавать об обновлении состояния благодаря повторному рендерингу компонента

Redux и MobX также позволяют управлять состоянием:

  • Redux сохраняет начальное значение путем вызова корневого редуктора (root reducer), позволяет читать текущее значение с помощью store.getState(), обновлять значение с помощью store.dispatch(action) и получать уведомления об обновлении состояния через store.subscribe(listener)
  • MobX сохраняет начальное значение путем присвоения значения полю класса хранилища, позволяет читать текущее значение и обновлять его через поля хранилища и получать учведомления об обновлении состояния с помощью методов autorun() и computed()

К инструментам управления состоянием можно причислить даже инструменты для работы с кэшем сервера, такие как React-Query, SWR, Apollo и Urql они сохраняют начальное значение на основе полученных (fetched) данных, возвращают текущее значение с помощью хуков, позволяют обновлять значения посредством серверных мутаций и уведомляют об изменениях с помощью повторного рендеринга компонента.

React Context не соответствует названным критериям. Поэтому он не является инструментом управления состоянием

Как было отмечено ранее, контекст сам по себе ничего не хранит. За передачу значения, которое, обычно, зависит от состояния компонента, в контекст отвечает родительский компонент, который рендерит <MyContext.Provider>. Настоящее управление состоянием происходит при использовании хуков useState()/useReducer().

David Khourshid также отмечает:

Контекст это то, как существующее состояние распределяется между компонентами. Контекст ничего не делает с состоянием.

И в более позднем твите:

Полагаю, контекст это как скрытые пропы, абстрагирующие состояние.

Все, что делает контекст, это позволяет избежать бурения.

Сравнение Context и Redux


Сравним возможности контекста и React+Redux:

  • Context
    • Ничего не хранит и ничем не управляет
    • Работает только в компонентах React
    • Передает ниже простое (единственное) значение, которое может быть чем угодно (примитивом, объектом, классом и т.д.)
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Показывает текущее значение для компонентов Provider и Consumer в инструментах разработчика, но не показывает историю изменений этого значения
    • Обновляет потребляющие компоненты при изменении значения, но не позволяет пропустить обновление
    • Не предоставляет механизма для обработки побочных эффектов отвечает только за рендеринг

  • React+Redux
    • Хранит и управляет простым значением (обычно, этим значением является объект)
    • Работает с любым UI, а также за пределами React-компонентов
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Может обновлять значение путем инициализации операций и запуска редукторов
    • Инструменты разработчика показывают историю инициализации операций и изменения состояния
    • Предоставляет возможность использования middleware для обработки побочных эффектов
    • Позволяет компонентам подписываться на обновления хранилища, извлекать определенные части состояния хранилища и контролировать повторный рендеринг компонентов


Очевидно, что это совершенно разные инструменты с разными возможностями. Единственной точкой пересечения между ними является предотвращение бурения.

Context и useReducer()


Одной из проблем в дискуссии Context против Redux является то, что люди, зачастую, на самом деле имеют ввиду следующее: Я использую useReducer() для управления состоянием и контекст для передачи значения. Но, вместо этого, они просто говорят: Я использую контекст. В этом, на мой взгляд, кроется основная причина неразберихи, способствующая поддержанию мифа о том, что контекст управляет состоянием.

Рассмотрим комбинацию Context + useReducer(). Да, такая комбинация выглядит очень похоже на Redux + React-Redux. Обе эти комбинации имеют:

  • Сохраненное значение
  • Функцию-редуктор
  • Возможность инициализации операций
  • Возможность передачи значения и его чтения во вложенных компонентах

Тем не менее, между ними по-прежнему существуют некоторые важные отличия, проявляющиеся в их возможностях и поведении. Я отметил эти отличия в статьях Поведение React, Redux и Context и "(Почти) полное руководство по рендерингу в React". Суммируя, можно отметить следующее:

  • Context + useReducer() основан на передаче текущего значения через контекст. React-Redux передает через контекст текущий экземпляр хранилища Redux
  • Это означает, что когда useReducer() производит новое значение, все компоненты, подписанные на контекст, принудительно перерисовываются, даже если они используют только часть данных. Это может привести к проблемам с производительностью в зависимости от размера значения состояния, количества подписанных компонентов и частоты повторного рендеринга. При использовании React-Redux компоненты могут подписываться на определенную часть значения хранилища и перерисовываться только при изменении этой части

Существуют и другие важные отличия:

  • Контекст + useReducer() являются встроенными возможностями React и не могут использоваться за его пределами. Хранилище Redux не зависит от UI, поэтому может использоваться отдельно от React
  • React DevTools показывают текущее значение контекста, но не историю его изменений. Redux DevTools показывают все инициализированные операции, их содержимое (тип и полезную нагрузку, type and payload), состояние после каждой операции и разницу между состояниями
  • useReducer() не имеет middleware. Некоторые побочные эффекты можно обработать с помощью хука useEffect() в сочетании с useReducer(), я даже встречал отдельные попытки оборачивания useReducer() в нечто похожее на middleware, однако всему этому далеко до функционала и возможностей Redux middleware

Вот, что сказал Sebastian Markbage (архитектор команды ядра React) об использовании контекста:

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

В сети существует много статей, рекомендующих настройку нескольких отдельных контекстов для разных частей состояния, что позволяет избежать ненужных повторных рендерингов и решить проблемы, связанные с областями видимости. Некоторые из постов также предлагают добавлять собственные компоненты по выбору контекста, что требует использования сочетания React.memo(), useMemo() и аккуратного разделения кода на два контекста для каждой части приложения (одна для данных, другая для функций обновления). Безусловно, код можно писать и так, но в этом случае вы заново изобретаете React-Redux.

Таким образом, несмотря на то, что Context + useReducer() это легкая альтернатива Redux + React-Redux в первом приближении эти комбинации не идентичны, контекст + useReducer() не может полностью заменить Redux!

Выбор правильного инструмента


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

Обзор случаев использования


  • Context
    • Передача данных вложенным компонентам без бурения

  • useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора

  • Context + useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора и передача состояния вложенным компонентам без бурения

  • Redux
    • Управление очень сложным состоянием с помощью функций-редукторов
    • Прослеживаемость того, когда, почему и как менялось состояние в течение времени
    • Желание полной изоляции логики управления состоянием от слоя UI
    • Распределение логики управления состоянием между разными слоями UI
    • Использование возможностей middleware для реализации дополнительной логики при инициализации операций
    • Возможность сохранения определенных частей состояния
    • Возможность получения воспроизводимых отчетов об ошибках
    • Возможность быстрой отладки логики и UI в процессе разработки

  • Redux + React-Redux
    • Все случаи использования Redux + возможность взаимодействия React-компонентов с хранилищем Redux


Еще раз: названные инструменты решают разные задачи!

Рекомендации


Как же решить, что следует использовать?

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

  • Если вам требуется просто избежать бурения, используйте контекст
  • Если у вас имеется сложное состояние, но вы не хотите использовать сторонние библиотеки, используйте контекст + useReducer()
  • Если вам требуется хорошая трассировка изменений состояния во времени, управлемый повторный рендеринг определенных компонентов, более мощные возможности обработки побочных эффектов и т.п., используйте Redux + React-Redux

Я считаю, что если в вашем приложении имеется 2-3 контекста для управления состоянием, то вам следует переключиться на Redux.

Часто можно услышать, что использование Redux предполагает написание большого количества шаблонного кода, однако, современный Redux значительно облегчает изучение данного инструмента и его использование. Официальный пакет Redux Toolkit решает проблему шаблонизации, а хуки React-Redux упрощают использование Redux в компонентах React.

Разумеется, добавление RTK и React-Redux в качестве зависимостей увеличивает бандл приложения по сравнению с контекстом + useReducer(), которые являются встроенными. Но преимущества такого подхода перекрывают недостатки лучшая трассировка состояния, простая и более предсказуемая логика, улучшенная оптимизация рендринга компонентов.

Также важно отметить, что одно не исключает другого вы можете использовать Redux, Context и useReducer() вместе. Мы рекомендуем хранить глобальное состояние в Redux, а локальное в компонентах и внимательно подходить к определению того, какая часть приложения должна храниться в Redux, а какая в компонентах. Так что вы можете использовать Redux для хранения глобального состояния, Context + useReducer() для хранения локального состояния, и Context для статических значений, одновременно и в одном приложении.

Еще раз: я не утверждаю, что все состояние приложения должно храниться в Redux или что Redux это всегда лучшее решение. Я утверждаю лишь, что Redux хороший выбор, существует много причин использовать Redux, и плата за его использование не так высока, как многие думают.

Наконец, контекст и Redux не единственные в своем роде. Существует множество других инструментов, решающих иные аспекты управления состоянием. MobX популярное решение, использующее ООП и наблюдаемые объекты (observables) для автоматического обновления зависимостей. Среди других подходов к обновлению состояния можно назвать Jotai, Recoil и Zustand. Библиотеки для работы с данными, вроде React Query, SWR, Apollo и Urql, предоставляют абстракции, упрощающие применение распространенных паттернов для работы с состоянием, кэшируемым сервером (скоро похожая библиотека (RTK Query) появится и для Redux Toolkit).

Надеюсь, данная статья помогла вам понять разницу между контекстом и Redux, а также какой инструмент и в каких случаях следует использовать. Благодарю за внимание.
Подробнее..

React за 60 секунд валидация формы

02.02.2021 08:07:36 | Автор: admin


Доброго времени суток, друзья!

В этом небольшом туториале я хочу продемонстировать вам пример клиент-серверной валидации формы.

Клиент будет реализован на React, сервер на Express.

Мы не будем изобретать велосипеды, а воспользуемся готовыми решениями: для валидации формы на стороне клиента будет использоваться react-hook-form (+: используются хуки, русский язык), а на стороне сервера express-validator.

Для стилизации будет использоваться styled-components (CSS-in-JS или All-in-JS, учитывая JSX).

Исходный код примера находится здесь.

Поиграть с кодом можно здесь.

Без дальнейших предисловий.

Клиент


Создаем проект с помощью create-react-app:

yarn create react-app form-validation# илиnpm init react-app form-validation# илиnpx create-react-app form-validation

В дальнейшем для установки зависимостей и выполнения команд я буду использовать yarn.

Структура проекта после удаления лишних файлов:

public  index.htmlsrc  App.js  index.js  styles.jsserver.js...

Устанавливаем зависимости:

# для клиентаyarn add styled-components react-hook-form# для сервера (производственные зависимости)yarn add express express-validator cors# для сервера (зависимость для разработки)yarn add -D nodemon# для одновременного запуска серверовyarn add concurrently

Поскольку styled-components не умеет импотировать шрифты, нам придется добавить их в public/index.html:

<head>  ...  <link rel="preconnect" href="http://personeltest.ru/aways/fonts.gstatic.com" />  <link    href="http://personeltest.ru/aways/fonts.googleapis.com/css2?family=Comfortaa&display=swap"    rel="stylesheet"  /></head>

Наша форма будет состоять из трех полей: имя пользователя, его адрес электронной почты и пароль. Условия, которым должны удовлетворять данные:

  • Имя
    • от 2 до 10 символов
    • кириллица

  • Email
    • особых требований не предъявляется

  • Пароль
    • 8-12 символов
    • латиница: буквы в любом регистре, цифры, нижнее подчеркивание и дефис


Начнем со стилизации (src/styles.js; для подстветки синтаксиса я использую расширение для VSCode vscode-styled-components):

// импорт инструментовimport styled, { createGlobalStyle } from 'styled-components'// глобальные стилиconst GlobalStyle = createGlobalStyle`  body {    margin: 0;    min-height: 100vh;    display: grid;    place-items: center;    background-color: #1c1c1c;    font-family: 'Comfortaa', cursive;    font-size: 14px;    letter-spacing: 1px;    color: #f0f0f0;  }`// заголовокconst StyledTitle = styled.h1`  margin: 1em;  color: orange;`// формаconst StyledForm = styled.form`  margin: 0 auto;  width: 320px;  font-size: 1.2em;  text-align: center;`// подписьconst Label = styled.label`  margin: 0.5em;  display: grid;  grid-template-columns: 1fr 2fr;  align-items: center;  text-align: left;`// проект поля для ввода данныхconst BaseInput = styled.input`  padding: 0.5em 0.75em;  font-family: inherit;  font-size: 0.9em;  letter-spacing: 1px;  outline: none;  border: none;  border-radius: 4px;`// обычное полеconst RegularInput = styled(BaseInput)`  background-color: #f0f0f0;  box-shadow: inset 0 0 2px orange;  &:focus {    background-color: #1c1c1c;    color: #f0f0f0;    box-shadow: inset 0 0 4px yellow;  }`// поле для отправки данных на серверconst SubmitInput = styled(BaseInput)`  margin: 1em 0.5em;  background-image: linear-gradient(yellow, orange);  cursor: pointer;  &:active {    box-shadow: inset 0 1px 3px #1c1c1c;  }`// проект сообщения с текстомconst BaseText = styled.p`  font-size: 1.1em;  text-align: center;  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);`// сообщение об ошибкеconst ErrorText = styled(BaseText)`  font-size: ${(props) => (props.small ? '0.8em' : '1.1em')};  color: red;`// сообщение об успехеconst SuccessText = styled(BaseText)`  color: green;`// экспорт стилизованных компонентовexport {  GlobalStyle,  StyledTitle,  StyledForm,  Label,  RegularInput,  SubmitInput,  ErrorText,  SuccessText}

Импортируем и подключаем глобальные стили в src/index.js:

import React from 'react'import ReactDOM from 'react-dom'// импортируем глобальные стилиimport { GlobalStyle } from './styles'import App from './App'ReactDOM.render(  <React.StrictMode>    {/* подключаем глобальные стили */}    <GlobalStyle />    <App />  </React.StrictMode>,  document.getElementById('root'))

Переходим к основному файлу клиента (src/App.js):

import { useState } from 'react'// импорт хука для валидации формыimport { useForm } from 'react-hook-form'// импорт стилизованных компонентовimport {  StyledTitle,  StyledForm,  Label,  RegularInput,  SubmitInput,  ErrorText,  SuccessText} from './styles'// компонент заголовкаfunction Title() {  return <StyledTitle>Валидация формы</StyledTitle>}// компонент формыfunction Form() {  // инициализируем начальное состояние  const [result, setResult] = useState({    message: '',    success: false  })  // извлекаем средства валидации:  // регистрация проверяемого поля  // ошибки и обработка отправки формы  const { register, errors, handleSubmit } = useForm()  // общие валидаторы  const validators = {    required: 'Не может быть пустым'  }  // функция отправки формы  async function onSubmit(values) {    console.log(values)    const response = await fetch('http://localhost:5000/server', {      method: 'POST',      headers: {        'Content-Type': 'application/json'      },      body: JSON.stringify(values)    })    const result = await response.json()    // обновляем состояние    setResult({      message: result,      success: response.ok    })  }  // нажатие кнопки сброса полей в исходное состояние приводит к перезагрузке страницы  function onClick() {    window.location.reload()  }  return (    <>      <StyledForm onSubmit={handleSubmit(onSubmit)}>        <Label>          Имя:          <RegularInput            type='text'            name='name'            // поля являются неуправляемыми            // это повышает производительность            ref={register({              ...validators,              minLength: {                value: 2,                message: 'Не менее двух букв'              },              maxLength: {                value: 10,                message: 'Не более десяти букв'              },              pattern: {                value: /[А-ЯЁ]{2,10}/i,                message: 'Только киррилица'              }            })}            defaultValue='Иван'          />        </Label>        {/* ошибки */}        <ErrorText small>{errors.name && errors.name.message}</ErrorText>        <Label>          Email:          <RegularInput            type='email'            name='email'            ref={register({              ...validators,              pattern: {                value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,                message: 'Неправильный адрес электронной почты'              }            })}            defaultValue='email@example.com'          />        </Label>        <ErrorText small>{errors.email && errors.email.message}</ErrorText>        <Label>          Пароль:          <RegularInput            type='password'            name='password'            ref={register({              ...validators,              pattern: {                value: /^[A-Z0-9_-]{8,12}$/i,                message:                  'От 8 до 12 символов: латиница, цифры, нижнее подчеркивание и дефис'              }            })}            defaultValue='password'          />        </Label>        <ErrorText small>          {errors.password && errors.password.message}        </ErrorText>        <SubmitInput type='submit' defaultValue='Отправить' />        {/* обратите внимание на атрибут "as", он позволяет превратить "инпут" в кнопку с аналогичными стилями */}        <SubmitInput as='button' onClick={onClick}>          Сбросить        </SubmitInput>      </StyledForm>      {/* результат отправки формы */}      {result.success ? (        <SuccessText>{result.message}</SuccessText>      ) : (        <ErrorText>{result.message}</ErrorText>      )}    </>  )}export default function App() {  return (    <>      <Title />      <Form />    </>  )}

Метод register() хука useForm() поддерживает все атрибуты тега input. Полный список таких атрибутов. В случае с именем, мы могли бы ограничиться регулярным выражением.

Запускаем сервер для клиента с помощью yarn start и тестируем форму:



Замечательно. Валидация на стороне клиента работает, как ожидается. Но ее всегда можно отключить. Поэтому нужна валидация на сервере.

Сервер


Приступаем к реализации сервера (server.js):

const express = require('express')// body читает тело запроса// validationResult - результат валидацииconst { body, validationResult } = require('express-validator')const cors = require('cors')const app = express()const PORT = process.env.PORT || 5000app.use(cors())app.use(express.json())app.use(express.urlencoded({ extended: false }))// валидаторыconst validators = [  body('name').trim().notEmpty().isAlpha('ru-RU').escape(),  body('email').normalizeEmail().isEmail(),  // кастомный валидатор  body('password').custom((value) => {    const regex = /^[A-Z0-9_-]{8,12}$/i    if (!regex.test(value)) throw new Error('Пароль не соответствует шаблону')    return true  })]// валидаторы передаются в качестве middlewareapp.post('/server', validators, (req, res) => {  // извлекаем массив с ошибками из результата валидации  const { errors } = validationResult(req)  console.log(errors)  // если массив с ошибками не является пустым  if (errors.length) {    res.status(400).json('Регистрация провалилась')  } else {    res.status(201).json('Регистрация прошла успешно')  }})app.listen(PORT, () => {  console.log(`Сервер готов. Порт: ${PORT}`)})

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

Добавим в package.json парочку скриптов server для запуска сервера и dev для одновременного запуска серверов:

"scripts": {  "start": "react-scripts start",  "build": "react-scripts build",  "server": "nodemon server",  "dev": "concurrently \"yarn server\" \"yarn start\""}

Выполняем yarn dev и тестируем отправку формы:





Прекрасно. Кажется, у нас все получилось.

Мы с вами рассмотрели очень простой вариант клиент-серверной валидации формы. Вместе с тем, более сложные варианты предполагают лишь увеличение количества валидаторов, общие принципы остаются такими же. Также стоит отметить, что валидацию формы на стороне клиента вполне можно реализовать средствами HTML (GitHub, CodeSandbox).

Благодарю за внимание и хорошего дня.
Подробнее..

CreateRef, setRef, useRef и зачем нужен current в ref

05.02.2021 10:09:34 | Автор: admin

Привет хабр!

В этой статье попробуем разобрать большинство непонятных базовых принципов при взаимодействии с ref. Например чем детально отличается createRef от useRef, зачем в этих объектах отдельное свойство current и многое другое. Одним словом попытаемся открыть много черных ящиков по работе с ref, из-за которых у вас возможно накопились вопросы. (Данная статья, является расшифровкой видео)

Вспоминаем setRef

Начнем с примера. Допустим у нас есть небольшой класс, где нам нужно работать с ref. В классовом компоненте, моим любимым способом работы с ref - является передача именно функции в атрибут ref:

class App extends Component {  setRef = (ref) => {    this.ref = ref;  };  componentDidMount() {    console.log(this.ref); // div  }  render() {    return <div ref={this.setRef}>test</div>;  }}

Функция setRef первым параметром получит ноду этого элемента и у вас есть возможность сохранить ее в this. Я называю такой метод в своих проектах setRef, т.к. он визуально мне напоминает классический setter.

Почему такой подход мне нравится, в нем почти отсутствует магия. Вы контролируете почти весь код, разве что кроме момента передачи функции в атрибут ref и кто-то ее там вызывает, но и это выглядит вполне себе привычно, когда мы хотим получить значение после асинхронной операции с помощью callback.

function someFunction(callback) {  doSomething()    .then((data) => callback(data));}

Вопросы к createRef

А теперь, давайте сравним с альтернативным подходом. Будем использовать createRef для создания инстанса ref и хранить все там же в this.

class App extends Component {  constructor(props) {    super(props);    this.ref = createRef();  }  componentDidMount() {    console.log(this.ref.current); // div  }  render() {    return <div ref={this.ref}>test</div>;  }}

Этот код, на мое мнение, немного сложнее предыдущего. Для начала, createRef() сохраняет, что-то неизвестное в this.ref. После добавления console.log, мы видим там объект с одним свойством current равным null.

this.ref = createRef();console.log(this.ref); // { current: null }

Далее мы этот объект { current: null } засовываем в атрибут ref и уже в componentDidMount имеем доступ к ноде.

Тут у меня возникает сразу несколько вопросов:

  • Зачем нам вообще свойство current?

  • И если оно есть, почему тогда в ref мы не передаем this.ref.current?

  • А вообще гарантировано ли существование свойства current?

Да и вообще подход передачи объекта в ref, чтобы его там мутировали, не очень популярен в React, особенно если вы используете redux в своем проекте, где мутирование не приветствуется.

Изучаем исходники createRef

Чтобы во всем этом разобраться, я предлагаю изучить исходники реакта. Начнем с метода createRef()(ссылка).

export function createRef(): RefObject {  const refObject = {    current: null,  };  if (__DEV__) {    Object.seal(refObject);  }  return refObject;}

Здесь мы видим, что создается объект с одним свойством current равным null, и он же возвращается. Крайне простой метод.

Таким образом, мы можем даже заменить метод createRef на просто создание объекта со свойством current. И это будет работать точно так же.

class App extends Component {  constructor(props) {    super(props);    this.ref = { current: null, count: 2 } // createRef();  }  componentDidMount() {    console.log(this.ref.current); // div    console.log(this.ref.count); // 2  }  render() {    return <div ref={this.ref}>test</div>;  }}

Более того для эксперимента я добавил в этот объект и другое свойство count и оно не исчезло после прокидывания в ref.

Изучаем исходники работы атрибута ref

Чтобы разобраться что происходит внутри атрибута ref, мы опять обратимся к исходникам. Я какое-то время подебажил, чтобы найти место где происходит работа с присваиванием ref. И это место - функция commitAttachRef. Она находится в пакете react-reconciler в файле ReactFiberCommitWork.new.js.

function commitAttachRef(finishedWork: Fiber) {  const ref = finishedWork.ref; // получаем то что мы передали в атрибут ref  if (ref !== null) { // Если в ref ничего не передавали, то и делать дальше нечего    const instanceToUse = finishedWork.stateNode; // достаем саму ноду        // ...        if (typeof ref === 'function') { // проверка на тип переданного нами ref      // ...      ref(instanceToUse);    } else {      // ...      ref.current = instanceToUse;    }  }}

Изучив этот код становится понятно, что полный сценарий работы с ref достаточно примитивный

this.ref = createRef(); // { current: null }<div ref={this.ref}>test</div>if (typeof ref !== 'function') {  ref.current = instanceToUse;}

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

export type RefObject = {|  current: any,|};

И действительно поле current не имеет вопросительного знака, а это значит оно обязательное. Поэтому можете смело использовать поле current без дополнительных проверок.

А что происходит в commitAttachRef при 2-ом рендере?

Код который мы изучили, описывает только первый рендер, давайте разберемся что происходит с ref на второй и последующие рендеры.

Для этого было решено провести еще один эксперимент. В начале метода commitAttachRef я добавил console.log

function commitAttachRef(finishedWork: Fiber) {  console.log('commitAttachRef !!!');  // ...}

А с другой стороны, доработал предыдущий пример с ref, а именно добавил счетчик внутрь div-а. И описал классический метод incerement.

class App extends Component {  constructor(props) {    super(props);        this.ref = createRef();    this.state = { counter: 0 };  }    // ...    increment = () => {    this.setState((prevState) => ({      counter: prevState.counter + 1,    }));  }  render() {    return (      <div ref={this.ref}>        <button onClick={this.increment}>+</button>        <span>{this.state.counter}</span>      </div>    );  }}

В браузере же мы увидим следующую картину:

При первом рендере вызывается console.log(commitAttachRef !!!). И дальше мы нажимаем несколько раз на кнопку увеличения счетчика, но метод commitAttachRef больше не вызывается.

Давайте еще доработаем пример и попробуем вставить значение счетчика в className этого div-а

return (  <div ref={this.ref} className={`class-${this.state.counter}`}>    <button onClick={this.increment}>+</button>    <span>{this.state.counter}</span>  </div>);

И в браузере ничего не изменилось от такой доработки.

Поэтому было решено еще доработать код, а именно, положили кнопку и счетчик рядом с div-ом, который маунтим, только если число четное.

return (  <>    <button onClick={this.increment}>+</button>    <span>{this.state.counter}</span>    {this.state.counter % 2 === 0 <div ref={this.ref}>test</div>}  </>)

Перейдем теперь в браузер. И понажимаем снова на кнопку плюс. И в консоли видим, каждый раз когда число четное, перед did update вызывается commitAttachRef !!!.

В принципе понять это не сложно. Пока мы меняем атрибуты, нода мутирует в виртуальном дереве и обновлять ref реакту смысла нет, т.к. ссылка в виртуальном дереве одна и та же, а когда по какой-либо причине происходит Mount / Unmount, ссылка на ноду обновляется и соответственно нужно перезаписать ref. Таким образом на первый взгляд метод commitAttachRef вызывается только, если нода полностью меняется, но это не единственный случай. Рассмотрим для этого другие ситуации.

А что если использовать createRef вместо useRef?

В предыдущих экспериментах мы разбирали примеры использования createRef() на классах, а что будет если createRef() использовать в функциональных компонентах?

Поэтому я решил переписать предыдущий пример на хуках. Получилась следующая картина:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = createRef(); // useRef();    const increment = () => setCounter(counter => counter + 1);    useEffect(() => {    console.log("[useEffect] counter = ", counter, ref.current);  }, [counter]);    return (    <div ref={ref}>      <button onClick={increment}>+</button>      <span>{counter}</span>    </div>  );}

Вместо привычного useRef() мы подставим createRef(). И в useEffect на каждое изменения counter будем выводить значение counter и ref. Перейдем в браузер.

Мы видим, что абсолютно на каждый рендер вызывается метод commitAttachRef. Хотя ноду, как в предыдущих примерах, мы не меняли. При этом в useEffect нода вполне себе валидная, и указывает на правильный <div>

Конечно же, если мы заменим createRef на useRef, тогда commitAttachRef будет вызываться только один раз. Чтобы понять почему это так работает нужно изучить исходники обоих методов и сравнить

Изучаем исходники useRef

Исходники createRef мы уже смотрели, давайте бегло изучим исходники useRef. Если вы читали мою предыдущую статью Первое погружение в исходники хуков вы знаете, что за одним хуком useRef кроется несколько методов, а именно mountRef, updateRef. Они достаточно примитивные.

Первым рассмотрим mountRef:

function mountRef<T>(initialValue: T): {|current: T|} {  const hook = mountWorkInProgressHook();  if (_DEV_) {    // ...  } else {    const ref = {current: initialValue};    hook.memoizedState = ref;    return ref;  }}  

В mountRef достается инстанса hook. И далее видим проверку на dev окружение. И когда мы проскролим весь этот большой блок для дев окружения, мы увидим, что для прод режима будут выполняться всего 3 строки: создать ref, сохранить его внутри хука и вернуть его.

Метод updateRef еще проще:

function updateRef<T>(initialValue: T): {|current: T|} {  const hook = updateWorkInProgressHook();  return hook.memoizedState;}

Нужно достать тот же хук из метода updateWorkInProgressHook() и вернуть сохраненный в нем ref.

Сравниваем поведение при createRef и useRef

Изучив хук useRef, мы можем наконец-то разобраться, почему useRef лучше подходит для функционального компонента, чем createRef. Перед тем как начнем сравнивать, я хочу ввести кое какие обозначения.

Для того чтобы показать, что создается именно новый объект, я решил ввести обозначение буквами. Если после какого-то действия буква возле объекта остаётся прежней, значит это та же ссылка на объект. Если же буква изменится, значит это полностью новый объект.

А теперь, построим временную сравнительную шкалу для сравнения useRef() и createRef()

Как мы видим, при первом рендере поведение у обоих методов абсолютно одинаковое, а вот второй рендер уже отличается. В случае useRef() в current мы имеем все ту же ноду указывающую на div, да и сам объект содержащий current все тот же "a". В случае createRef() создается как новый объект "c" (в первом рендере был "b") так и свойство current снова равняется null. И как следствие вызывается commitAttachRef и на 2-ом рендере.

Возникает резонный вопрос. А что именно заставляет вызвать commitAttachRef? Это то что ссылка на объект поменялась с "b" на "с" или это, потому что при втором рендере current снова стал null?

Для разгадки этой тайны, проведем 2 мелких эксперимента:

Эксперимент 1. Сохраняем ту же ссылку на объект и обнуляем current

Рассмотрим следующий код:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = useRef();  // ...  ref.current = null;  return (    <div ref={ref}>      <button onClick={increment>+</button>      <span>{counter}</span>    </div>  )}

Суть идеи в том, чтобы при 1-ом, 2-ом и последующих рендерах, в атрибут ref передавать current равный null. И посмотреть, будет ли вызываться commitAttachRef при каждом ренедере.

РЕЗУЛЬТАТ: commitAttachRef вызывается лишь 1 раз, при маунте компонент

Эксперимент 2. Изменяем ссылку на объект и сохраняем current

Рассмотрим следующий код:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = useRef();  const testRef = { current: ref.current };    // ...    useEffect(() => {    ref.current = testRef.current;  });  return (    <div ref={testRef}>      <button onClick={increment}>+</button>      <span>{counter}</span>    </div>  )}

Идея заключается в том, чтобы при каждом рендере менялась ссылка на объект передаваемый в атрибут ref, но при этом приходило валидное значение current.

РЕЗУЛЬТАТ: commitAttachRef вызывается на каждый рендер

Паттерн единого объекта с мутирующим свойством

Из этого можно сделать вывод, что на вызов commitAttachRef влияет именно ссылка на объект, который мы послали в атрибут ref. И сделано это не просто так. Более того можно проследить паттерн, который закладывали React Core разработчики. С одной стороны useRef всегда создает объект лишь единожды, при первой инициализации. И эту ссылку можно использовать например в useEffect, и даже eslint, который заставляет нас дописывать в зависимости, все что мы используем внутри. Абсолютно не требует дописывать ref в зависимости т.к. ссылка всегда одинаковая, хоть current может и меняться, а это значит, мы не получим дополнительных вызовов useEffect, если current изменится, но при желании можем в зависимости и добавить ref.current и получим дополнительные вызовы useEffect (но eslint на такое использование ref.current ругается, т.к. в большинстве случаев, это приведет скорее к багам, чем к осознанной пользе). Получается данный патерн дает нам определенную дополнительную гибкость.

Так же ref удобно использовать и как props. И при изменении current, ваш компонент не будет перерисовываться, если вы этого не хотите. Поэтому конструкция объекта с дополнительным свойством current, это не просто так исторически сложилось, а осознанный паттерн, которым предлагают пользоваться нам React Core разработчики.

Мысли вслух

Идея данной статьи - это чуть лучше разобраться в том, как работает инструмент, которым мы вкалачиваем гвозди каждый в свой проект. Да, после этой статьи маловероятно, что вы начнете писать свой проект как то иначе, но однозначно станете более уверенно писать привычные вам конструкции, т.к. будете осознавать, что именно происходит под капотом. И конечно, я надеюсь вас немного меньше станет раздражать наличие current в ref, т.к. это жертва во имя гибкости.
Если вам нравится, то что я делаю, можете поддержать здесь :)

Подробнее..
Категории: Javascript , React , Reactjs , React.js , React hooks , Useref

Перевод React лучшие практики

08.02.2021 10:22:45 | Автор: admin


Разрабатываете на React или просто интересуетесь данной технологией? Тогда добро пожаловать в мой новый проект Тотальный React.

Введение


Я работаю с React уже 5 лет, однако, когда дело касается структуры приложения или его внешнего вида (дизайна), сложно назвать какие-то универсальные подходы.

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

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

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

Предлагаемые подходы не являются истинной в последней инстанции. Это всего лишь мое мнение. Существует много разных способов решения одной и той же задачи.

Компоненты


Функциональные компоненты

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

До тех пор, пока вам не потребуются предохранители, используйте функциональные компоненты. Ментальная модель, которую надо держать в голове, будет намного проще.

//  Классовые компоненты являются "многословными"class Counter extends React.Component {  state = {    counter: 0,  }  constructor(props) {    super(props)    this.handleClick = this.handleClick.bind(this)  }  handleClick() {    this.setState({ counter: this.state.counter + 1 })  }  render() {    return (      <div>        <p>Значение счетчика: {this.state.counter}</p>        <button onClick={this.handleClick}>Увеличить</button>      </div>    )  }}//  Функциональные компоненты легче читать и поддерживатьfunction Counter() {  const [counter, setCounter] = useState(0)  handleClick = () => setCounter(counter + 1)  return (    <div>      <p>Значение счетчика: {counter}</p>      <button onClick={handleClick}>Увеличить</button>    </div>  )}

Согласованные (последовательные) компоненты

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

У каждого подхода имеются свои преимущества и недостатки.

Неважно, как вы экспортируете компоненты, в самом низу или при определении, просто придерживайтесь одного правила.

Названия компонентов

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

Это также помогает определить, разработкой какого компонента вы в данный момент занимаетесь.

//  Этого следует избегатьexport default () => <form>...</form>//  Именуйте свои функцииexport default function Form() {  return <form>...</form>}

Вспомогательные функции

Функции, которым не требуются данные, хранящиеся в компоненте, должны находиться за пределами (снаружи) компонента. Для этого идеально подходит место перед определением компонента, чтобы код можно было изучать сверху вниз.

Это уменьшает шум компонента в нем остается только самое необходимое.

//  Этого следует избегатьfunction Component({ date }) {  function parseDate(rawDate) {    ...  }  return <div>Сегодня {parseDate(date)}</div>}//  Размещайте вспомогательные функции перед компонентомfunction parseDate(date) {  ...}function Component({ date }) {  return <div>Сегодня {parseDate(date)}</div>}

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

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

//  Вспомогательные функции не должны "читать" значения из состояния компонентаexport default function Component() {  const [value, setValue] = useState('')  function isValid() {    // ...  }  return (    <>      <input        value={value}        onChange={e => setValue(e.target.value)}        onBlur={validateInput}      />      <button        onClick={() => {          if (isValid) {            // ...          }        }}      >        Отправить      </button>    </>  )}//  Поместите их снаружи и передавайте им только необходимые значенияfunction isValid(value) {  // ...}export default function Component() {  const [value, setValue] = useState('')  return (    <>      <input        value={value}        onChange={e => setValue(e.target.value)}        onBlur={validateInput}      />      <button        onClick={() => {          if (isValid(value)) {            // ...          }        }}      >        Отправить      </button>    </>  )}

Статическая (жесткая) разметка

Не создавайте статическую разметку для навигации, фильтров или списков. Вместо этого, создайте объект с настройками и переберите его в цикле.

Это означает, что, при необходимости, вам нужно будет изменить разметку и элементы только в одном месте.

//  Статическую разметку сложно поддерживатьfunction Filters({ onFilterClick }) {  return (    <>      <p>Жанры книг</p>      <ul>        <li>          <div onClick={() => onFilterClick('fiction')}>Научная фантастика</div>        </li>        <li>          <div onClick={() => onFilterClick('classics')}>            Классика          </div>        </li>        <li>          <div onClick={() => onFilterClick('fantasy')}>Фэнтези</div>        </li>        <li>          <div onClick={() => onFilterClick('romance')}>Романы</div>        </li>      </ul>    </>  )}//  Используйте циклы и объекты с настройкамиconst GENRES = [  {    identifier: 'fiction',    name: 'Научная фантастика',  },  {    identifier: 'classics',    name: 'Классика',  },  {    identifier: 'fantasy',    name: 'Фэнтези',  },  {    identifier: 'romance',    name: 'Романы',  },]function Filters({ onFilterClick }) {  return (    <>      <p>Жанры книг</p>      <ul>        {GENRES.map(genre => (          <li>            <div onClick={() => onFilterClick(genre.identifier)}>              {genre.name}            </div>          </li>        ))}      </ul>    </>  )}

Размеры компонентов

Компонент это всего лишь функция, принимающая пропы и возвращающая разметку. Они следуют тем же принципам проектирования.

Если функция выполняет слишком много задач, вынесите часть логики в другую функцию. Тоже самое справедливо в отношении компонентов если в компоненте содержится слишком сложный функционал, разделите его на несколько компонентов.

Если часть разметки является сложной, включает циклы или условия извлеките ее в отдельный компонент.

Полагайтесь на пропы и коллбеки для взаимодействия и получения данных. Количество строк кода далеко не всегда является объективным критерием его качества. Всегда помните об отзывчивости и абстракции.

Комментарии в JSX

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

function Component(props) {  return (    <>      {/* Если пользователь оформил подписку, мы не будем показывать ему рекламу */}      {user.subscribed ? null : <SubscriptionPlans />}    </>  )}

Предохранители

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

В функции, запрашивающей данные, у нас может быть любое количество блоков try/catch. Используйте предохранители не только на верхнем уровне приложения, но оборачивайте им каждый компонент, в котором потенциально может быть выброшено исключение во избежание каскада ошибок.

function Component() {  return (    <Layout>      <ErrorBoundary>        <CardWidget />      </ErrorBoundary>      <ErrorBoundary>        <FiltersWidget />      </ErrorBoundary>      <div>        <ErrorBoundary>          <ProductList />        </ErrorBoundary>      </div>    </Layout>  )}

Деструктуризация пропов

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

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

//  Не повторяйте "props" в каждом компонентеfunction Input(props) {  return <input value={props.value} onChange={props.onChange} />}//  Деструктурируйте и используйте значения в явном видеfunction Component({ value, onChange }) {  const [state, setState] = useState('')  return <div>...</div>}

Количество пропов

Ответ на вопрос о количестве пропов является очень субъективным. Количество пропов, передаваемых в компонент, коррелируется с количеством используемых компонентом переменных. Чем больше пропов передается в компонент, тем выше его ответственность (имеется ввиду количество решаемых компонентом задач).

Большое количество пропов может свидетельствовать о том, что компонент делает слишком много.

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

Обратите внимание: чем больше пропов принимает компонент, чем чаще он перерисовывается.

Передача объекта вместо примитивов

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

//  Не передавайте значения по одному<UserProfile  bio={user.bio}  name={user.name}  email={user.email}  subscription={user.subscription}/>//  Вместо этого, используйте объект<UserProfile user={user} />

Условный рендеринг

В некоторых случаях использование коротких вычислений (оператора логическое И &&) для условного рендеринга может привести к отображению 0 в UI. Во избежание этого используйте тернарный оператор. Единственным недостатком такого подхода является чуть большее количество кода.

Оператор "&&" уменьшает количество кода, что здорово. Тернарник является более многословным, зато всегда работает корректно. Кроме того, добавление альтернативного варианта при необходимости становится менее трудоемким.

//  Старайтесь избегать коротких вычисленийfunction Component() {  const count = 0  return <div>{count && <h1>Сообщения: {count}</h1>}</div>}//  Вместо этого, используйте тернарный операторfunction Component() {  const count = 0  return <div>{count ? <h1>Сообщения: {count}</h1> : null}</div>}

Вложенные тернарные операторы

Тернарные операторы становится трудно читать после первого уровня вложенности. Несмотря на то, что тернарники экономят пространство, лучше явным и очевидным образом выражать свои намерения.

//  Вложенные тернарники сложно читатьisSubscribed ? (  <ArticleRecommendations />) : isRegistered ? (  <SubscribeCallToAction />) : (  <RegisterCallToAction />)//  Извлеките их в отдельный компонентfunction CallToActionWidget({ subscribed, registered }) {  if (subscribed) {    return <ArticleRecommendations />  }  if (registered) {    return <SubscribeCallToAction />  }  return <RegisterCallToAction />}function Component() {  return (    <CallToActionWidget      subscribed={subscribed}      registered={registered}    />  )}

Списки

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

При необходимости перебора элементов, извлеките их в отдельный компонент, даже если разметка небольшая. Родительскому компоненту не нужны детали, ему достаточно знать, что в определенном месте рендерится список.

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

//  Не объединяйте циклы с другой разметкойfunction Component({ topic, page, articles, onNextPage }) {  return (    <div>      <h1>{topic}</h1>      {articles.map(article => (        <div>          <h3>{article.title}</h3>          <p>{article.teaser}</p>          <img src={article.image} />        </div>      ))}      <div>Вы находитесь на странице {page}</div>      <button onClick={onNextPage}>Дальше</button>    </div>  )}//  Извлеките список в отдельный компонентfunction Component({ topic, page, articles, onNextPage }) {  return (    <div>      <h1>{topic}</h1>      <ArticlesList articles={articles} />      <div>Вы находитесь на странице {page}</div>      <button onClick={onNextPage}>Дальше</button>    </div>  )}

Пропы по умолчанию

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

Поэтому более предпочтительным является присвоение дефолтных значений при деструктуризации пропов. Это облегчает чтение кода сверху вниз и позволяет держать в одном месте определения и значения.

//  Не определяйте значения пропов по умолчанию за пределами функцииfunction Component({ title, tags, subscribed }) {  return <div>...</div>}Component.defaultProps = {  title: '',  tags: [],  subscribed: false,}//  Поместите их в список аргументовfunction Component({ title = '', tags = [], subscribed = false }) {  return <div>...</div>}

Вложенные функции рендеринга

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

Это означает, что вложенная функция будет иметь доступ к состоянию и данным внешней функции. Это делает код менее читаемым что делает эта функция (за что она отвечает)?

Перенесите вложенную функцию в отдельный компонент, присвойте ей имя и полагайтесь на пропы вместо замыкания.

//  Не вкладывайте одни компоненты в другиеfunction Component() {  function renderHeader() {    return <header>...</header>  }  return <div>{renderHeader()}</div>}//  Извлекайте их в отдельные компонентыimport Header from '@modules/common/components/Header'function Component() {  return (    <div>      <Header />    </div>  )}

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


Редукторы

Порой нам требуется более мощный способ определения и управления состоянием, чем useState(). Попробуйте использовать useReducer() перед обращением к сторонним библиотекам. Это отличный инструмент для управления сложным состоянием, не требующий использования зависимостей.

В комбинации с контекстом и TypeScript, useReducer() может быть очень мощным. К сожалению, его используют не очень часто. Люди предпочитают применять специальные библиотеки.

Если вам требуется несколько частей состояния, переместите их в редуктор:

//  Не используйте слишком много частей состоянияconst TYPES = {  SMALL: 'small',  MEDIUM: 'medium',  LARGE: 'large'}function Component() {  const [isOpen, setIsOpen] = useState(false)  const [type, setType] = useState(TYPES.LARGE)  const [phone, setPhone] = useState('')  const [email, setEmail] = useState('')  const [error, setError] = useSatte(null)  return (    // ...  )}//  Унифицируйте их с помощью редуктораconst TYPES = {  SMALL: 'small',  MEDIUM: 'medium',  LARGE: 'large'}const initialState = {  isOpen: false,  type: TYPES.LARGE,  phone: '',  email: '',  error: null}const reducer = (state, action) => {  switch (action.type) {    ...    default:      return state  }}function Component() {  const [state, dispatch] = useReducer(reducer, initialState)  return (    // ...  )}

Хуки против HOC и рендер-пропов

В некоторых случаях нам требуется усилить компонент или предоставить ему доступ к внешним данным. Существует три способа это сделать компоненты высшего порядка (HOC), рендеринг через пропы и хуки.

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

HOC получают значения в виде пропов. Не всегда очевидно, откуда приходят значения, из родительского компонента или из его обертки. Кроме того, цепочка из нескольких пропов является хорошо известным источником ошибок.

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

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

//  Не используйте рендер-пропыfunction Component() {  return (    <>      <Header />        <Form>          {({ values, setValue }) => (            <input              value={values.name}              onChange={e => setValue('name', e.target.value)}            />            <input              value={values.password}              onChange={e => setValue('password', e.target.value)}            />          )}        </Form>      <Footer />    </>  )}//  Используйте хукиfunction Component() {  const [values, setValue] = useForm()  return (    <>      <Header />        <input          value={values.name}          onChange={e => setValue('name', e.target.value)}        />        <input          value={values.password}          onChange={e => setValue('password', e.target.value)}        />      <Footer />    </>  )}

Библиотеки для получения данных

Очень часто данные для состояния приходят из API. Нам необходимо сохранять их в памяти, обновлять и получать в нескольких местах.

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

Работать с внешними данными еще легче, если вы используете GraphQL-клиент наподобие Apollo. Он реализует концепцию состояния клиента из коробки.

Библиотеки для управления состоянием

В подавляющем большинстве случаев нам для управления состоянием приложения не нужны никакие библиотеки. Они требуются лишь в очень больших приложениях с очень сложным состоянием. В таких ситуациях я использую одно из двух решений Recoil или Redux.

Ментальные модели компонентов


Контейнер и представитель

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

Суть в том, что некоторые компоненты не содержат состояния и функционала. Они просто вызываются родительским компонентом с некоторыми пропами. Компонент-контейнер, в свою очередь, содержит некую бизнес-логику, отправляет запросы на получение данных и управляет состоянием.

Данная ментальная модель по факту описывает паттерн проектирования MVC для серверных приложений. Там она прекрасно работает.

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

Компоненты с состоянием и без

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

Данные должны находиться максимально близко к компоненту, в котором они используются. При использовании клиента GrapQL мы получаем данные в компоненте, который эти данные отображает. Даже если это не компонент верхнего уровня. Не думайте о контейнерах, думайте об ответственности компонента. Определяйте наиболее подходящий компонент для хранения части состояния.

Например, компонент <Form/> должен содержать данные формы. Компонент <Input/> должен получать значения и вызывать коллбеки. Компонент <Button/> должен уведомлять форму о желании пользователя отправить данные на обработку и т.д.

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

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

Структура приложения


Группировка по маршруту/модулю

Группировка по контейнерам и компонентам делает приложение сложным в изучении. Определения того, к какой части приложения относится конкретный компонент, предполагает близкое знакомство со всей кодовой базой.

Не все компоненты одинаковые некоторые испольузются глобально, другие созданы для решения специфических задач. Такая структура подходит для небольших проектов. Однако, для средних и больших проектов такая структура неприемлима.

//  Не группируйте компоненты по деталям технической реализации containers|    Dashboard.jsx|    Details.jsx components|    Table.jsx|    Form.jsx|    Button.jsx|    Input.jsx|    Sidebar.jsx|    ItemCard.jsx//  Группируйте их по модулю/домену modules|    common|   |    components|   |   |    Button.jsx|   |   |    Input.jsx|    dashboard|   |    components|   |   |    Table.jsx|   |   |    Sidebar.jsx|    details|   |    components|   |   |    Form.jsx|   |   |    ItemCard.jsx

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

Основанная на модулях архитектура легко масштабируется. Вы просто добавляете новые модули, при этом не увеличивая сложность системы.

Контейнерная архитектура не является неправильной, но она не очень общая (абстрактная). Она не скажет тому, кто ее изучает ничего, кроме того, что для разработки приложения используется React.

Общие модули

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

Так вы будете видеть, какие общие компоненты используются в вашем приложении, даже без помощи Storybook. Это позволяет избежать дублирования кода. Вы же не хотите, чтобы каждый член вашей команды разрабатывал собственный вариант кнопки? К сожалению, это часто происходит из-за плохой архитектуры приложения.

Абсолютные пути

Отдельные части приложения должны меняться настолько легко, насколько это возможно. Это относится не только к коду компонента, но и к месту его расположения. Абсолютные пути означают, что вам не придется ничего менять при перемещении импортируемого компонента в другое место. Кроме того, это облегчает определение расположения компонента.

//  Не используйте относительные путиimport Input from '../../../modules/common/components/Input'//  Абсолютный путь никогда не изменитсяimport Input from '@modules/common/components/Input'

Я использую префикс "@" в качестве индикатора внутреннего модуля, но я также видел примеры использования символа "~".

Оборачивание внешних компонентов

Старайтесь не импортировать слишком много сторонних компонентов напрямую. Создавая адаптер для таких компонентов мы можем при необходимости модифицировать их API. Также мы можем менять используемые библиотеки в одном месте.

Это относится как к библиотекам компонентов, таким как Semantic UI, так и к утилитам. Простейший способ заключается в повторном экспорте таких компонентов из общего модуля.

Компоненту не нужно знать, какую конкретно библиотеку мы используем.

//  Не импортируйте зависимости напрямуюimport { Button } from 'semantic-ui-react'import DatePicker from 'react-datepicker'//  Повторно экспортируйте их из внутреннего модуляimport { Button, DatePicker } from '@modules/common/components'

Один компонент одна директория

Я создаю директорию для компонентов для каждого модуля в моих приложениях. Сначала я создаю компонент. Затем, если возникает необходимость в дополнительных файлах, относящихся к компоненту, таких как стили или тесты, я создаю директорию для компонента и размещаю все файлы в ней.

Хорошей практикой является создание файла index.js для повторного экспорта компонента. Это позволяет не изменять пути импорта и избежать дублирования названия компонента import Form from 'components/UserForm/UserForm'. Однако, не следует помещать код компонента в файл index.js, поскольку это сделает невозможным поиск компонента по названию вкладки в редакторе кода.

//  Не размещайте все файлы в одном месте components     Header.jsx     Header.scss     Header.test.jsx     Footer.jsx     Footer.scss     Footer.test.jsx//  Помещайте их в собственные директории components     Header         index.js         Header.jsx         Header.scss         Header.test.jsx     Footer         index.js         Footer.jsx         Footer.scss         Footer.test.jsx

Производительность


Преждевременная оптимизация

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

Разумеется, думать о таких вещах, как оптимизация, нужно, но предпочтение следует отдавать разработке читаемых и поддерживаемых компонентов. Хорошо написанный код легче улучшать.

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

После определения проблем, устраните их в порядке влияния на производительность.

Размер сборки

Количество JavaScript, отправляемое браузеру, это ключевой фактор производительности приложения. Само приложение может быть очень быстрым, но об этом никто не узнает, если для его запуска придется предварительно загружать 4 Мб JavaScript.

Не стремитесь к одному бандлу. Разделяйте приложение на уровне маршрутов и даже больше. Убедитесь, что отправляете браузеру минимальное количество кода.

Осуществляйте подгрузку в фоновом режиме или при выражении пользователем намерения получить другую часть приложения. Если нажатие кнопки запускает загрузку PDF-файла, вы можете начать загрузку соответствующей библиотеки в момент наведения курсора на кнопку.

Повторный рендеринг коллбеки, массивы и объекты

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

Не передавайте коллбеки в виде пропов. При таком подходе функция каждый раз создается заново, запуская повторный рендеринг.

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

Явная передача массивов или объектов относится к той же категории проблем. Они сравниваются по ссылкам, так что не проходят поверхностную проверку и запускают повторный рендеринг. Если вам необходимо передать статичный массив, создайте его в качестве константы перед определением компонента. Это позволит каждый раз передавать один и тот же экземпляр.

Тестирование


Тестирование при помощи снимков

Однажды, я столкнулся с интересной проблемой при проведении snapshot-тестирования: сравнение new Date() без аргумента с текущей датой всегда возвращало false.

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

Важно понимать, что снимки не заменяют тестов на уровне компонента. Лично я этим видом тестирования больше не пользуюсь.

Тестирование корректного рендеринга

Основная задача тестирования заключается в подтверждении того, что компонент работает, как ожидается. Убедитесь в том, что компонент возвращает правильную разметку как с дефолтными, так и с переданными пропами.

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

Тестирование состояния и событий

Компонент с состоянием, обычно, меняется в ответ на событие. Создайте имитацию события и проверьте, что компонент правильно на него реагирует.

Убедитесь, что вызываются обработчики и передаются верные аргументы. Проверьте правильную установку внутреннего состояния.

Тестирование пограничных случаев

После покрытия кода базовыми тестами, добавьте несколько тестов для проверки особых случаев.

Это может означать передачу пустого массива для того, чтобы убедиться в том, что доступ к индексу не осуществляется без проверки. Это также может означать вызов ошибки в компоненте (например, в запросе к API) для проверки ее правильной обработки.

Интеграционное тестирование

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

Отдельные компоненты могут успешно проходить юнит-тесты, но при этом взаимодействие между ними может вызывать проблемы.

Стилизация


CSS-в-JS

Это очень спорный вопрос. Лично я предпочитаю использовать библиотеки вроде Styled Components или Emotion, позволяющие писать стили в JavaScript. Одним файлом меньше. Не надо думать о таких вещах, как, например, названия классов.

Структурной единицей React является компонент, так что техника CSS-в-JS или, точнее, все-в-JS, на мой взгляд, является наиболее предпочтительной.

Обратите внимание: другие подходы к стилизации (SCSS, CSS-модули, библиотеки со стилями типа Tailwind) не являются неправильными, но я все же рекомендую использовать CSS-в-JS.

Стилизованные компоненты

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

Тем не менее, когда стилизованных компонентов очень много, имеет смысл вынести их в отдельный файл. Я видел использование такого подхода в некоторых открытых проектах вроде Spectrum.

Получение данных


Библиотеки для работы с данными

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

Использование такого подхода означает, что отслеживание статуса загрузки и обработка HTTP-ошибок возлагается на нас. Это приводит к многословности и большому количеству шаблоного кода.

Вместо этого, лучше использовать такие библиотеки как React Query и SWR. Они делают взаимодействие с сервером органической частью жизненного цикла компонента идиоматическим способом с помощью хуков.

Они имеют встроенную поддержку кэширования, управления состоянием загрузки и обработки ошибок. Также они избавляют от необходимости использования библиотек для управления состоянием для обработки этих данных.

Благодарю за внимание и хорошего начала рабочей недели.
Подробнее..

5 подходов к стилизации React-компонентов на примере одного приложения

16.02.2021 12:20:24 | Автор: admin


Доброго времени суток, друзья!

Сегодня я хочу поговорить с вами о стилизации в React.

Почему данный вопрос является актуальным? Почему в React существуют разные подходы к работе со стилями?

Когда дело касается разметки (HTML), то React предоставляет в наше распоряжение JSX (JavaScript и XML). JSX позволяет писать разметку в JS-файлах данную технику можно назвать HTML-в-JS.

Однако, когда речь идет о стилях, то React не предоставляет каких-либо специальных инструментов (JSC?). Поэтому каждый разработчик волен выбирать такие инструменты по своему вкусу.

Всего можно выделить 5 подходов к стилизации React-компонентов:

  • Глобальные стили все стили содержатся в одном файле (например, index.css)
  • Нативные CSS-модули для каждого компонента создается отдельный файл со стилями (например, в директории css); затем эти файлы импортируются в главный CSS-файл (тот же index.css) с помощью директивы "@import"
  • Реактивные CSS-модули (данная техника используется не только в React-проектах; реактивными я назвал их потому, что библиотека css-modules в настоящее время интегрирована в React, т.е. не требует отдельной установки, по крайней мере, при использовании create-react-app) для каждого компонента создается файл Component.module.css, где Component название соответствующего компонента (обычно, такой файл размещается рядом с компонентом); затем стили импортируются в JS-файл в виде объекта, свойства которого соответствуют селекторам класса (например: import styles from './Button.module.css'; <button style={styles.button}>Нажми на меня</button>)
  • Встроенные (инлайновые) стили элементы стилизуются с помощью атрибутов style со значениями в виде объектов со стилями (например, <button style={{ borderRadius: '6px'; } }>Нажми на меня</button>)
  • CSS-в-JS библиотеки, позволяющие писать CSS в JS-файлах; одной из таких библиотек является styled-components: import styled from 'styled-components'; const Button = styled`какой-то css`; <Button>Нажми на меня</Button>

На мой взгляд, лучшим решением является последний подход, т.е. CSS-в-JS. Он выглядит самым логичным с точки зрения описания структуры (разметки), внешнего вида (стилей) и логики (скрипта) компонента в одном файле получаем нечто вроде Все-в-JS.

Шпаргалку по использованию библиотеки styled-components можно найти здесь. Возможно, вам также интересно будет взглянуть на шпаргалку по хукам.

Ну, а худшим подходом, по моему мнению, являются встроенные стили. Стоит, однако, отметить, что определение объектов со стилями перед определением компонента и последующее использование этих объектов напоминает CSS-в-JS, но остаются camelCase-стиль, атрибуты style и сами встроенные стили, которые затрудняют инспектирование DOM.

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

Исходный код GitHub.

Песочница:


Выглядит приложение так:



Приложение состоит из трех компонентов: Title заголовок, Counter значение счетчика и информация о том, каким является число: положительным или отрицательным, четным или нечетным, Control панель управления, позволяющая увеличивать, уменьшать и сбрасывать значение счетчика.

Структура проекта следующая:

|--public  |--index.html|--src  |--components    |--Control      |--Control.js      |--Control.module.css      |--package.json      |--styles.js    |--Counter      |--Counter.js      |--Control.module.css      |--package.json      |--styles.js    |--Title      |--Title.js      |--Title.module.css      |--package.json    |--index.js  |--css    |--control.css    |--counter.css    |--title.css  |--App.js  |--global.css  |--index.js  |--nativeModules.css  |--reactModules.css...

Пройдемся по некоторым файлам, находящимся в директории src:

  • index.js входная точка JavaScript (в терминологии бандлеров), где импортируются глобальные стили и рендерится компонент App
  • App.js основной компонент, где импортируются и объединяются компоненты Control, Counter и Title
  • global.css глобальные стили, т.е. стили всех компонентов в одном файле
  • nativeModules.css файл, где импортируются и объединяются нативные CSS-модули из директории css (control.css, counter.css и title.css)
  • reactModules.css глобальные стили для реактивных CSS-модулей
  • components/Control/Control.js три реализации компонента Control (с глобальными стилями/нативными CSS-модулями, c реактивными CSS-модулями и стилизованными компонентами), а также пример объекта со встроенными стилями
  • components/Control/Control.module.css реактивный CSS-модуль для компонента Control
  • components/Control/styles.js стилизованные компоненты для компонента Control (когда стилизованных компонентов много, я предпочитаю выносить их в отдельный файл)
  • components/Control/package.json файл с main: "./Control", облегчающий импорт компонента (вместо import Control from './Control/Control' можно использовать import Control from './Control'
  • components/index.js повторный экспорт, позволяющий разом импортировать все компоненты в App.js

Как всегда, буду рад любой форме обратной связи.

Благодарю за внимание и хорошего дня.
Подробнее..

Перевод 20 основных частей любого крупномасштабного React-приложения

05.03.2021 18:07:04 | Автор: admin

За эти годы мне посчастливилось работать над некоторыми крупномасштабными проектами, написанными на React. Сегодня я соберу важные вещи, которые нужно учитывать при создании нового проекта или расширении функциональности любого крупномасштабного приложения. Мы начнём с самых основ, а затем пойдём глубже. Так что пристегнитесь!


1. Структура проекта

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

Найдите время, чтобы подобрать лучшую структуру проекта.

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

Моя структура проектаМоя структура проекта

Вот моя структура проекта. Вы можете выбирать и настраивать структуру по своему усмотрению.

2. Глобальное хранилище

Наличие глобального хранилища очень важно для любого крупномасштабного приложения ReactJS. Хотя есть много вариантов, Redux по-прежнему является очень хорошим и безопасным вариантом для больших проектов:

Экосистема Redux также достаточно богата, чтобы покрыть большинство кейсов использования. Вот некоторые вспомогательные библиотеки redux:

redux-persist: для локального сохранения данных;

redux-thunk: для асинхронных операций;

reselect: библиотека селекторов для оптимизации доступа к хранилищу;

react-redux: интеграция с React.

С появлением redux-toolkit он стал намного менее многословным и чистым.

3. Маршрутизация

React не предоставляет официальной библиотеки для маршрутизации на стороне клиента. Но react-router-dom безусловно, лучший выбор для большинства проектов:

Кроме того, есть некоторые вспомогательные библиотеки, которые хорошо сочетаются с экосистемой react-router;

history: отслеживание истории посещённых страниц;

connected-react-router: помогает связать ваш маршрут с состоянием redux.

4. Несколько сред в проекте

В любом большом проекте будет несколько сред, и вы как разработчик должны знать, как с этим работать. Может быть несколько сред, например:

  • development;

  • staging;

  • production.

Для этого вы должны поддерживать отдельные файлы среды. Чтобы достичь этой цели, вы можете добавить файлы .env .env.development, .env.staging и т. д.

Вы можете узнать больше об .env в react по этой ссылке:

Работа с несколькими средами в React простое и элегантное решение

5. Обработка форм

Формы являются неотъемлемой частью практически любого веб-приложения, и их обработка вручную в чистом виде может оказаться сложной задачей.

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

Они позаботятся о большей части шаблонной логики вашего компонента и предоставят валидацию и другие интересные функции.

6. Стилизация

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

Если вы хотите что-то более современное, вам может понравиться styled-components . На самом деле, сейчас это моя любимая библиотека. Это помогает мне использовать стили как независимые компоненты и избавиться от свойства className:

7. UI-библиотеки

Если вы проектируете все свои компоненты вручную, в этом нет необходимости. Скорее всего, вам следует с умом выбрать библиотеку компонентов:

Это некоторые из вариантов, на которые вам следует обратить внимание.

Кроме того, для некоторых конкретных случаев использования вы можете применять другие библиотеки, такие как react-loader-spinner или react-spinner для анимации загрузки. Рассмотрите react-table для таблиц она может оказаться мощной в вашем случае.

8. Запросы HTTP

Извлечение данных с удалённого сервера является одной из наиболее распространённых задач для динамических приложений на react. Для стандартных CRUD операций отлично подойдёт Axios:

Если вам нужно что-то более мощное, вы можете использовать response-query с кешированием из коробки:

9. Документация

Для больших проектов очень важна документация. Существует много библиотек для документирования, но, по моему мнению, простой и лучший вариант react-styleguidist:

Вы можете узнать о ней больше здесь:

Правильно документируйте свои приложения React. Пошаговое введение Guidemedium.com

10. Локализация

Для крупномасштабных международных проектов часто требуется добавить локализацию. Лучше сделать это в начале проекта.

Лучше всего работать с библиотеками react-i18next и i18next. Вы можете узнать больше об этом здесь:

Реализовать многоязычную поддержку в ReactIn. 6 простых шагов. Medium.com

11. Библиотека анимации

Анимация помогает сделать ваше приложение более отзывчивым и интересным в использовании. Должное количество анимации может иметь большое значение. Но не переусердствуйте! Вы можете создавать свои собственные анимации или использовать мощные библиотеки, такие как react-awesome-detect, react-spring или react-transition-group.

Спасибо Alex Chan, я узнал о другой замечательной библиотеке под названием Framer Motion из его комментария. Это круто!

12. EsLint и Prettier

Заставить всех разработчиков следовать одному стилю кода в больших проектах может быть непросто. Можно воспользоваться помощью двух потрясающих библиотек eslint и prettier:

  • EsLint работает как линтер и средство проверки статического типа для вашего проекта.

  • Prettier помогает добиться единообразного стиля кода.

Вы можете узнать о них больше здесь:

Как добавить линтинг и форматирование для вашего приложения React. Сделайте это правильно, иначе в вашем коде будут проблемы. medium.com

14. Typescript

Настройка TypeScript может значительно повысить вашу продуктивность и продуктивность вашей команды.

Чтобы привыкнуть к нему, может потребоваться некоторое время, но для крупных проектов это отличное вложение. Он может сэкономить много времени в будущем.

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

15. Аналитика

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

  • react-ga: официальная реализация Google Analytics для React.

Как настроить и добавить Google Analytics в ваше приложение React. Google упростил получение информации о вашем веб-приложении. Medium.com

16. Тестирование

Для приложения очень важно иметь определённое тестовое покрытие. Среда тестирования должна быть правильно настроена. Вы получаете её автоматически с помощью приложения create-react-app. Наиболее востребованные мной библиотеки:

  • react-testing-library: для тестирования react-компонентов;

  • jest: для юнит-тестирования javascript;

  • cypress: e2e-тестирование.

Вот вводное руководство для вас:

Всё, что вам нужно для начала тестирования в React. Лёгкое введение для начинающих betterprogramming.pub

17. Redux Dev Tools

Вам следует использовать redux-devtools, чтобы получить максимальную отдачу от любого проекта, основанного на react-redux. Эти инструменты помогут вам быстрее отладить приложение и значительно улучшить взаимодействие с разработчиками.

Если вы используете redux-toolkit, он автоматически настроится для вас.

18. Утилиты

В любом проекте нам нужно делать некоторые общие операции, и в этом нам нужна помощь внешних библиотек. Некоторые самые распространённые из них:

  • lodash: манипуляция данными;

  • date-fns: обработка дат.

19. Docker

Это не очень важная часть, но нужно хорошо знать, как докеризовать ваше react-приложение. Из докеризации вы можете извлечь реальную выгоду.

Докер предлагает мобильность и эффективность. Так что подумайте о том, чтобы установить его в ваш проект. Вы можете прочитать следующую статью, чтобы получить представление о том, как эффективно докеризовать react-приложение.

Как я уменьшил размер образа Docker с 1,43 ГБ до 22,4 МБ за 5 простых шагов

20. Непрерывная интеграция

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

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

Заключение

Пожалуйста, дайте мне знать, если я что-то пропустил. Спасибо, что прочитали этот длинный пост.

Узнайте подробности, как получить Level Up по навыкам и зарплате или востребованную профессию с нуля, пройдя онлайн-курсы SkillFactory со скидкой 40% и промокодомHABR, который даст еще +10% скидки на обучение.

Другие профессии и курсы
Подробнее..

Структура React REST API приложения TypeScript Styled-Components

09.03.2021 16:05:49 | Автор: admin

Доброго %время_суток, хабровчане!

Какие-либо статьи я еще не писал, опыта в этом нет от слова совсем. Поэтому, пожалуйста, не судите строго и прошу заранее прощения, если где-то совершу ошибки.

Это для меня совершенно новый опыт, мне никогда еще не доводилось делиться своим опытом и наработками с сообществом.

Предисловие

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

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

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

(!) Прошу вас, на время прочтения этой статьи, держать в голове мысль, что то, что тут предлагаю - это всего-лишь одна из идей того, как можно организовать структуру своего приложения. Не стоит воспринимать мои слова, как догму (это относится к тем, кто только начал свое знакомство с React), ведь я и сам новичок.

Буду очень рад, если вы оставите предложения по улучшению данной структуры, как и вашей конструктивной критике.

Components

Начну, пожалуй, с компонентов.

Компоненты, в данной структуре, разделяются на:

  • Умные (Smart)

  • Обычные (Ordinary)

  • Простые (Simple)

  • UI (UI, как ни странно)

  • Контейнеры (Containers)

  • Страницы (Pages)

Первые четыре группы (Smart, Ordinary, Simple и UI) хранятся в папке Components.

Поговорим немного о них:

  • UI компоненты - это те компоненты, которые заменяют нативные (стандартные) компоненты по типу: button, input, textarea, select и так далее.

    • Данные компоненты не могут использовать свое локальное хранилище и обращаться к глобальному.

  • Simple компоненты - это те компоненты, которые являются простыми, иначе говоря компоненты, в которых нет какой-либо логики, которые просто что-то рендерят.

    • Не могут использовать локальное хранилище и обращаться к глобальному.

    • Не могут использовать хуки, кроме тех, что изначально поставляются с React (за исключением useState).

    • Могут использовать в своей реализации UI компоненты.

  • Ordinary компоненты - это те компоненты, которые могут иметь какую-то логику, для отображения чего-либо.

    • Не могу использовать локальное хранилище, как и обращаться к глобальному.

    • Не могут использовать хуки, кроме тех, что изначально поставляются с React (за исключением useState).

    • Могут использовать в своей реализации Simple и UI компоненты.

  • Smart компоненты - это те компоненты, которые могут использовать относительно серьезную логику, для отображения чего-либо

    • Могут использовать локальное хранилище, как и обращаться к глобальному (не изменяя его)

    • Могут использовать все доступные хуки, кроме тех, что взаимодействуют с сетью

    • Могут использовать в своей реализации Ordinary, Simple и UI компоненты.

Структура папки Componets:

. src/     components/        ordinary        simple        smart        ui     ...

Оставшиеся две группы (Containers и Pages) имеют отдельные папки в корне приложения (папка src).

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

  • Pages - это те компоненты, которые формируются благодаря контейнерам и компонентам из папки Components, если в этом есть необходимость. Могут, как и контейнеры, взаимодействовать с сервисами.

Структура корневой папки:

. src/     components/        ordinary        simple        smart        ui     containers     pages     ...

Сами компоненты должны иметь отдельные папки, есть 2 (это число не является константой) файла:

  • index.tsx - файл, в котором находится сам компонент

  • styled.ts - файл, в котором находятся стилизованные компоненты (его спокойно можно заменить на styles.sсss, либо же styles.css, в зависимости от того, чем вы пользуетесь для стилизации своих компонентов)

Пример компонента Align. Хотелось бы сказать, что этот компонент попадает под группу "Simple", так как он является глупым (не имеет нужды в локальном хранилище) и не заменяет никакой нативный, браузерный, UI компонент.

// index.tsximport React, { memo } from "react";import * as S from "./styled"; // Импортируем стилизованные компонентыconst Align = memo(({ children, axis, isAdaptable = false }: Readonly<Props>) => {return (<S.Align $axis={axis} $isAdaptable={isAdaptable}>{children}</S.Align>);});export { Align };export interface Props {axis: S.Axis;children?: React.ReactNode;isAdaptable?: boolean;}
// styled.tsimport styled, { css } from "styled-components";const notAdaptableMixin = css`width: 100%;height: 100%;max-height: 100%;max-width: 100%;`;const adaptableMixin = css<AlignProps>`width: ${(props) => !props.$axis.includes("x") && "100%"};height: ${(props) => !props.$axis.includes("y") && "100%"};min-width: ${(props) => props.$axis.includes("x") && "100%"};min-height: ${(props) => props.$axis.includes("y") && "100%"};`;export const Align = styled.div<AlignProps>`display: flex;flex-grow: 1;justify-content: ${(props) => (props.$axis.includes("x") ? "center" : "start")};align-items: ${(props) => (props.$axis.includes("y") ? "center" : "start")};${(props) => (props.$isAdaptable ? adaptableMixin : notAdaptableMixin)};`;export interface AlignProps {$axis: Axis;$isAdaptable?: boolean;}export type Axis = ("y" | "x")[] | "x" | "y";

Теперь, поговорим о самом сладком...

Core

Данная папка является "ядром" вашего приложения. В ней хранится все, для взаимодействия с сервером, глобальное хранилище, тема вашего приложения и т.д.

Эта папка содержит:

  • Config - в данной папке хранятся конфигурационные файлы приложения (например в ней можно хранить данные, необходимы для взаимодействия с бэкендом)

  • Constants - в данной папке находятся все константы, что используются в приложении (например сообщения об ошибках и предупреждениях)

  • Hooks - в данной папке хранятся все хуки кастомные хуки (хуки, что были сделаны вами).

  • Models - в данной папке хранятся модели, что приходят с бэкенда.

  • Schemes - в данной папке хранятся схемы форм, таблиц и т.д.

  • Services - в данной папке хранятся сами сервисы, благодаря которым и происходит общение с бэкендом.

  • Store - в данной папке хранятся схемы глобального хранилища (если Вы используете MobX), если же вы отдаете предпочтение Redux, то в данной папке могут хранится экшены, редьюсеры и т.д.

  • Theme (для Styled-Components) - в данной папке хранятся темы приложения.

  • Types - в данной папке хранятся вспомогательные типы, а также декларации модулей.

  • Utils - в данной папке хранятся вспомогательные, простые, функции, которые могут использоваться в хуках, либо же компонентах.

  • api.ts - в данном файле находится экземпляр HTTP клиента (например axios), который используют сервисы и который какой-то мутирует данные запросы (для передачи каких-либо заголовков, например).

Примеры содержимого папок
// config/api.config.tsexport const serverURI = "http://localhost:8080";export const routesPrefix = '/api/v1';// config/routes.config.tsimport { routesPrefix } from "./api.config";export const productBrowserRoutes = {getOne: (to: string = ":code") => `/product/${to}`,search: (param: string = ":search") => `/search/${param}`,};export const productAPIRoutes = {getOne: (code: string) => `${routesPrefix}/product/code/${code}`,search: () => `${routesPrefix}/product/search`,};

Довольно-таки удобно хранить все роуты в одном файле. Если вдруг со стороны бэкенда изменятся роуты, то их легко можно будет изменить в одном файле, не проходясь, при этом, по всему проекту.

// constants/message.constants.tsexport const UNKNOWN_ERROR = "Неизвестная ошибка";
// hooks/useAPI.ts// Хук для взаимодействия с сервисами/* eslint-disable react-hooks/exhaustive-deps */import { useCallback, useEffect } from "react";import { useLocalObservable } from "mobx-react-lite";import type { API, Schema, Take } from "@core/types";function useAPI<F extends API.Service.Function<API.Response<any>>,R extends Take.FromServiceFunction.Response<F>,P extends Parameters<F>>(service: F, { isPendingAfterMount = false, isIgnoreHTTPErrors = false }: Options = {}) {const localStore = useLocalObservable<Store>(() => ({isPending: {value: isPendingAfterMount,set: function (value) {this.value = value;},},}));const call = useCallback(async (...params: P): Promise<R["result"]> => {localStore.isPending.set(true);try {const { data } = await service(...params);const { result } = data;localStore.isPending.set(false);return result;} catch (error) {if (isIgnoreHTTPErrors === false) {console.error(error);}localStore.isPending.set(false);throw error;}},[service, isIgnoreHTTPErrors]);const isPending = useCallback(() => {return localStore.isPending.value;}, []);useEffect(() => {localStore.isPending.set(isPendingAfterMount);}, [isPendingAfterMount]);return {call,isPending,};}export { useAPI };export interface Options {isPendingAfterMount?: boolean;isIgnoreHTTPErrors?: boolean;}type Store = Schema.Store<{ isPending: boolean }>;
// models/product.model.ts// Описание модели товараexport interface ProductModel {id: number;name: string;code: string;info: {description: string;note: string;};config: {isAllowedForPurchaseIfInStockZero: boolean;isInStock: boolean;};seo: {title: string;keywords: string;description: string;};}
// services/product.service.ts// Сервисы для взаимодействия с товарамиimport { api } from "../api";import { routesConfig } from "../config";import type { ProductModel } from "../models";import type { API } from "../types";export function getOne(code: string) {return api.get<API.Service.Response.GetOne<ProductModel>>(routesConfig.productAPIRoutes.getOne(code));}
// theme/index.ts// Тема приложенияimport { DefaultTheme } from "styled-components";export const theme: DefaultTheme = {colors: {primary: "#2648f1",intense: "#151e27",green: "#53d769",grey: "#626b73",red: "#f73d34",orange: "#fdb549",yellow: "#ffe243",white: "white",},};
// types/index.tsx// Вспомогательные типыimport type { AxiosResponse } from "axios";export namespace API {export namespace Service {export namespace Response {export type Upsert<T> = Response<T | null>;export type GetOne<T> = Response<T | null>;export type GetMany<T> = Response<{rows: T[];totalRowCount: number;totalPageCount: number;}>;}export type Function<T extends API.Response<any>, U extends any[] = any[]> = (...params: U) => Promise<AxiosResponse<T>>;}export type Response<T> = {status: number;result: T;};}
// utils/throttle.tsfunction throttle<P extends any[]>(func: (...params: P) => any, limit: number) {let inThrottle: boolean;return function (...params: P): any {if (!inThrottle) {inThrottle = true;func(...params);setTimeout(() => (inThrottle = false), limit);}};}export { throttle };
// store/index.tsximport { createContext } from "react";import { useLocalObservable } from "mobx-react-lite";import { app, App } from "./segments/app";import { layout, Layout } from "./segments/layout";import { counters, Counters } from "./segments/counters";export const combinedStore = { layout, app, counters };export const storeContext = createContext<StoreContext>(combinedStore);export function StoreProvider({ children }: { children: React.ReactNode }) {const store = useLocalObservable(() => combinedStore);return <storeContext.Provider value={store}>{children}</storeContext.Provider>;}export type StoreContext = {app: App;layout: Layout;counters: Counters;};
// api.ts// Экземпляр AXIOS для взаимодействия с серверомimport axios from "axios";import { apiConfig } from "./config";const api = axios.create({baseURL: apiConfig.serverURI,});api.interceptors.request.use((req) => {return {...req,baseURL: apiConfig.serverURI,};});export { api };

Ух ты! Как же много получилось.

И напоследок...

Есть еще несколько, немаловажных папок, которые также следует упомянуть:

  • Assets - в данной папке хранятся все статичные файлы, такие как: иконки, изображения, шрифты и т.д. (их, конечно же, также стоит группировать и разделять на папки)

  • Routes - в данной папке (либо же файле, кому как больше нравится) хранятся все роуты приложения (пример будет ниже).

  • Styles - в данной папке хранятся все глобальные стили, которые применяются ко всем элементам и документу, в том числе.

// routes/index.tsximport { Switch, Route } from "react-router-dom";// Экспортируем страницыimport { Product } from "../pages/Product";...import { NotFound } from "../pages/NotFound";import { routesConfig } from "../core/config";const Routes = () => {return (<Switch><Route exact path={routesConfig.productBrowserRoutes.getOne()}><Product /></Route>{/* Объявляем как-то роуты */}<Route><NotFound /></Route></Switch>);};export { Routes };

Остается еще 2 файла:

  • app.tsx - компонент приложения

Примерно так он может выглядеть:

// app.tsximport React, { useEffect } from "react";// Импортирует роутыimport { Routes } from "./routes";const App = () => {return (<Routes />);};export { App };
  • index.tsx - входной файл вашего приложения

Он же может выглядеть примерно так:

import React from "react";import ReactDOM from "react-dom";import { BrowserRouter } from "react-router-dom";import { ThemeProvider } from "styled-components";// импортируем нашеimport { App } from "./app";// импортируем глобальные стилиimport { BodyStyles } from "./styles";import { StoreProvider } from "../core/store";// импортируем темуimport { theme } from "../core/theme";import reportWebVitals from "./reportWebVitals";const app = document.getElementById("app");ReactDOM.render(<React.StrictMode><ThemeProvider theme={theme}><BodyStyles /><BrowserRouter><StoreProvider><App /></StoreProvider></BrowserRouter></ThemeProvider></React.StrictMode>,app);reportWebVitals();

И на этом, я думаю, стоит закончить.

Итоговая структура выглядит вот так:

. src/     assets/        fonts        icons     components/        ordinary        simple        smart        ui     containers     core/        config        constants        hooks        models        schemes        services        store        theme        types        utils        api.ts     pages     routes     styles     app.tsx     index.tsx

Заключение

Если вам понравилась эта статья и вы узнали что-то интересное для себя, то я очень этому рад.

Ссылка на репозиторий (за такой скудный ридми, мне еще не по силам разговорный английский).

Всем удачи и огромное спасибо за внимание.

Подробнее..

React основные подходы к управлению состоянием

12.03.2021 08:08:07 | Автор: admin


Доброго времени суток, друзья!

Предлагаю вашему вниманию простое приложение список задач. Что в нем особенного, спросите вы. Дело в том, что я попытался реализовать одну и ту же тудушку с использованием четырех разных подходов к управлению состоянием в React-приложениях: useState, useContext + useReducer, Redux Toolkit и Recoil.

Начнем с того, что такое состояние приложения, и почему так важен выбор правильного инструмента для работы с ним.

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

Условно, состояние можно разделить на локальное и глобальное. Под локальным состоянием, обычно, понимается состояние отдельно взятого компонента, например, состояние формы, как правило, является локальным состоянием соответствующего компонента. В свою очередь, глобальное состояние правильнее именовать распределенным или совместно используемым, подразумевая под этим то, что такое состояние используется более чем одним компонентом. Условность рассматриваемой градации выражается в том, что локальное состояние вполне может использоваться несколькими компонентами (например, состояние, определенное с помощью useState(), может в виде пропов передаваться дочерним компонентам), а глобальное состояние не обязательно используется всеми компонентами приложения (например, в Redux, где имеется одно хранилище для состояния всего приложения, обычно, создается отдельный срез (slice) состояния для каждой части UI, точнее, для логики управления этой частью).

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

Я не буду вдаваться в подробности работы каждого инструмента, а ограничусь общим описанием и ссылками на соответствующие материалы. Для прототипирования UI будет использоваться react-bootstrap.

Код на GitHub
Песочница на CodeSandbox

Создаем проект с помощью Create React App:

yarn create react-app state-management# илиnpm init react-app state-management# илиnpx create-react-app state-management

Устанавливаем зависимости:

yarn add bootstrap react-bootstrap nanoid# илиnpm i bootstrap react-bootstrap nanoid

  • bootstrap, react-bootstrap стили
  • nanoid утилита для генерации уникального ID

В src создаем директорию use-state для первого варианта тудушки.

useState()


Шпаргалка по хукам

Хук useState() предназначен для управления локальным состоянием компонента. Он возвращает массив с двумя элементами: текущим значением состояния и сеттером функцией для обновления этого значения. Сигнатура данного хука:

const [state, setState] = useState(initialValue)

  • state текущее значение состояния
  • setState сеттер
  • initialValue начальное или дефолтное значение

Одним из преимуществ деструктуризации массива, в отличие от деструктуризации объекта, является возможность использования произвольных названий переменных. По соглашению, название сеттера должно начинаться с set + название первого элемента с большой буквы ([count, setCount], [text, setText] и т.п.).

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

Структура проекта:

|--use-state  |--components    |--index.js    |--TodoForm.js    |--TodoList.js    |--TodoListItem.js  |--App.js

Думаю, тут все понятно.

В App.js мы с помощью useState() определяем начальное состояние приложения, импортируем и рендерим компоненты приложения, передавая им состояние и сеттер в виде пропов:

// хукimport { useState } from 'react'// компонентыimport { TodoForm, TodoList } from './components'// стилиimport { Container } from 'react-bootstrap'// начальное состояние// изучите его как следует, чтобы понимать логику обновленияconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}export default function App() {  const [state, setState] = useState(initialState)  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useState</h1>      <TodoForm setState={setState} />      {length ? <TodoList state={state} setState={setState} /> : null}    </Container>  )}

В TodoForm.js мы реализуем добавление новой задачи в список:

// хукimport { useState } from 'react'// утилита для генерации IDimport { nanoid } from 'nanoid'// стилиimport { Container, Form, Button } from 'react-bootstrap'// функция принимает сеттерexport const TodoForm = ({ setState }) => {  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const id = nanoid(5)      const newTodo = { id, text, completed: false }      // обратите внимание, как нам приходится обновлять состояние      setState((state) => ({        ...state,        todos: {          ...state.todos,          ids: state.todos.ids.concat(id),          entities: {            ...state.todos.entities,            [id]: newTodo          }        }      }))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

В TodoList.js мы просто рендерим список элементов:

// компонентimport { TodoListItem } from './TodoListItem'// стилиimport { Container, ListGroup } from 'react-bootstrap'// функция принимает состояние и сеттер только для того,// чтобы передать их потомкам// обратите внимание, как мы передаем отдельную задачуexport const TodoList = ({ state, setState }) => (  <Container className='mt-2'>    <h4>List</h4>    <ListGroup>      {state.todos.ids.map((id) => (        <TodoListItem          key={id}          todo={state.todos.entities[id]}          setState={setState}        />      ))}    </ListGroup>  </Container>)

Наконец, в TodoListItem.js происходит самое интересное здесь мы реализуем оставшиеся операции: переключение, обновление и удаление задачи:

// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// функция принимает задачу и сеттерexport const TodoListItem = ({ todo, setState }) => {  const { id, text, completed } = todo  // переключение задачи  const toggleTodo = () => {    setState((state) => {      // небольшая оптимизация      const { todos } = state      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    })  }  // обновление задачи  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      setState((state) => {        const { todos } = state        return {          ...state,          todos: {            ...todos,            entities: {              ...todos.entities,              [id]: {                ...todos.entities[id],                text: trimmed              }            }          }        }      })    }  }  // удаление задачи  const deleteTodo = () => {    setState((state) => {      const { todos } = state      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    })  }  // небольшой финт для упрощения обновления задачи  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={toggleTodo}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

В components/index.js мы выполняем повторный экспорт компонентов:

export { TodoForm } from './TodoForm'export { TodoList } from './TodoList'

Файл scr/index.js выглядит следующим образом:

import React from 'react'import { render } from 'react-dom'// стилиimport 'bootstrap/dist/css/bootstrap.min.css'// компонентimport App from './use-state/App'const root$ = document.getElementById('root')render(<App />, root$)

Основные проблемы данного подхода к управлению состоянием:

  • Необходимость передачи состояния и/или сеттера на каждом уровне вложенности, обусловленная локальным характером состояния
  • Логика обновления состояния приложения разбросана по компонентам и смешана с логикой самих компонентов
  • Сложность обновления состояния, вытекающая из его иммутабельности
  • Однонаправленный поток данных, невозможность свободного обмена данными между компонентами, находящимися на одном уровне вложенности, но в разных поддеревьях виртуального DOM

Первые две проблемы можно решить с помощью комбинации useContext()/ useReducer().

useContext() + useReducer()


Шпаргалка по хукам

Контекст (context) позволяет передавать значения дочерним компонентам напрямую, минуя их предков. Хук useContext() позволяет извлекать значения из контекста в любом компоненте, обернутом в провайдер (provider).

Создание контекста:

const TodoContext = createContext()

Предоставление контекста с состоянием дочерним компонентам:

<TodoContext.Provider value={state}>  <App /></TodoContext.Provider>

Извлечение значения состония из контекста в компоненте:

const state = useContext(TodoContext)

Хук useReducer() принимает редуктор (reducer) и начальное состояние. Он возвращает значение текущего состояния и функцию для отправки (dispatch) операций (actions), на основе которых осуществляется обновление состояния. Сигнатура данного хука:

const [state, dispatch] = useReducer(todoReducer, initialState)

Алгоритм обновления состояния выглядит так: компонент отправляет операцию в редуктор, а редуктор на основе типа операции (action.type) и опциональной полезной нагрузки операции (action.payload) определенным образом изменяет состояния.

Результатом комбинации useContext() и useReducer() является возможность передачи состояния и диспетчера, возвращаемых useReducer(), любому компоненту, являющемуся потомком провайдера контекста.

Создаем директорию use-reducer для второго варианта тудушки. Структура проекта:

|--use-reducer  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js    |--todoReducer      |--actions.js      |--actionTypes.js      |--todoReducer.js    |--todoContext.js  |--App.js

Начнем с редуктора. В actionTypes.js мы просто определяем типы (названия, константы) операций:

const ADD_TODO = 'ADD_TODO'const TOGGLE_TODO = 'TOGGLE_TODO'const UPDATE_TODO = 'UPDATE_TODO'const DELETE_TODO = 'DELETE_TODO'export { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO }

Типы операций определяются в отдельном файле, поскольку используются как при создании объектов операции, так и при выборе редуктора случая (case reducer) в инструкции switch. Существует другой подход, когда типы, создатели операции и редуктор размещаются в одном файле. Такой подход назвается утиной структурой файла.

В actions.js определяются так называемые создатели операций (action creators), возвращающие объекты определенной формы (для редуктора):

import { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO } from './actionTypes'const createAction = (type, payload) => ({ type, payload })const addTodo = (newTodo) => createAction(ADD_TODO, newTodo)const toggleTodo = (todoId) => createAction(TOGGLE_TODO, todoId)const updateTodo = (payload) => createAction(UPDATE_TODO, payload)const deleteTodo = (todoId) => createAction(DELETE_TODO, todoId)export { addTodo, toggleTodo, updateTodo, deleteTodo }

В todoReducer.js определяется сам редуктор. Еще раз: редуктор принимает состояние приложения и операцию, отправленную из компонента, и на основе типа операции (и полезной нагрузки) выполняет определенные действия, приводящие к обновлению состояния. Обновление состояния выполняется точно также, как в предыдущем варианте тудушки, только вместо setState() редуктор возвращает новое состояние.

// утилита для генерации IDimport { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'export const todoReducer = (state, action) => {  const { todos } = state  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      return {        ...state,        todos: {          ...todos,          ids: todos.ids.concat(id),          entities: {            ...todos.entities,            [id]: { id, ...newTodo }          }        }      }    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              text            }          }        }      }    }    case actions.DELETE_TODO: {      const { payload: id } = action      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    }    // по умолчанию (при отсутствии совпадения со всеми case) редуктор возвращает состояние в неизменном виде    default:      return state  }}

В todoContext.js определяется начальное состояние приложения, создается и экспортируется провайдер контекста со значением состояния и диспетчером из useReducer():

// reactimport { createContext, useReducer, useContext } from 'react'// редукторimport { todoReducer } from './todoReducer/todoReducer'// создаем контекстconst TodoContext = createContext()// начальное состояниеconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// провайдерexport const TodoProvider = ({ children }) => {  const [state, dispatch] = useReducer(todoReducer, initialState)  return (    <TodoContext.Provider value={{ state, dispatch }}>      {children}    </TodoContext.Provider>  )}// утилита для извлечения значений из контекстаexport const useTodoContext = () => useContext(TodoContext)

В этом случае src/index.js выглядит так:

// React, ReactDOM и стилиimport { TodoProvider } from './use-reducer/modules/TodoContext'import App from './use-reducer/App'const root$ = document.getElementById('root')render(  <TodoProvider>    <App />  </TodoProvider>,  root$)

Теперь у нас нет необходимости передавать состояние и функцию для его обновления на каждом уровне вложенности компонентов. Компонент извлекает состояние и диспетчера с помощью useTodoContext(), например:

import { useTodoContext } from '../TodoContext'// в компонентеconst { state, dispatch } = useTodoContext()

Операции отправляются в редуктор с помощью dispatch(), которому передается создатель операции, которому может передаваться полезная нагрузка:

import * as actions from '../todoReducer/actions'// в компонентеdispatch(actions.addTodo(newTodo))

Код компонентов
App.js:

// componentsimport { TodoForm, TodoList } from './modules/components'// stylesimport { Container } from 'react-bootstrap'// contextimport { useTodoContext } from './modules/TodoContext'export default function App() {  const { state } = useTodoContext()  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useReducer</h1>      <TodoForm />      {length ? <TodoList /> : null}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// stylesimport { Container, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoForm = () => {  const { dispatch } = useTodoContext()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { text, completed: false }      dispatch(actions.addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'export const TodoList = () => {  const {    state: { todos }  } = useTodoContext()  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {todos.ids.map((id) => (          <TodoListItem key={id} todo={todos.entities[id]} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoListItem = ({ todo }) => {  const { dispatch } = useTodoContext()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(actions.updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(actions.toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(actions.deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}


Таким образом, мы решили две первые проблемы, связанные с использованием useState() в качестве инструмента для управления состоянием. На самом деле, прибегнув к помощи одной интересной библиотеки, мы можем решить и третью проблему сложность обновления состояния. immer позволяет безопасно мутировать иммутабельные значения (да, я знаю, как это звучит), для этого достаточно обернуть редуктор в функцию produce(). Создадим файл todoReducer/todoProducer.js:

// утилита, предоставляемая immerimport produce from 'immer'import { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'// сравните с "классической" реализацией редуктора// для обновления состояния используется draft - черновик исходного состоянияexport const todoProducer = produce((draft, action) => {  const {    todos: { ids, entities }  } = draft  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      ids.push(id)      entities[id] = { id, ...newTodo }      break    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      entities[id].completed = !entities[id].completed      break    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      entities[id].text = text      break    }    case actions.DELETE_TODO: {      const { payload: id } = action      ids.splice(ids.indexOf(id), 1)      delete entities[id]      break    }    default:      return draft  }})

Главное ограничение, накладываемое immer, состоит в том, что мы должны либо мутировать состояние напрямую, либо возвращать состояние, обновленное иммутабельно. Нельзя делать и то, и другое одновременно.

Вносим изменения в todoContext.js:

// import { todoReducer } from './todoReducer/todoReducer'import { todoProducer } from './todoReducer/todoProducer'// в провайдере// const [state, dispatch] = useReducer(todoReducer, initialState)const [state, dispatch] = useReducer(todoProducer, initialState)

Все работает, как и прежде, но код редуктора стало легче читать и анализировать.

Двигаемся дальше.

Redux Toolkit


Руководство по Redux Toolkit

Redux Toolkit это набор инструментов, облегчающий работу с Redux. Сам по себе Redux очень похож на то, что мы реализовали с помощью useContext() + useReducer():

  • Состояние всего приложения находится в одном хранилище (store)
  • Дочерние компоненты оборачиваются в Provider из react-redux, которому в виде пропа store передается хранилище
  • Редукторы (reducers) каждой части состояния объединяются с помощью combineReducers() в один корневой редуктор (root reducer), который передается при создании хранилища в createStore()
  • Компоненты подключаются к хранилищу с помощью connect() (+ mapStateToProps(), mapDispatchToProps()) и т.д.

Для реализации основных операций мы воспользуемся следующими утилитами из Redux Toolkit:

  • configureStore() для создания и настройки хранилища
  • createSlice() для создания частей состояния
  • createEntityAdapter() для создания адаптера сущностей

Чуть позже мы расширим функционал списка задач с помощью следующих утилит:

  • createSelector() для создания селекторов
  • createAsyncThunk() для создания преобразователей (thunk)

Также в компонентах мы будем использовать следующие хуки из react-redux: useDispatch() для получения доступа к диспетчеру и useSelector() для получения доступа к селекторам.

Создаем директорию redux-toolkit для третьего варианта тудушки. Устанавливаем Redux Toolkit:

yarn add @reduxjs/toolkit# илиnpm i @reduxjs/toolkit

Структура проекта:

|--redux-toolkit  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js  |--slices    |--todosSlice.js  |--App.js  |--store.js

Начнем с хранилища. store.js:

// утилита для создания хранилищаimport { configureStore } from '@reduxjs/toolkit'// редукторimport todosReducer from './modules/slices/todosSlice'// начальное состояниеconst preloadedState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// хранилищеconst store = configureStore({  reducer: {    todos: todosReducer  },  preloadedState})export default store

В этом случае src/index.js выглядит так:

// React, ReactDOM & стили// провайдерimport { Provider } from 'react-redux'// основной компонентimport App from './redux-toolkit/App'// хранилищеimport store from './redux-toolkit/store'const root$ = document.getElementById('root')render(  <Provider store={store}>    <App />  </Provider>,  root$)

Переходим к редуктору. slices/todosSlice.js:

// утилиты для создания части состояния и адаптера сущностейimport {  createSlice,  createEntityAdapter} from '@reduxjs/toolkit'// создаем адаптерconst todosAdapter = createEntityAdapter()// инициализируем начальное состояние// получаем { ids: [], entities: {} }const initialState = todosAdapter.getInitialState()// создаем часть состоянияconst todosSlice = createSlice({  // уникальный ключ, используемый в качестве префикса при генерации создателей операции  name: 'todos',  // начальное состояние  initialState,  // редукторы  reducers: {    // данный создатель операции отправляет в редуктор операцию { type: 'todos/addTodo', payload: newTodo }    addTodo: todosAdapter.addOne,    // Redux Toolkit использует immer для обновления состояния    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne  }})// экспортируем селектор для получения всех entities в виде массиваexport const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// экспортируем создателей операцииexport const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo} = todosSlice.actions// эскпортируем редукторexport default todosSlice.reducer

В компоненте для доступа к диспетчеру используется useDispatch(), а для отправки конкретной операции создатель операции, импортируемый из todosSlice.js:

import { useDispatch } from 'react-redux'import { addTodo } from '../slices/todosSlice'// в компонентеconst dispatch = useDispatch()dispatch(addTodo(newTodo))

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

Начнем с сервера.

В качестве fake API мы будем использовать JSON Server. Вот шпаргалка по работе с ним. Устанавливаем json-server и concurrently утилиту для выполнения двух и более команд:

yarn add json-server concurrently# илиnpm i json-server concurrently

Вносим изменения в раздел scripts package.json:

"server": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""

  • -w означает наблюдение за изменениями файла db.json
  • -p означает порт, по умолчанию запросы из приложения отправляются на порт 3000
  • -d задержка ответа от сервера

Создаем файл db.json в корневой директории проекта (state-management):

{  "todos": [    {      "id": "1",      "text": "Eat",      "completed": true,      "visible": true    },    {      "id": "2",      "text": "Code",      "completed": true,      "visible": true    },    {      "id": "3",      "text": "Sleep",      "completed": false,      "visible": true    },    {      "id": "4",      "text": "Repeat",      "completed": false,      "visible": true    }  ]}

По умолчанию все запросы из приложения отправляются на порт 3000 (порт, на котором запущен сервер для разработки). Для того, чтобы запросы отправлялись на порт 5000 (порт, на котором будет работать json-server), необходимо их проксировать. Добавляем в package.json следующую строку:

"proxy": "http://localhost:5000"

Запускаем сервер с помощью команды yarn server.

Создаем еще одну часть состояния. slices/filterSlice.js:

import { createSlice } from '@reduxjs/toolkit'// фильтрыexport const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}// начальное состояние - отображать все задачиconst initialState = {  status: Filters.All}// состояние фильтраconst filterSlice = createSlice({  name: 'filter',  initialState,  reducers: {    setFilter(state, action) {      state.status = action.payload    }  }})export const { setFilter } = filterSlice.actionsexport default filterSlice.reducer

Вносим изменения в store.js:

// нам больше не требуется preloadedStateimport { configureStore } from '@reduxjs/toolkit'import todosReducer from './modules/slices/todosSlice'import filterReducer from './modules/slices/filterSlice'const store = configureStore({  reducer: {    todos: todosReducer,    filter: filterReducer  }})export default store

Вносим изменения в todosSlice.js:

import {  createSlice,  createEntityAdapter,  // утилита для создания селекторов  createSelector,  // утилита для создания преобразователей  createAsyncThunk} from '@reduxjs/toolkit'// утилита для выполнения HTTP-запросовimport axios from 'axios'// фильтрыimport { Filters } from './filterSlice'const todosAdapter = createEntityAdapter()const initialState = todosAdapter.getInitialState({  // добавляем в начальное состояние статус загрузки  status: 'idle'})// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// преобразовательexport const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {  try {    const response = await axios(SERVER_URL)    return response.data  } catch (err) {    console.error(err.toJSON())  }})const todosSlice = createSlice({  name: 'todos',  initialState,  reducers: {    addTodo: todosAdapter.addOne,    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne,    // создатель операции для выполнения всех задач    completeAllTodos(state) {      Object.values(state.entities).forEach((todo) => {        todo.completed = true      })    },    // создатель операции для очистки выполненных задач    clearCompletedTodos(state) {      const completedIds = Object.values(state.entities)        .filter((todo) => todo.completed)        .map((todo) => todo.id)      todosAdapter.removeMany(state, completedIds)    }  },  // дополнительные редукторы  extraReducers: (builder) => {    builder      // после начала выполнения запроса на получения задач      // меняем значение статуса на loading      // это позволит отображать индикатор загрузки в App.js      .addCase(fetchTodos.pending, (state) => {        state.status = 'loading'      })      // после получения задач от сервера      // записываем их в состояние      // и меняем статус загрузки      .addCase(fetchTodos.fulfilled, (state, action) => {        todosAdapter.setAll(state, action.payload)        state.status = 'idle'      })  }})export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// создаем и экспортируем кастомный селектор для получения отфильтрованных задачexport const selectFilteredTodos = createSelector(  selectAllTodos,  (state) => state.filter,  (todos, filter) => {    const { status } = filter    if (status === Filters.All) return todos    return status === Filters.Active      ? todos.filter((todo) => !todo.completed)      : todos.filter((todo) => todo.completed)  })export const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo,  completeAllTodos,  clearCompletedTodos} = todosSlice.actionsexport default todosSlice.reducer

Вносим изменения в src/index.js:

// после импорта компонента "App"import { fetchTodos } from './redux-toolkit/modules/slices/todosSlice'store.dispatch(fetchTodos())

App.js выглядит так:

// хук для доступа к селекторамimport { useSelector } from 'react-redux'// индикатор загрузки - спиннерimport Loader from 'react-loader-spinner'// компонентыimport {  TodoForm,  TodoList,  TodoFilters,  TodoControls,  TodoStats} from './modules/components'// стилиimport { Container } from 'react-bootstrap'// селектор для получения всех entitites в виде массиваimport { selectAllTodos } from './modules/slices/todosSlice'export default function App() {  // получаем длину массива сущностей  const { length } = useSelector(selectAllTodos)  // получаем значение статуса  const loadingStatus = useSelector((state) => state.todos.status)  // стили для индикатора загрузки  const loaderStyles = {    position: 'absolute',    top: '50%',    left: '50%',    transform: 'translate(-50%, -50%)'  }  if (loadingStatus === 'loading')    return (      <Loader        type='Oval'        color='#00bfff'        height={80}        width={80}        style={loaderStyles}      />    )  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>Redux Toolkit</h1>      <TodoForm />      {length ? (        <>          <TodoStats />          <TodoFilters />          <TodoList />          <TodoControls />        </>      ) : null}    </Container>  )}

Код остальных компонентов
TodoControls.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// action creatorsimport { completeAllTodos, clearCompletedTodos } from '../slices/todosSlice'export const TodoControls = () => {  const dispatch = useDispatch()  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button          variant='outline-secondary'          onClick={() => dispatch(completeAllTodos())}        >          Complete all        </Button>        <Button          variant='outline-secondary'          onClick={() => dispatch(clearCompletedTodos())}        >          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// reduximport { useDispatch, useSelector } from 'react-redux'// stylesimport { Container, Form } from 'react-bootstrap'// filters & action creatorimport { Filters, setFilter } from '../slices/filterSlice'export const TodoFilters = () => {  const dispatch = useDispatch()  const { status } = useSelector((state) => state.filter)  const changeFilter = (filter) => {    dispatch(setFilter(filter))  }  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === status        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => changeFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// reduximport { useDispatch } from 'react-redux'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// action creatorimport { addTodo } from '../slices/todosSlice'export const TodoForm = () => {  const dispatch = useDispatch()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      dispatch(addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// reduximport { useSelector } from 'react-redux'// componentimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectFilteredTodos } from '../slices/todosSlice'export const TodoList = () => {  const filteredTodos = useSelector(selectFilteredTodos)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// action creatorsimport { toggleTodo, updateTodo, deleteTodo } from '../slices/todosSlice'export const TodoListItem = ({ todo }) => {  const dispatch = useDispatch()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// reduximport { useSelector } from 'react-redux'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectAllTodos } from '../slices/todosSlice'export const TodoStats = () => {  const allTodos = useSelector(selectAllTodos)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (allTodos.length) {      const total = allTodos.length      const completed = allTodos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [allTodos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Как мы видим, с появлением Redux Toolkit использовать Redux для управления состоянием приложения стало проще, чем комбинацию useContext() + useReducer() (невероятно, но факт), не считая того, что Redux предоставляет больше возможностей для такого управления. Однако, Redux все-таки рассчитан на большие приложения со сложным состоянием. Существует ли какая-то альтернатива для управления состоянием небольших и средних приложений, кроме useContext()/useReducer(). Ответ: да, существует. Это Recoil.

Recoil


Руководство по Recoil

Recoil это новый инструмент для управления состоянием в React-приложениях. Что значит новый? Это значит, что некоторые его API все еще находятся на стадии разработки и могут измениться в будущем. Однако, те возможности, которые мы будем использовать для создания тудушки, являются стабильными.

В основе Recoil лежат атомы и селекторы. Атом это часть состояния, а селектор часть производного состояния. Атомы создаются с помощью функции atom(), а селекторы с помощью функции selector(). Для извлечение значений из атомов и селекторов используются хуки useRecoilState() (для чтения и записи), useRecoilValue() (только для чтения), useSetRecoilState() (только для записи) и др. Компоненты, использующие состояние Recoil, должны быть обернуты в RecoilRoot. По ощущениям, Recoil представляет собой промежуточное звено между useState() и Redux.

Создаем директорию recoil для последнего варианта тудушки и устанавливаем Recoil:

yarn add recoil# илиnpm i recoil

Структура проекта:

|--recoil  |--modules    |--atoms      |--filterAtom.js      |--todosAtom.js    |--components      |--index.js      |--TodoControls.js      |--TodoFilters.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js      |--TodoStats.js  |--App.js

Вот как выглядит атом списка задач:

// todosAtom.js// утилиты для создания атомов и селекторовimport { atom, selector } from 'recoil'// утилита для выполнения HTTP-запросовimport axios from 'axios'// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// атом с состоянием для списка задачexport const todosState = atom({  key: 'todosState',  default: selector({    key: 'todosState/default',    get: async () => {      try {        const response = await axios(SERVER_URL)        return response.data      } catch (err) {        console.log(err.toJSON())      }    }  })})

Одной из интересных особенностей Recoil является то, что мы можем смешивать синхронную и асинхронную логику при создании атомов и селекторов. Он спроектирован таким образом, что у нас имеется возможность использовать React Suspense для отображения резервного контента до получения данных. Также у нас имеется возможность использовать предохранитель (ErrorBoundary) для перехвата ошибок, возникающих при создании атомов и селекторов, в том числе асинхронным способом.

В этом случае src/index.js выглядит так:

import React, { Component, Suspense } from 'react'import { render } from 'react-dom'// recoilimport { RecoilRoot } from 'recoil'// индикатор загрузкиimport Loader from 'react-loader-spinner'import App from './recoil/App'// предохранитель с официального сайта Reactclass ErrorBoundary extends Component {  constructor(props) {    super(props)    this.state = { error: null, errorInfo: null }  }  componentDidCatch(error, errorInfo) {    this.setState({      error: error,      errorInfo: errorInfo    })  }  render() {    if (this.state.errorInfo) {      return (        <div>          <h2>Something went wrong.</h2>          <details style={{ whiteSpace: 'pre-wrap' }}>            {this.state.error && this.state.error.toString()}            <br />            {this.state.errorInfo.componentStack}          </details>        </div>      )    }    return this.props.children  }}const loaderStyles = {  position: 'absolute',  top: '50%',  left: '50%',  transform: 'translate(-50%, -50%)'}const root$ = document.getElementById('root')// мы оборачиваем основной компонент приложения сначала в Suspense, затем в ErrorBoundaryrender(  <RecoilRoot>    <Suspense      fallback={        <Loader          type='Oval'          color='#00bfff'          height={80}          width={80}          style={loaderStyles}        />      }    >      <ErrorBoundary>        <App />      </ErrorBoundary>    </Suspense>  </RecoilRoot>,  root$)

Атом фильтра выглядит следующим образом:

// filterAtom.js// recoilimport { atom, selector } from 'recoil'// атомimport { todosState } from './todosAtom'export const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}export const todoListFilterState = atom({  key: 'todoListFilterState',  default: Filters.All})// данный селектор использует два атома: атом фильтра и атом списка задачexport const filteredTodosState = selector({  key: 'filteredTodosState',  get: ({ get }) => {    const filter = get(todoListFilterState)    const todos = get(todosState)    if (filter === Filters.All) return todos    return filter === Filters.Completed      ? todos.filter((todo) => todo.completed)      : todos.filter((todo) => !todo.completed)  }})

Компоненты извлекают значения из атомов и селекторов с помощью указанных выше хуков. Например, код компонента TodoListItem выглядит так:

// хукimport { useRecoilState } from 'recoil'// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// атомimport { todosState } from '../atoms/todosAtom'export const TodoListItem = ({ todo }) => {  // данный хук - это как useState() для состояния Recoil  const [todos, setTodos] = useRecoilState(todosState)  const { id, text, completed } = todo  const toggleTodo = () => {    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, completed: !todo.completed } : todo    )    setTodos(newTodos)  }  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (!trimmed) return    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, text: value } : todo    )    setTodos(newTodos)  }  const deleteTodo = () => {    const newTodos = todos.filter((todo) => todo.id !== id)    setTodos(newTodos)  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check type='checkbox' checked={completed} onChange={toggleTodo} />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

Код остальных компонентов
TodoControls.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoControls = () => {  const [todos, setTodos] = useRecoilState(todosState)  const completeAllTodos = () => {    const newTodos = todos.map((todo) => (todo.completed = true))    setTodos(newTodos)  }  const clearCompletedTodos = () => {    const newTodos = todos.filter((todo) => !todo.completed)    setTodos(newTodos)  }  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button variant='outline-secondary' onClick={completeAllTodos}>          Complete all        </Button>        <Button variant='outline-secondary' onClick={clearCompletedTodos}>          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, Form } from 'react-bootstrap'// filters & atomimport { Filters, todoListFilterState } from '../atoms/filterAtom'export const TodoFilters = () => {  const [filter, setFilter] = useRecoilState(todoListFilterState)  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === filter        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => setFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// recoilimport { useSetRecoilState } from 'recoil'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoForm = () => {  const [text, setText] = useState('')  const setTodos = useSetRecoilState(todosState)  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      setTodos((oldTodos) => oldTodos.concat(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// recoilimport { useRecoilValue } from 'recoil'// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { filteredTodosState } from '../atoms/filterAtom'export const TodoList = () => {  const filteredTodos = useRecoilValue(filteredTodosState)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// recoilimport { useRecoilValue } from 'recoil'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoStats = () => {  const todos = useRecoilValue(todosState)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (todos.length) {      const total = todos.length      const completed = todos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [todos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Заключение


Итак, мы с вами реализовали список задач с использованием четырех разных подходов к управлению состоянием. Какие выводы можно из всего этого сделать?

Я выскажу свое мнение, оно не претендует на статус истины в последней инстанции. Разумеется, выбор правильного инструмента для управления состоянием зависит от задач, решаемых приложением:

  • Для управления локальным состоянием (состоянием одного-двух компонентов; при условии, что эти два компонента тесно связаны между собой) используйте useState()
  • Для управления распределенным состоянием (состоянием двух и более автономных компонентов) или состоянием небольших и средних приложений используйте Recoil или сочетание useContext()/useReducer()
  • Обратите внимание, что если вам нужно просто передавать значения в глубоко вложенные компоненты, то вам вполне хватит useContext() (useContext() сам по себе не является инструментом для управления состоянием)
  • Наконец, для управления глобальным состоянием (состоянием всех или большинства компонентов) или состоянием сложного приложения используйте Redux Toolkit

Что касается MobX, то я слышал о нем много хорошего, но изучить как следует пока не успел.

Благодарю за внимание и хорошего дня.
Подробнее..

Экосистема JavaScript тренды в 2021 году. Всё ли так однозначно?

01.04.2021 12:23:08 | Автор: admin

В конце прошлого года на сайте State of JS 2020 было опубликовано исследование о состоянии экосистемы JavaScript в 2020 году с ретроспективой на предыдущие годы развития. Исследование основывалось на многочисленных опросах, в которых суммарно приняли участие более 23 тысяч человек из 137 стран мира.

Географическое распределение числа опрошенных.Географическое распределение числа опрошенных.

По результатам исследования можно сделать ряд прогнозов о том, какой будет экосистема JavaScript в 2021 году.

Языки, расширяющие возможности JavaScript

Первое, на что хочется обратить внимание, это тренды использования компилируемых языков, которые расширяют стандартные возможности JavaScript. Здесь всё довольно предсказуемо: TypeScript прочно обосновался на позиции лидера. При этом из года в год количество его поклонников только растет. И, возможно, TypeScript уже стал негласным стандартом для индустрии разработки веб-приложений.

Тренды использования языка.Тренды использования языка.

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

Также, судя по трендам, сообщество проявляет интерес к языкам из ML-семейства, которые предоставляют больше возможностей для приверженцев функционального программирования. Но о преобладании кого-то из них пока говорить рано.

Фреймворки

Наверно, многие помнят, когда в начале бурного развития фронтенд-экосистемы количество фреймворков росло, словно грибы после дождя. Часть из них уже канула в лету (Press F for Backbone.js, Marrionete.js, Prototype.js, [type anything].js), и за последние годы мы могли наблюдать стабилизацию позиций трёх основных конкурентов: React, Angular, Vue. И с каждым годом их доля присутствия на рынке только росла.

Тренды использования технологии.Тренды использования технологии.

Однако здесь не всё так однозначно. В 2019 году ворвался молодой Svelte, который за 2020 год в два раза увеличил свою долю использования среди разработчиков. И при этом в рейтингах проявления интереса и удовлетворённости от использования со стороны IT-сообщества Svelte занимает первое место. Фреймворк стал глотком свежего воздуха в подходе к созданию веб-приложений, и поэтому следует ожидать, что он будет наращивать своё присутствие в 2021 году всё больше и больше.

Тренды интереса к технологии.Тренды интереса к технологии.Тренды удовлетворённости от использования технологии.Тренды удовлетворённости от использования технологии.

Такжеинтересна позицияAngularкак одного из лучших в рейтинге использования.Несмотря на то, что им пользуется более половины опрошенных, интерес к изучению этого JavaScript-фреймворка плавно угасает.Разработчики едины во мнении, что Angular имеет высокий порог входа и применим в основном для разработки крупных и сложных проектов.Ожидается, что в 2021 году разработчиков будут привлекать фреймворки с меньшим порогом входа.

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

Управление данными

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

Тренды использования технологии.Тренды использования технологии.

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

Тренды удовлетворённости от использования технологии.Тренды удовлетворённости от использования технологии.Тренды интереса к технологии.Тренды интереса к технологии.

Инструменты для тестирования

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

Mocha по-прежнему является достойной альтернативной. Но отсутствие явной привязанности к конкретному фреймворку смещает её на вторую позицию.

Jasmine является инструментом тестирования по умолчанию для проектов на Angular. И, возможно, спад его популярности связан с определенным спадом самого Angular.

Тренды использования технологии.Тренды использования технологии.

Также интересен тот факт, что сообщество проявляет активный интерес к инструментам для интеграционного и e2e-тестирования. Сложно сказать, с чем это могло бы быть связано. Возможно, на это повлияла популярность TypeScript, который предлагает статическую проверку типов на этапе компиляции. Что значительно снижает количество требуемых модульных тестов для покрытия кода и высвобождает время для того, чтобы можно было заняться интеграционными и e2e-тестами.

Тренды интереса к технологии.Тренды интереса к технологии.

Заключение

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

Подробнее..

Как готовить микрофронтенды в Webpack 5

27.04.2021 16:10:26 | Автор: admin

Всем привет, меня зовут Иван и я фронтенд-разработчик.

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

Начнём с того, что ребята с Хабра (@artemu78, @dfuse, @Katsuba) уже писали про Module Federation, так что, моя статья - это не что-то уникальное и прорывное. Скорее, это шишки, костыли и велосипеды, которые полезно знать тем, кто собирается использовать данную технологию.

Причина

Причина, по которой решено было внедрять микросервисный подход на фронте, довольно простая - много команд, а проект один, нужно было как-то разделить зоны ответственности и распараллелить разработку. Как раз в тот момент, мне на глаза попался доклад Павла Черторогова про Webpack 5 Module Federation. Честно, это перевернуло моё видение современных веб-приложений. Я очень вдохновился и начал изучать и крутить эту технологию, чтобы понять, можно ли применить это в нашем проекте. Оказалось, всё что нужно, это дописать несколько строк в конфиг Webpack, создать пару компонентов-хелперов, и... всё завелось.

Настройка

Итак, что же нужно сделать, чтобы запустить микрофронтенды на базе сборки Webpack 5?

Для начала, убедитесь, что используете Webpack пятой версии, потому что Module Federation там поддерживается из коробки.

Настройка shell-приложения

Так как, до внедрения микрофронтендов, у нас уже было действующее приложение, решено было использовать его в качестве точки входа и оболочки для подключения других микрофронтендов. Для сборки использовался Webpack версии 4.4 и при обновлении до 5 версии возникли небольшие проблемы с некоторыми плагинами. К счастью, это решилось простым поднятием версий плагинов.

Чтобы создать контейнер на базе сборки Webpack и при помощи этого контейнера иметь возможность импортировать ресурсы с удаленных хостов добавляем в Webpack-конфиг следующий код:

const webpack = require('webpack');// ...const { ModuleFederationPlugin } = webpack.container;const deps = require('./package.json').dependencies;module.exports = {  // ...  output: {    // ...    publicPath: 'auto', // ВАЖНО! Указывайте либо реальный publicPath, либо auto  },  module: {    // ...  },  plugins: [    // ...    new ModuleFederationPlugin({      name: 'shell',      filename: 'shell.js',      shared: {        react: { requiredVersion: deps.react },        'react-dom': { requiredVersion: deps['react-dom'] },        'react-query': {          requiredVersion: deps['react-query'],        },      },      remotes: {        widgets: `widgets@http://localhost:3002/widgets.js`,      },    }),  ],  devServer: {    // ...  },};

Теперь нам нужно забутстрапить точку входа в наше приложение, чтобы оно запускалось асинхронно, для этого создаем файл bootstrap.tsx и кладем туда содержимое файла index.tsx

// bootstrap.tsximport React from 'react';import { render } from 'react-dom';import { App } from './App';import { config } from './config';import './index.scss';config.init().then(() => {  render(<App />, document.getElementById('root'));});

А в index.tsx вызываем этот самый bootstrap

import('./bootstrap');

В общем то всё, в таком виде уже можно импортировать ваши микрофронтенды - они указываются в объекте remotes в формате <name>@<адрес хоста>/<filename>. Но нам такая конфигурация не подходит, ведь на момент сборки приложения мы ещё не знаем откуда будем брать микрофронтенд, к счастью, есть готовое решение, поэтому возьмем код из примера для динамических хостов, так как наше приложение написано на React, то оформим хэлпер в виде React-компонента LazyService:

// LazyService.tsximport React, { lazy, ReactNode, Suspense } from 'react';import { useDynamicScript } from './useDynamicScript';import { loadComponent } from './loadComponent';import { Microservice } from './types';import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary';interface ILazyServiceProps<T = Record<string, unknown>> {  microservice: Microservice<T>;  loadingMessage?: ReactNode;  errorMessage?: ReactNode;}export function LazyService<T = Record<string, unknown>>({  microservice,  loadingMessage,  errorMessage,}: ILazyServiceProps<T>): JSX.Element {  const { ready, failed } = useDynamicScript(microservice.url);  const errorNode = errorMessage || <span>Failed to load dynamic script: {microservice.url}</span>;  if (failed) {    return <>{errorNode}</>;  }  const loadingNode = loadingMessage || <span>Loading dynamic script: {microservice.url}</span>;  if (!ready) {    return <>{loadingNode}</>;  }  const Component = lazy(loadComponent(microservice.scope, microservice.module));  return (    <ErrorBoundary>      <Suspense fallback={loadingNode}>        <Component {...(microservice.props || {})} />      </Suspense>    </ErrorBoundary>  );}

Хук useDynamicScript нужен нам, чтобы в рантайме прикреплять загруженный скрипт к нашему html-документу.

// useDynamicScript.ts  import { useEffect, useState } from 'react';export const useDynamicScript = (url?: string): { ready: boolean; failed: boolean } => {  const [ready, setReady] = useState(false);  const [failed, setFailed] = useState(false);  useEffect(() => {    if (!url) {      return;    }    const script = document.createElement('script');    script.src = url;    script.type = 'text/javascript';    script.async = true;    setReady(false);    setFailed(false);    script.onload = (): void => {      console.log(`Dynamic Script Loaded: ${url}`);      setReady(true);    };    script.onerror = (): void => {      console.error(`Dynamic Script Error: ${url}`);      setReady(false);      setFailed(true);    };    document.head.appendChild(script);    return (): void => {      console.log(`Dynamic Script Removed: ${url}`);      document.head.removeChild(script);    };  }, [url]);  return {    ready,    failed,  };};

loadComponent это обращение к Webpack-контейнеру, по сути - обычный динамический импорт.

// loadComponent.tsexport function loadComponent(scope, module) {  return async () => {    // Initializes the share scope. This fills it with known provided modules from this build and all remotes    await __webpack_init_sharing__('default');    const container = window[scope]; // or get the container somewhere else    // Initialize the container, it may provide shared modules    await container.init(__webpack_share_scopes__.default);    const factory = await window[scope].get(module);    const Module = factory();    return Module;  };}

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

// types.tsexport type Microservice<T = Record<string, unknown>> = {  url: string;  scope: string;  module: string;  props?: T;};
  • url - имя хоста + имя контейнера (например, http://localhost:3002/widgets.js), с которого мы хотим подтянуть модуль

  • scope - параметр name, который мы укажем в удаленном конфиге ModuleFederationPlugin

  • module - имя модуля, который мы хотим подтянуть

  • props - опциональный параметр, если вдруг наш микросервис требует пропсы, нужно их типизировать

Вызов компонента LazyService происходит следующим образом:

import React, { FC, useState } from 'react';import { LazyService } from '../../components/LazyService';import { Microservice } from '../../components/LazyService/types';import { Loader } from '../../components/Loader';import { Toggle } from '../../components/Toggle';import { config } from '../../config';import styles from './styles.module.scss';export const Video: FC = () => {  const [microservice, setMicroservice] = useState<Microservice>({    url: config.microservices.widgets.url,    scope: 'widgets',    module: './Zack',  });  const toggleMicroservice = () => {    if (microservice.module === './Zack') {      setMicroservice({ ...microservice, module: './Jack' });    }    if (microservice.module === './Jack') {      setMicroservice({ ...microservice, module: './Zack' });    }  };  return (    <>      <div className={styles.ToggleContainer}>        <Toggle onClick={toggleMicroservice} />      </div>      <LazyService microservice={microservice} loadingMessage={<Loader />} />    </>  );};

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

Так, с shell-приложением вроде разобрались, теперь нужно откуда-то брать наши модули.

Настройка микрофронтенда

Для начала проделываем все те же манипуляции что и в shell-приложении и убеждаемся, что версия Webpack => 5

Настраиваем ModuleFederationPlugin, но уже со своими параметрами, эти параметры указываем при подключении модуля в основное приложение.

// ...new ModuleFederationPlugin({      name: 'widgets',      filename: 'widgets.js',      shared: {        react: { requiredVersion: deps.react },        'react-dom': { requiredVersion: deps['react-dom'] },        'react-query': {          requiredVersion: deps['react-query'],        },      },      exposes: {        './Todo': './src/App',        './Gallery': './src/pages/Gallery/Gallery',        './Zack': './src/pages/Zack/Zack',        './Jack': './src/pages/Jack/Jack',      },    }),// ...

В объекте exposes указываем те модули, которые мы ходим отдать наружу, точку входа в приложение так же нужно забутстрапить. Если в микрофронтенде нам не нужны модули с других хостов, то компонент LazyService тут не нужен.

Вот и всё, получен работающий прототип микрофронтенда.

Выглядит круто, работает тоже круто. Общие зависимости не грузятся повторно, версии библиотек рулятся плагином, можно динамически переключать модули, в общем, сказка. Если копать глубже, то это очень гибкая технология, можно использовать её не только с React и JavaScript, но и со всем, что переваривает Webpack, то есть теоретически можно подружить части приложения написанные на разных фреймворках, это конечно не очень хорошо, но сделать так можно. Можно собрать модули и положить на CDN, можно использовать контейнер как общую библиотеку компонентов для нескольких приложений. Возможностей реально много.

Проблемы

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

Потеря контекстов в React-компонентах

Как только понадобилось работать с контекстом библиотеки react-router, то возникли проблемы, при попытке использовать в микрофронтенде хук useLocation, например, приложение вылетало с ошибкой.

Ошибка при попытке обращения к контексту shell-приложения из микрофронтендаОшибка при попытке обращения к контексту shell-приложения из микрофронтенда

Для взаимодействия с бэкендом мы используем Apollo, и хотелось, чтобы ApolloClient объявлялся только единожды в shell-приложении. Но при попытке из микрофронтенда просто использовать хук useQuery, в рантайме приложение вылетало с такой же ошибкой как и для useLocation.

Экспериментальным путём было выяснено, для того чтобы контексты правильно работали, нужно в микрофронтендах использовать версию npm-пакета не выше, чем в shell-приложение, так что за этим нужно внимательно следить.

Дублирование UI-компонентов в shell-приложении и микрофронтенде

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

  1. Выносить UI-компоненты в отдельный npm-пакет и использовать его как shared-модуль

  2. "Делиться" компонентами через ModuleFederationPlugin

В принципе, у обоих подходов есть свои плюсы, но мы выбрали первый, потому что так удобнее и прозрачнее управлять библиотекой компонентов. Да и саму технологию Module Federation хотелось использовать как механизм для построения микрофронтендов, а не аналог npm.

Заключение

Пока что выглядит так, что переход на Webpack 5 Module Federation решает проблему, которая стояла перед нашим стримом, а именно - разделение зоны ответственности и распараллеливание разработки. При этом, нет больших накладных расходов при разработке, а настройка довольно проста даже для тех, кто не знаком с этой технологией.

Минусы у этого подхода конечно же есть, накладные расходы для развертывания зоопарка микрофронтендов будут значительно выше, чем для монолита. Если над вашим приложением работает одна-две команды и оно не такое большое, то наверное не стоит делить его на микросервисы.

Но для нашей конкретной проблемы, это решение подошло хорошо, посмотрим, как оно покажет себя в будущем, технология развивается и уже появляются фреймворки и библиотеки, которые под капотом используют Module Federation.

Полезные ссылки

Репозиторий из примера

Документация Module Federation в доках Webpack 5

Примеры использования Module Federation

Плейлист по Module Federation на YouTube

Подробнее..

Перевод 7 лучших библиотек для создания молниеносно быстрых приложений ReactJS

27.05.2021 18:04:31 | Автор: admin

Некоторые необходимые инструменты для rock-star разработчика

Привет, Хабр. В рамках набора на курс "React.js Developer" подготовили перевод материала.

Всех желающих приглашаем на открытый демо-урок "Webpack и babel". На занятии рассмотрим современные и мощные фишки JavaScript Webpack и Babel. Пошагово покажем, как с нуля создать проект на React, используя Webpack. Присоединяйтесь!


ReactJS по умолчанию обладает высокой производительностью. Но время от времени у вас появляется шанс сделать его еще лучше. И замечательное сообщество React придумало для этого несколько фантастических библиотек.

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

Давайте начнем.

. . .

1. React Query

Известно, что React Query, библиотека управления состоянием для React, отсутствует.

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

Да. Это именно то, что она делает. Она помогает нам управлять состоянием сервера без лишних хлопот. Это может уменьшить необходимость использования библиотеки управления состоянием, такой как Redux.

Преимущества

  • Автоматическое кэширование

  • Автоматическое обновление данных в фоновом режиме

  • Значительно сокращает объем кода

До использования React Query

Вот пример для получения данных с помощью нашего пользовательского хука. Он даже не поддерживает кэширование.

const useFetch = (url) => {  const [data, setData] = useState();  const [isLoading, setIsLoading] = useState(false);  const [error, setError] = useState(false);   useEffect(() => {    const fetchData = async () => {      setIsError(false);      setIsLoading(true);      try {        const result = await fetch(url);        setData(result.data);      } catch (error) {        setError(error);      }      setIsLoading(false);    };    fetchData();  }, [url]);    return {data , isLoading , isError}}

После (использования) React Query

Вот код, если мы хотим использовать React Query. Посмотрите, какой он маленький.

import { useQuery } from 'react-query'const { isLoading, error, data } = useQuery('repoData', () =>    fetch(url).then(res =>res.json()  ))

Посмотрите, насколько сильно сократился наш код.

. . .

2. React Hook Form

React Hook Form - это современная библиотека обработки форм, которая может поднять эффективность работы вашей формы на совершенно новый уровень.

Преимущества

  • Уменьшает объем кода

  • Сокращает ненужный ре-рендеринг.

  • Легко интегрируется с современными библиотеками пользовательского интерфейса (UI)

Ниже приведен пример, демонстрирующий, как React Hook Form может улучшить качество кода.

Без React Hook Form

Вот пример создания формы авторизации вручную.

function LoginForm() {  const [email, setEmail] = React.useState("");  const [password, setPassword] = React.useState("");  const handleSubmit = (e: React.FormEvent) => {    e.preventDefault();    console.log({email, password});  }    return (    <form onSubmit={handleSubmit}>          <input        type="email"        id="email"        value={email}        onChange={(e) => setEmail(e.target.value)}      />            <input        type="password"        id="password"        value={password}        onChange={(e) => setPassword(e.target.value)}      />          </form>  );}

С помощью React Form

Вот тот же пример с React Hook Form.

function LoginForm() {  const { register, handleSubmit } = useForm();    const onSubmit = data => console.log(data);     return (    <form onSubmit={handleSubmit(onSubmit)}>      <input {...register("email")} />      <input {...register("password")} />      <input type="submit" />    </form>  );}

Выглядит аккуратно и в то же время эффективно. Попробуйте.

. . .

3. React Window

React Window используется для рендеринга длинных списков. Представьте, что у вас есть список из 1 000 элементов. На экране отображаются только десять, но ваш код пытается визуализировать 1000 элементов одновременно.

Это может привести к серьезным задержкам в вашем приложении. Данная библиотека очень популярна и является обязательным инструментом в вашем арсенале.

Ручной рендеринг 1 000 элементов

import React, {useEffect, useState} from 'react';const names = [] // 1000 namesexport const LongList = () => {    return <div>       {names.map(name => <div> Name is: {name} </div>)}     <div/>}

Но этот код рендерит 1000 элементов одновременно, хотя на экране можно увидеть не более 10-20 элементов.

Использование React Window

Теперь давайте используем React Window.

import { FixedSizeList as List } from 'react-window'; const Row = ({ index, style }) => <div style={style}> Name is {names[index]}</div> const LongList = () => (  <List    height={150}    itemCount={1000}    itemSize={35}    width={300}  >    {Row}  </List>);

Данный код рендерит только то, что вы видите на экране. Поначалу это может быть непонятно, но необходимо, если вам нужно отобразить длинный список.

. . .

4. React LazyLoad

Ленивая загрузка - это техника, используемая для загрузки только того, что вам нужно. Таким образом, она повышает производительность, не расходуя без необходимости вычислительные мощности.

React LazyLoad - это библиотека, специально созданная для этой цели. Вы просто оборачиваете свой компонент, а эта библиотека позаботится обо всем остальном.

Преимущества

  • Повышенная производительность

  • Поддерживает рендеринг на стороне сервера

Без LazyLoad

Вот пример, в котором мы загружаем пять изображений вручную.

import React from 'react';const ImageList = () => {    return <div>    <img src ='image1.png' />    <img src ='image2.png' />    <img src ='image3.png' />    <img src ='image4.png' />    <img src ='image5.png' />  </div>}

С LazyLoad

Вот тот же пример с компонентом LazyLoad.

import React from 'react';import LazyLoad from 'react-lazyload';const ImageList = () => {    return <div>    <LazyLoad> <img src ='image1.png' /> <LazyLoad>    <LazyLoad> <img src ='image2.png' /> <LazyLoad>    <LazyLoad> <img src ='image3.png' /> <LazyLoad>    <LazyLoad> <img src ='image4.png' /> <LazyLoad>    <LazyLoad> <img src ='image5.png' /> <LazyLoad>  </div>}

. . .

5. Почему вы выполняете рендеринг (Why Did You Render)

Ненужный рендеринг может навредить производительности ваших React-приложений. Но иногда мы делаем это, даже не подозревая.

Этот замечательный пакет, Why Did You Render, помогает нам найти проблемы с производительностью и решить их. Вы просто включаете его в любом компоненте, и он сообщает вам, почему именно происходит рендеринг.

Ниже представлен компонент с возникающими проблемами рендеринга.

import React, {useState} from 'react'const WhyDidYouRenderDemo = () => {    console.log('render')        const [user , setUser] = useState({})    const updateUser = () => setUser({name: 'faisal'})    return <>        <div > User is : {user.name}</div>        <button onClick={updateUser}> Update </button>    </>}export default WhyDidYouRenderDemo

После включения эта библиотека будет записывать в консоль следующий результат.

Из этого журнала видно, что мы обновляем объект с одним и тем же значением, что плохо для производительности.

. . .

6. Reselect

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

Reselect решает эту проблему, меморизуя значения и передавая только то, что необходимо.

Преимущества (из документации)

  • Селекторы могут вычислять производные данные, что позволяет Redux хранить минимально возможное состояние.

  • Селекторы эффективны. Селектор не пересчитывается, если один из его аргументов не изменился.

  • Селекторы являются составными. Они могут быть использованы в качестве входных данных для других селекторов.

Пример

Ниже приведен пример получения значений из хранилища и их изменения в селекторе.

import { createSelector } from 'reselect'const shopItemsSelector = state => state.shop.itemsconst subtotalSelector = createSelector(  shopItemsSelector,  items => items.reduce((subtotal, item) => subtotal + item.value, 0))const exampleState = {  shop: {    items: [      { name: 'apple', value: 1.20 },      { name: 'orange', value: 0.95 },    ]  }}

. . .

7. Deep Equal

Deep Equal - это известная библиотека, которую можно использовать для сравнения. Это очень удобно. Ведь в JavaScript, несмотря на то, что два объекта могут иметь одинаковые значения, они считаются разными, поскольку указывают на разные области памяти.

Вот почему мы видим следующий результат.

const user1 = {    name:'faisal'}const user2 ={    name:'faisal'}const normalEqual = user1 === user2 // false

Но если нужно проверить равенство (для мемоизации), то это становится затратной (и сложной) операцией.

Если мы используем Deep Equal, то это повышает производительность в 46 раз. Ниже приведен пример того, как мы можем это сделать.

var equal = require('deep-equal');const user1 = {    name:'faisal'}const user2 ={    name:'faisal'}const deepEqual = equal(user1 , user2); // true -> exactly what we wanted!

. . .

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

Оставляйте комментарии, если у вас на примете есть другие. Хорошего дня!

Ресурсы

  1. Веб-сайт React Query

  2. Веб-сайт React Hook Form

  3. Примеры React Window

  4. Пакет Why Did You Render

  5. Пакет React Lazy Load

  6. Reselect Репозиторий

  7. Пакет Deep Equal


Узнать подробнее о курсе "React.js Developer"

Смотреть открытый онлайн-урок "Webpack и babel"

Подробнее..

Перевод react-router Три метода рендеринга маршрутов (компонентный, рендеринговый и дочерний)

08.06.2021 18:13:14 | Автор: admin

Введение

В прошлом посте я рассказывал об учебнике по настройке react-router. Если вы не читали предыдущий пост, нажмите здесь! Я хочу добавить несколько важных тем о методах рендеринга маршрутов.

Методы рендеринга маршрута

Существует несколько способов рендеринга HTML компонента или тега с помощью <Route>. Я использовал этот способ в своем последнем посте.

<Route path="/">  <Home /></Route>

В этом сниппете нет ничего плохого, и компонент <Home/> будет рендирован. Однако у вас не будет доступа к трем пропсам маршрута match, location и history. Я расскажу об этих трех реквизитах в следующем посте. Оставайтесь с нами! Итак, давайте рассмотрим, как мы можем получить доступ к этим реквизитам, если мы используем эти три метода рендеринга маршрута.

1. Компонентный метод <Route component/>

Первый метод рендеринга очень прост. Нам просто нужно поместить компонент в качестве пропса в Route.

<Route path="/" component={Home} />
const Home = (props) => {  console.log(props);  return <div>Home</div>;};

Вот и все. Вы получите эти пропсы.

Подождите. Как мы можем передать компоненту еще один проп? Ответ заключается в том, что мы не можем использовать этот метод рендеринга. Однако мы можем использовать render и children

2. Рендеринговый метод <Route render/>

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

<Route path="/contact" render={(routeProps) => {  return <Contact name={name} address={address} {...routeProps} />; }}/>

С помощью <Route render/> можно также рендировать HTML тег, и для этого не требуется рендировать такой компонент, как <Route component/>.

<Route path="/contact" render={() => {  return (   <div>    <h2>Contact</h2>    <p>Name: {name}</p>    <p>Adress: {address}</p>   </div>  ); }}/>

Я лично предпочитаю использовать этот метод рендеринга.

3. Дочерний метод <Route children />

По сути, дочерний и рендеринговый методы одинаковы. Оба они получают функцию, но если вы используете дочерний метод, она будет рендирована, когда путь не совпадает. Также следует убедиться, что вы не используете <switch>.

<Route path="/" exact component={Home} /><Route path="/about" render={() => <About></About>} /><Route path="/portfolio" children={() => <Portfolio></Portfolio>} /><Route path="/contact" children={() => <Contact></Contact>} />

В этом случае, когда пользователи сталкиваются с /, компоненты <Portfolio/> и <Contact/> будут рендированы, поскольку они используют метод рендеринга дочерних элементов. Честно говоря, я не знаю, когда следует использовать этот метод в реальном проекте, но вы можете посмотреть документацию здесь.

Заключение

Это три метода рендеринга маршрута, которые вы можете использовать. Сначала я был озадачен, почему существует так много способов для рендеринга маршрута. После того, как я несколько раз прочитал документацию, наступил момент "AHA".

Я надеюсь, что это было полезно, и, пожалуйста, оставляйте комментарии для вопросов или отзывов! Счастливого кодирования!


Перевод материала подготовлен в рамках курса "React.js Developer". Если вам интересно узнать о курсе подробнее, регистрируйтесь на день открытых дверей онлайн, на нем преподаватель расскажет о формате и программе обучения.

Подробнее..

Чего мне не хватало в функциональных компонентах React.js

12.04.2021 18:13:34 | Автор: admin

За последние годы о React hooks не писал разве что ленивый. Решился и я.

Помню первое впечатление - WOW-эффект. Можно не писать классы. Не нужно описывать тип состояния, инициализировать состояния в конструкторе, теснить всё состояние в одном объекте, помнить о том, как setState сливает новое состояние со старым. Не придется больше насиловать методы componentDidMount и componentWillUnmount запутанной логикой инициализации и освобождения ресурсов.

Вот простой компонент: управляемое текстовое поле и счетчик, который увеличивается на 1 по таймеру и уменьшается на 10 по нажатию кнопки;

import * as React from "react";interface IState {    numValue: number;    strValue: string;}export class SomeComponent extends React.PureComponent<{}, IState> {        private intervalHandle?: number;    constructor() {        super({});        this.state = { numValue: 0, strValue: "" };    }    render() {        const { numValue, strValue } = this.state;        return <div>            <span>{numValue}</span>            <input type="text" onChange={this.onTextChanged} value={strValue} />            <button onClick={this.onBtnClick}>-10</button>        </div>;    }    private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({ strValue: e.target.value });    private onBtnClick = () => this.setState(({ numValue }) => ({ numValue: numValue - 10 }));    componentDidMount() {        this.intervalHandle = setInterval(            () => this.setState(({ numValue }) => ({ numValue: numValue + 1 })),            1000        );    }    componentWillUnmount() {        clearInterval(this.intervalHandle);    }}

превращается в ещё более простой:

import * as React from "react";export function SomeComponent() {    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    React.useEffect(() => {        const intervalHandle = setInterval(() => setNumValue(v => v - 10), 1000);        return () => clearInterval(intervalHandle);    }, []);    const onBtnClick = () => setNumValue(v => v - 10);    const onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => setStrValue(e.target.value);    return <div>        <span>{numValue}</span>        <input type="text" onChange={onTextChanged} value={strValue} />        <button onClick={onBtnClick}>-10</button>    </div>;}

Функциональный компонент не только в два раза короче, он понятнее: функция умещается в один экран, всё перед глазами, конструкции лаконичны и ясны. Красота.

Но в реальном мире далеко не все компоненты получаются такими простыми. Давайте добавим нашему компоненту возможность сигнализировать родителю об изменении числа и строки, а элементы input и button заменим компонентами Input и Button, которые потребуют обернуть обработчики событий хуком useCallback.

interface IProps {    numChanged?: (sum: number) => void;    stringChanged?: (concatRezult: string) => void;}export function SomeComponent(props: IProps) {    const { numChanged, stringChanged } = props;    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    const setNumValueAndCall = React.useCallback((diff: number) => {        const newValue = numValue + diff;        setNumValue(newValue);        if (numChanged) {            numChanged(newValue);        }    }, [numValue, numChanged]);    React.useEffect(() => {        const intervalHandle = setInterval(() => setNumValueAndCall(1), 1000);        return () => clearInterval(intervalHandle);    }, [setNumValueAndCall]);    const onBtnClick = React.useCallback(        () => setNumValueAndCall(- 10),        [setNumValueAndCall]);    const onTextChanged = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {        setStrValue(e.target.value);        if (stringChanged) {            stringChanged(e.target.value);        }    }, [stringChanged]);    return <div>        <span>{numValue}</span>        <Input type="text" onChange={onTextChanged} value={strValue} />        <Button onClick={onBtnClick}>-10</Button>    </div>;}

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

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

А классовый компонент переносит тоже расширение функциональности без осложнений.

export class SomeComponent extends React.PureComponent<IProps, IState> {    private intervalHandle?: number;    constructor() {        super({});        this.state = { numValue: 0, strValue: "" };    }    render() {        const { numValue, strValue } = this.state;        return <div>            <span>{numValue}</span>            <Input type="text" onChange={this.onTextChanged} value={strValue} />            <Button onClick={this.onBtnClick}>-10</Button>        </div>;    }    private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => {        this.setState({ strValue: e.target.value });        const { stringChanged } = this.props;        if (stringChanged) {            stringChanged(e.target.value);        }    }    private onBtnClick = () => this.setNumValueAndCall(- 10);    private setNumValueAndCall(diff: number) {        const newValue = this.state.numValue + diff;        this.setState({ numValue: newValue });        const { numChanged } = this.props;        if (numChanged) {            numChanged(newValue);        }    }    componentDidMount() {        this.intervalHandle = setInterval(            () => this.setNumValueAndCall(1),            1000        );    }    componentWillUnmount() {        clearInterval(this.intervalHandle);    }}

Что же делать? В сложных случаях возвращаться к компонентам-классам? Ну уж нет, нам слишком нравятся возможности, привнесенные хуками.

Предлагаю выносить загромождающие код обработчики в объект класса вместе с зависимостями. Разве так не лучше?

export function SomeComponent(props: IProps) {    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    const { onTextChanged, onBtnClick, intervalEffect } =           useMembers(Members, { props, numValue, setNumValue, setStrValue });    React.useEffect(intervalEffect, []);    return <div>        <span>{numValue}</span>        <Input type="text" onChange={onTextChanged} value={strValue} />        <Button onClick={onBtnClick}>-10</Button>    </div>;}type SetState<T> = React.Dispatch<React.SetStateAction<T>>;interface IDeps {    props: IProps;    numValue: number;    setNumValue: SetState<number>;    setStrValue: SetState<string>;}class Members extends MembersBase<IDeps> {    intervalEffect = () => {        const intervalHandle = setInterval(() => this.setNumValueAndCall(1), 1000);        return () => clearInterval(intervalHandle);    };    onBtnClick = () => this.setNumValueAndCall(- 10);    onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => {        const { props: { stringChanged }, setStrValue } = this.deps;        setStrValue(e.target.value);        if (stringChanged) {            stringChanged(e.target.value);        }    };    private setNumValueAndCall(diff: number) {        const { props: { numChanged }, numValue, setNumValue } = this.deps;        const newValue = numValue + diff;        setNumValue(newValue);        if (numChanged) {            numChanged(newValue);        }    };}

Код компонента снова прост и изящен. Обработчики событий вместе с зависимостями мирно ютятся в классе.

Хук useMembers и базовый класс тривиальны:

export class MembersBase<T> {    protected deps: T;    setDeps(d: T) {        this.deps = d;    }}export function useMembers<D, T extends MembersBase<D>>(ctor: (new () => T), deps:  (T extends MembersBase<infer D> ? D : never)): T {    const ref = useRef<T>();    if (!ref.current) {        ref.current = new ctor();    }    const rv = ref.current;    rv.setDeps(deps);    return rv;}

Код на Github

Подробнее..

Кому с Redux жить хорошо

11.02.2021 14:07:02 | Автор: admin
Приветствую всех любителей хорошей инженерки! Меня зовут Евгений Иваха, я фронтенд-разработчик в команде, занимающейся дев-программой в ManyChat. В рамках дев-программы мы разрабатываем инструменты, позволяющие расширять функциональность ManyChat за счет интеграции со сторонними системами.

Существует мнение, что разработка через тестирование, или по канонам Test Driven Development (TDD) для фронтенда не применима. В данной статье я постараюсь развенчать этот миф и покажу, что это не только возможно, но и очень удобно и приятно.

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



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

Любой фронтенд-компонент продукта сам по себе делится на несколько составляющих. Для простоты можно считать, что в браузерном приложении есть свой фронтенд и свой бэкенд. Первый отвечает за непосредственный пользовательский интерфейс, а второй за бизнес-логику. React нам помогает с интерфейсами, а Redux представляет собой очень удобный инструмент для обработки бизнес-логики.

Поскольку, мы в ManyChat в начале 2020 года полностью перешли на TypeScript, будем писать код сразу с использованием строгой типизации.

Redux


Что такое Redux? Redux это паттерн и библиотека для управления и обновления состоянием приложения с использованием специальных событий, называемых Action. Он предоставляет централизованное хранилище состояния, которое используется во всём приложении с правилами, гарантирующими предсказуемое изменение этого состояния. Если посмотреть на диаграмму потока данных в Redux для приложений на React, мы увидим примерно следующее:



При необходимости изменения состояния, например, при клике на элемент в DOM, вызывается Action creator, который создаёт определенный Action. Этот Action c помощью метода Dispatch отправляется в Store, где он передаётся на обработку в Reducers. Редьюсеры, в свою очередь, на основании текущего состояния и информации, которая находится в экшене, возвращают новое состояние приложения, которое принимает React с помощью Selectors для нового рендера DOM. Более подробно о каждом компоненте Redux будет рассказано ниже по ходу разработки приложения.

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

Задача


Создадим форму принятия какого-то пользовательского соглашения. Форма должна содержать чекбокс, который следует отметить пользователем, в случае, если он принимает соглашение, а так же кнопку Submit, которая становится активной только при активации чекбокса. Выглядеть это должно примерно вот так:



Воспользуемся шаблоном create-react-app:

npx create-react-app my-app --template typescriptcd my-appnpm start

Запустили, убедились, что приложение работает.



Можем остановить сборщик, поскольку он понадобится нам ещё очень не скоро. Можем даже закрыть браузер.

Теперь приступим к самому вкусному подключению Redux в наш проект. Т.к. приложение у нас совершенно пустое, мы не знаем структуру стора, оставим его напоследок.

Установим нужные пакеты:
npm i redux react-redux redux-mock-store @types/redux @types/react-redux @types/redux-mock-store  

Actions


Что такое Action? Это обычный Javascript объект, у которого есть обязательное свойство type, в котором содержится, как правило, осознанное имя экшена. Создатели Redux рекомендуют формировать строку для свойства type по шаблону домен/событие. Также в нём может присутствовать дополнительная информация, которая, обычно, складывается в свойство payload. Экшены создаются с помощью Action Creators функций, которые возвращают экшены.

Раз у нас всего лишь чекбоксы, кажется, достаточно одного экшена, отрабатывающего изменение его состояния, нам нужно только придумать ему название или type.

Напишем первый тест. Для тестирования используем уже ставший стандартным фреймворк Jest. Для запуска тестов в следящем режиме, достаточно в корне проекта выполнить команду npm test.
// actions/actions.test.tsimport { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: 'checkbox/click', payload: checkboxName })  })})

Здесь мы проверяем, Action Creator вернёт экшн с нужным типом и правильными данными, а именно с названием чекбокса. И больше нам здесь нечего проверять.

Само собой, тест у нас красный (сломанный), т.к. код ещё не написан:



Пора написать код:
// actions/package.json{  "main": "./actions"}// actions/actions.tsexport const checkboxClick = (name: string) => ({ type: 'checkbox/click', payload: name })

Проверяем:



Тест пройден, можем приступить к рефакторингу. Здесь мы видим явное дублирование константы с типом экшена, вынесем её в отдельный модуль.
// actionTypes.tsexport const CHECKBOX_CLICK = 'checkbox/click'

Поправим тест:
// actions/actions.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: CHECKBOX_CLICK, payload: checkboxName })  })})

Тест не проходит, потому что мы не использовали относительный путь к actionTypes. Чтобы это исправить, добавим в tsconfig.json в секцию compilerOptions следующий параметр "baseUrl": "src". После этого понадобится перезапустить тесты вручную.

Убедимся, что тест позеленел, теперь поправим сам код:
// actions/actions.tsimport { CHECKBOX_CLICK } from 'actionTypes'export const checkboxClick = (name: string) => ({ type: CHECKBOX_CLICK, payload: name })

Ещё раз убеждаемся, что тест проходит, и можем двигаться дальше.

Reducers


Напишем обработчик события изменения состояния чекбокса, т.е. Reducer. Редьюсеры это специальные чистые функции, которые принимают на вход текущий state и action, решают как нужно изменить состояние, если это требуется, и возвращают новый state. Редьюсеры должны содержать всю бизнес-логику приложения, насколько это возможно.

Хранить состояние чекбоксов (отмечены они или нет) мы будем простым объектом, где ключом будет выступать название чекбокса, а в булевом значении непосредственно его состояние.
{  checkboxName: true}

Приступим. Первый тест будет проверять, что мы получаем исходное состояние, т.е. пустой объект.
// reducers/reducers.test.tsimport { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })})

Т.к. у нас даже нет файла с редьюсером, тест сломан. Напишем код.
// reducers/package.json{  "main": "./reducers"}// reducers/reducers.tsconst initialState: Record<string, boolean> = {}export const checkboxReducer = (state = initialState, action: { type: string }) => {  return state}

Первый тест редьюсера починили, можем написать новый, который уже проверит, что получим в результате обработки экшена с информацией о нажатом чекбоксе.
// reducers/reducers.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })  it('sets checked flag', () => {    const state = checkboxReducer(undefined, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(true)  })})

Минимальный код для прохождения данного теста будет выглядеть следующим образом:
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: true }  }  return state}

Мы убедились, что при обработке экшена, в котором содержится имя чекбокса, в state будет состояние о том, что он отмечен. Теперь напишем тест, который проверит обратное поведение, т.е. если чекбокс был отмечен, то отметка должна быть снята, свойство должно получить значение false.
// reducers/reducers.test.ts  it('sets checked flag to false when it was checked', () => {    const state = checkboxReducer({ anyName: true }, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(false)  })

Убеждаемся, что тест красный, т.к. у нас всегда устанавливается значение в true, ведь до сего момента у нас не было других требований к коду. Исправим это.
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: !state[action.payload] }  }  return state}

Отлично! Мы описали всю необходимую бизнес-логику в тестах, написали код, который этим тестам удовлетворяет.

Selectors


Чтобы получить состояние чекбокса, нам понадобится Selector. Селекторы это функции, которые умеют извлекать требуемую информацию из общего состояния приложения. Если в нескольких частях приложения требуется одинаковая информация, используется один и тот же селектор.

Напишем первый тест для селектора.
// selectors/selectors.test.tsimport { getCheckboxState } from './selectors'describe('getCheckboxState', () => {  const state = {    checkboxes: { anyName: true },  }  it('returns current checkbox state', () => {    const result = getCheckboxState('anyName')(state)    expect(result).toBe(true)  })})

Теперь заставим его позеленеть.

Так как селектор должен знать, откуда извлекать информацию, определим структуру хранения.
// types.tsexport type State = {  checkboxes: Record<string, boolean>}

Теперь напишем код селектора. Здесь используется функция высшего порядка из-за особенностей хука useSelector пакета react-redux, который принимает на вход функцию, принимающую один аргумент текущее состояние стора, а нам требуется сообщить ещё дополнительные параметры название чекбокса.
// selectors/package.json{  "main": "./selectors"}// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name]

Кажется, мы всё сделали правильно, тест теперь зелёный. Но что произойдёт, если у нас ещё не было информации о состоянии чекбокса? Напишем ещё один тест.
// selectors/selectors.test.ts  it('returns false when checkbox state is undefined', () => {    const result = getCheckboxState('anotherName')(state)    expect(result).toBe(false)  })

Получим вот такую картину:



И это правильно, мы получили на выходе undefined, т.е. state ничего не знает об этом чекбоксе. Исправим код.
// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name] ?? false

Вот теперь селектор работает, как и требуется.

Store


Давайте теперь создадим сам Store, т.е. специальный объект Redux, в котором хранится состояние приложения.
// store.tsimport { AnyAction, createStore, combineReducers } from 'redux'import { State } from 'types'import { checkboxReducer } from 'reducers'export const createAppStore = (initialState?: State) =>  createStore<State, AnyAction, unknown, unknown>(    combineReducers({      checkboxes: checkboxReducer,    }),    initialState,  )export default createAppStore()

Этот код мы отдельно тестировать не будем, т.к. мы используем стороннюю библиотеку, которая уже протестирована. Но далее мы напишем интеграционный тест, который проверит всю связку React + Redux.

React Components


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

Для более удобной работы мы написали небольшую утилиту для тестов. В ней несколько больше функциональности, чем требуется для нашего первого теста, но далее мы всё это применим. Используем удобную библиотеку react-test-renderer, которая позволяет не производить рендер в настоящий DOM, а получать его JS представление. Установим пакет:
npm i react-test-renderer @types/react-test-renderer

Приступим к написанию тестов на компоненты. Начнём непосредственно с чекбокса.

Checkbox


// components/Checkbox/Checkbox.test.tsximport { create } from 'react-test-renderer'import Checkbox from '.'describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = create(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })})

Первый тест компонента проверяет, что внутри Checkbox рендерится стандартный input с типом checkbox.

Сделаем тест зелёным.
// components/Checkbox/package.json{  "main": "Checkbox"}// components/Checkbox/Checkbox.tsximport React from 'react'const Checkbox: React.FC = () => {  return (    <div>      <input type="checkbox" />    </div>  )}export default Checkbox

Отлично, теперь добавим свойство label, содержащее текст для html элемента label, который должен отображаться рядом с чекбоксом.
// components/Checkbox/Checkbox.test.tsxit('renders label', () => {    const labelText = 'anyLabel'    const renderer = create(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })

Заставим тест пройти.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ label: string }> = ({ label }) => {  return (    <div>      <input type="checkbox" />      <label>{label}</label>    </div>  )}

Осталась небольшая деталь чекбокс как-то должен себя идентифицировать, кроме того, для корректной работы клика по label, нужно прописать id чекбокса в свойство htmlFor. Напишем тест, проверяющий установку свойства id:
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })

Убедившись, что он красный, исправим код:
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label>{label}</label>    </div>  )}

Тест зеленый, можем написать ещё один, который проверит установку свойства name в свойство htmlFor элемента label.
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })

Тест красный, нужно снова поправить код.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label htmlFor={name}>{label}</label>    </div>  )}

Пора бы подключить Store к компоненту. Напишем тест, который покажет, что состояние чекбокса (свойство checked) соответствует тому, что хранится в Store.
// components/Checkbox/Checkbox.test.tsximport { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import Checkbox from '.'// omit old code  it('sets checked flag from store when it`s checked', () => {    const store = createAppStore({ checkboxes: { anyName: true } })    const renderer = create(      <Provider store={store}>        <Checkbox name="anyName" label="anyLabel" />      </Provider>,    )    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })

Тест пока красный, т.к. компонент ничего не знает о сторе. Заставим тест позеленеть.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const checked = useSelector(getCheckboxState(name))  return (    <div>      <input id={name} type="checkbox" checked={checked} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

Тест пройден. Наконец-то, мы задействовали Redux! Мы использовали ранее написанный селектор getCheckboxState, который вызвали с помощью хука useSelector, получили значение и передали его в свойство checked элемента input. Но сейчас произошла другая проблема сломались остальные тесты на компонент.



Дело в том, что ранее в тестах мы не передавали стор в компонент. Выделим часть с провайдером стора в функцию и перепишем наши тесты.
// components/Checkbox/Checkbox.test.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import { State } from 'types'import Checkbox from '.'export const renderWithRedux = (node: ReactElement, initialState: State = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = renderWithRedux(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })  it('renders label', () => {    const labelText = 'anyLabel'    const renderer = renderWithRedux(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })  it('sets checked flag from store when it`s checked', () => {    const initialState = { checkboxes: { anyName: true } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })})

Функция renderWithRedux выглядит достаточно полезной, вынесем её в отдельный модуль и импортируем в тестах.
// utils.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { Store } from './types'import { createAppStore } from './store'export const renderWithRedux = (node: ReactElement, initialState: Store = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}

В итоге, шапка тестового файла будет выглядеть вот так:
// components/Checkbox/Checkbox.test.tsximport { renderWithRedux } from 'utils'import Checkbox from '.'describe('Checkbox', () => {

Для полной уверенности напишем ещё один тест, который проверит, что checked бывает и false.
// components/Checkbox/Checkbox.test.tsx  it('sets checked flag from store when it`s unchecked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(false)  })

Тест пройден, но у нас теперь появилось два теста с похожими описаниями и почти идентичным кодом, давайте немного модифицируем наши тесты, создав табличный тест. Последние два теста превратятся в один:
// components/Checkbox/Checkbox.test.tsx  test.each`    storedState | state    ${true}     | ${'checked'}    ${false}    | ${'unchecked'}  `('sets checked flag from store when it`s $state', ({ storedState }) => {    const initialState = { checkboxes: { anyName: storedState } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(storedState)  })

Так уже лучше. А теперь самое вкусное напишем интеграционный тест, который проверит, что при нажатии на чекбокс, он изменит своё состояние, т.е. свойство checked.
// components/Checkbox/Checkbox.test.tsximport { act } from 'react-test-renderer'// omit old code    it('changes it`s checked state when it`s clicked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    act(() => {      element.props.onChange()    })    expect(element.props.checked).toBe(true)  })

Здесь мы воспользовались функцией act, пакета react-test-renderer, выполняя которую, мы убеждаемся в том, что все сайд-эффекты уже произошли и мы можем продолжить проверки. И далее проверяем, что когда будет вызвано событие onChange на нашем чекбоксе, он изменит свойство checked на true. Пока этого не происходит, требуется написать код. Окончательный вариант компонента примет вот такой вид.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useDispatch, useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'import { checkboxClick } from 'actions'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const dispatch = useDispatch()  const checked = useSelector(getCheckboxState(name))  const handleClick = React.useCallback(() => {    dispatch(checkboxClick(name))  }, [dispatch, name])  return (    <div>      <input id={name} type="checkbox" checked={checked} onChange={handleClick} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

В коде мы навесили обработчик на событие change, который отправляет action в store, создаваемый функцией checkboxClick. Как видим, тест позеленел. Не открывая браузера и даже не запуская сборку приложения, мы имеем протестированный компонент с отдельным слоем бизнес-логики, заключенной в Redux.

AgreementSubmitButton


Нам требуется ещё один компонент непосредственно кнопка Submit, создадим его. Конечно, вначале тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsximport { renderWithRedux } from 'utils'import AgreementSubmitButton from '.'describe('AgreementSubmitButton', () => {  it('renders button with label Submit', () => {    const renderer = renderWithRedux(<AgreementSubmitButton />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('button')    expect(element.props.value).toBe('Submit')  })})

Теперь заставим тест позеленеть:
// components/AgreementSubmitButton/package.json{  "main": "./AgreementSubmitButton"}// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'const AgreementSubmitButton: React.FC = () => {  return <input type="button" value="Submit" />}export default AgreementSubmitButton

Тест зелёный, начало положено. Напишем новый тест, проверяющий зависимость свойства disabled новой кнопки от состояния чекбокса. Т.к. может быть два состояния, вновь используем табличный тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsx  test.each`    checkboxState | disabled | agreementState    ${false}      | ${true}  | ${'not agreed'}    ${true}       | ${false} | ${'agreed'}  `(    'render button with disabled=$disabled when agreement is $agreementState',    ({ checkboxState, disabled }) => {      const initialState = { checkboxes: { agree: checkboxState } }      const renderer = renderWithRedux(<AgreementSubmitButton />, initialState)      const element = renderer.root.findByType('input')      expect(element.props.disabled).toBe(disabled)    },  )

Имеем двойной красный тест, напишем код для прохождения этого теста. Компонент станет выглядеть вот так:
// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors/selectors'const AgreementSubmitButton: React.FC = () => {  const checkboxName = 'agree'  const agreed = useSelector(getCheckboxState(checkboxName))  return <input type="button" value="Submit" disabled={!agreed} />}export default AgreementSubmitButton

Ура, все тесты зелёные!
Следует обратить внимание, что в табличном тесте мы намеренно использовали два различных параметра checkboxState и disabled, хотя может показаться, что достаточно только первого, а в тесте написать вот так expect(element.props.disabled).toBe(!disabled). Но это плохой паттерн закладывать какую-то логику внутри тестов. Вместо этого мы явно описываем входные и выходные параметры. Так же, мы здесь немного ускорились, т.к., фактически написали два теста за раз. Такое допустимо, когда чувствуешь в себе силы и понимаешь, что реализация достаточно очевидная. Когда уровень владения TDD ещё не совершенный, лучше создавать по одному тесту за раз. В нашем случае это писать по одной строчке в таблице.

LicenseAgreement


Оформим нашу работу в то, ради чего мы всё это затевали в форму принятия лицензионного соглашения. Какие имеются требования к форме:
  1. Содержится заголовок и непосредственно текст лицензионного соглашения. Эта часть компонента не требует тестирования.
  2. На форме имеется компонент Checkbox с определенными label и name. Это можно и нужно тестировать.
  3. На форме имеется кнопка AgreementSubmitButton. Это тоже прекрасно поддаётся тестированию.

Приступим, первый тест на то, что на форме есть Checkbox:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })})

На что тут стоит обратить внимание мы использовали тестовый дублёр для компонента Checkbox в строчке jest.mock('components/Checkbox', () => () => null). Это делает наш тест изолированным, таким образом он не зависит от реализации Checkbox, возможные ошибки в этом компоненте не повлияют на результат выполнения данного теста. Дополнительно это экономит вычислительные ресурсы и время выполнения тестов. Тест красный, требуется написать правильный код:
// components/LicenseAgreement/package.json{  "main": "./LicenseAgreement"}// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />    </div>  )}export default LicenseAgreement

Получили зеленый тест, можем написать второй для этого компонента. Файл с тестами изменится:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)jest.mock('components/AgreementSubmitButton', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })  it('renders SubmitAgreementButton', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    expect(() => renderer.root.findByType(AgreementSubmitButton)).not.toThrow()  })})

Чтобы он позеленел, добавим AgreementSubmitButton в компонент:
// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />      <AgreementSubmitButton />    </div>  )}export default LicenseAgreement

Заметим, что Checkbox и AgreementSubmitButton не зависят друг от друга. Каждый компонент зависит только от стора и больше ни от чего.

Ключ на старт!


Вставим над компонентами сам текст соглашения, далее можем добавлять компонент в приложение. В сгенерированном приложении имеется корневой компонент App, модифицируем его тесты на проверку рендера LicenseAgreement:
// App.test.tsximport { renderWithRedux } from 'utils'import LicenseAgreement from 'components/LicenseAgreement'import App from 'App'jest.mock('components/LicenseAgreement', () => () => null)test('renders LicenseAgreement', () => {  const renderer = renderWithRedux(<App />)  expect(() => renderer.root.findByType(LicenseAgreement)).not.toThrow()})

Заставим тест позеленеть:
// App.tsximport React from 'react'import LicenseAgreement from 'components/LicenseAgreement'const App: React.FC = () => {  return <LicenseAgreement />}export default App

Мы получили зелёный тест, можно, наконец, запустить сборку приложения с помощью npm start. Сборка пройдёт успешно, но в браузере мы увидим следующую картину:



Это говорит о том, что мы не подключили Redux store в само приложение. Сделаем это в файле index.tsx:
// index.tsximport React from 'react'import ReactDOM from 'react-dom'import { Provider } from 'react-redux'import 'index.css'import store from 'store'import App from 'App'ReactDOM.render(  <React.StrictMode>    <Provider store={store}>      <App />    </Provider>  </React.StrictMode>,  document.getElementById('root'),)

Теперь приложение запускается, всё работает, как ожидается, кроме внешнего вида:



Исправим это, поправив вёрстку, и получим конечный результат:



Заключение


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

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

Исходные коды полученного приложения доступны на GitHub.

Дополнительные источники информации:
Подробнее..

Машинное обучение. Нейронные сети (часть 2) Моделирование OR XOR с помощью TensorFlow.js

25.08.2020 22:13:11 | Автор: admin
Статья является продолжением цикла статей, посвященных машинному обучению с использованием библиотеки TensorFlow.JS, в предыдущей статье приведены общая теоретическая часть обучения простейшей нейронной сети, состоящей из одного нейрона:
Машинное обучение. Нейронные сети (часть 1): Процесс обучения персептрона

В данной же статье мы с помощью нейронной сети смоделируем выполнение логических операций OR; XOR, которые являются своеобразным Hello World приложением для нейронных сетей.
В статье будет последовательно описан процесс такого моделирования с использованием TensorFlow.js.

Итак построим нейронную сеть для логической операции ИЛИ. На вход мы будем всегда подавать два сигнала X1 и X2, а на выходе будем получать один выходной сигнал Y. Для обучения нейронный сети нам также потребуется тренировочный набор данных (рисунок 1).

Рисунок 1 Тренировочный набор данных и модель для моделирования логической операции ИЛИ

Чтобы понять какую структуру нейронной сети задать, давайте представим тренировочный набор данных на координатной плоскости с осями X1 и X2 (рисунок 2, слева).

Рисунок 2 Тренировочный набор на координатной плоскости для логической операции ИЛИ

Обратите внимание, что для решения этой задачи нам достаточно провести линию, которая разделяла бы плоскость таким образом, чтобы по одну сторону линии были все TRUE значения, а по другую все FALSE значения (рисунок 2, справа). Мы также знаем, что с этой целью прекрасно может справиться один нейрон в нейронной сети (персептрон), выходное значение которое по входным сигналам вычисляется как:

$y=x_1w_1+x_2w_2$

что является математической записью уравнения прямой.

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

Рисунок 3 Нейронная сеть для обучения логической операции ИЛИ

Итак решим данную задачу с помощью TensorFlow.js
Для начала нам надо тренировочный набор данных преобразовать в тензоры. Тензор это контейнер данных, который может иметь $N$ осей и произвольное число элементов вдоль каждой из осей. Большинство с тензорами знакомы с математики векторы (тензор с одной осью), матрицы (тензор с двумя осями строки, колонки).
Для задания тренировочного набора данных первая ось (axis 0) это всегда ось вдоль которой располагаются все находящиеся в наличии экземпляры выборок данных (рисунок 4).

Рисунок 4 Структура тензора

В нашем конкретном случае мы имеем 4 экземпляра выборок данных (рисунок 1), значит входной тензор вдоль первой оси будет иметь 4 элемента. Каждый элемент тренировочной выборки представляет собой вектор, состоящий из двух элементов X1, X2. Таким образом, входной тензор имеет 2 оси (матрица), вдоль первой оси расположено 4 элемента, вдоль второй оси 2 элемента.
const input = [[0, 0], [1, 0], [0, 1], [1, 1]];const inputTensor = tf.tensor(input, [input.length, 2]);

Аналогично, преобразуем выходные данные в тензор. Как и для входных сигналов, вдоль первой оси имеем 4 элемента, а в каждом элементе располагается вектор, содержащий одно значение:
const output = [[0], [1], [1], [1]]const outputTensor = tf.tensor(output, [output.length, 1]);

Создадим модель, используя TensorFlow API:
const model = tf.sequential();model.add(      tf.layers.dense({ inputShape: [2], units: 1, activation: 'sigmoid' }));

Создание модели всегда будет начинаться с вызова tf.sequential(). Основным строительным блоком модели это слои. Мы можем подключать к модели столько слоев в нейронную сеть, сколько нам надо. Тут мы используем dense слой, что означает что каждый нейрон последующего слоя имеет связь с каждым нейроном предыдущего слоя. Например, если у нас есть два dense слоя, в первом слое $N$ нейронов, а во втором $M$, то общее число соединений между слоями будет $NM$.
В нашем случае как видим нейронная сеть состоит из одного слоя, в котором один нейрон, поэтому units задан единице.
Также для первого слоя нейронной сети мы обязательно должны задать inputShape, так как у нас каждый входной экземпляр представлен вектором из двух значений X1 и X2, поэтому inputShape=[2]. Обратите внимание, что задавать inputShape для промежуточных слоев нет необходимости TensorFlow может определить эту величину по значению units предыдущего слоя.
Также каждому слою в случае необходимости можно задать активационную функцию, мы определились выше, что это будет сигмоидная функция. Доступные на данных момент активационные функции в TensorFlow можно найти здесь.

Далее нам надо откомпилировать модель (см АПИ здесь), при этом нам надо задать два обязательных параметра это функция-ошибки и вид оптимизатора, который будет искать ее минимум:
model.compile({    optimizer: tf.train.sgd(0.1),    loss: 'meanSquaredError'});

Мы задали в качестве оптимизатора stochastic gradient descent с обучающим шагом 0.1.
Список реализованных оптимизаторов в библиотеке: tf.train.sgd, tf.train.momentum, tf.train.adagrad, tf.train.adadelta, tf.train.adam, tf.train.adamax, tf.train.rmsprop.
На практике по умолчанию сразу можно выбирать adam оптимизатор, который имеет лучшие показатели сходимости модели, в отличии от sgd обучающий шаг (learning rate) на каждом этапе обучения задается в зависимости от истории предыдущих шагов и не является постоянным на протяжении всего процесса обучения.

В качестве функции ошибки задана функцией среднеквадратичной ошибки:

$L=\frac{1}{N}\sum_{i=1}^{N}\left(y_{predicted(i)}-y_{expected(i)}\right)^2$


Модель задана, и следующим шагом является процесс обучения модели, для этого у модели должен быть вызван метод fit:
async function initModel() {    // skip for brevity    await model.fit(trainingInputTensor, trainingOutputTensor, {        epochs: 1000,        shuffle: true,        callbacks: {            onEpochEnd: async (epoch, { loss }) => {                // any actions on during any epoch of training                await tf.nextFrame();            }        }    })}

Мы задали, что процесс обучения должен состоять из 100 обучающих шагов (количество эпох обучений); также на каждой очередной эпохе входные данные следует перетасовать в произвольном порядке (shuffle=true) что ускорит процесс сходимости модели, так в нашем тренировочном наборе данных мало экземпляров (4).
После завершения процесса обучения мы можем использовать predict метод, который по новым входным сигналам, будет вычислять выходное значение.
const testInput = generateInputs(10);const testInputTensor = tf.tensor(testInput, [testInput.length, 2]);const output = model.predict(testInputTensor).arraySync();

Метод generateInputs просто создает набор тестовых данных с количеством элементов 10x10, которые делят координатную плоскость на 100 квадратов:

$[[0,0], [0, 0.1], [0, 0.2], . [1, 1]]$



Полный код приведен тут
import React, { useEffect, useState } from 'react';import LossPlot from './components/LossPlot';import Canvas from './components/Canvas';import * as tf from "@tensorflow/tfjs";let model;export default () => {    const [data, changeData] = useState([]);    const [lossHistory, changeLossHistory] = useState([]);    useEffect(() => {        async function initModel() {            const input = [[0, 0], [1, 0], [0, 1], [1, 1]];            const inputTensor = tf.tensor(input, [input.length, 2]);            const output = [[0], [1], [1], [1]]            const outputTensor = tf.tensor(output, [output.length, 1]);            const testInput = generateInputs(10);            const testInputTensor = tf.tensor(testInput, [testInput.length, 2]);            model = tf.sequential();            model.add(            tf.layers.dense({ inputShape:[2], units:1, activation: 'sigmoid'})            );            model.compile({                optimizer: tf.train.adam(0.1),                loss: 'meanSquaredError'            });            await model.fit(inputTensor, outputTensor, {                epochs: 100,                shuffle: true,                callbacks: {                    onEpochEnd: async (epoch, { loss }) => {                        changeLossHistory((prevHistory) => [...prevHistory, {                            epoch,                            loss                        }]);                        const output = model.predict(testInputTensor)                                                       .arraySync();                        changeData(() => output.map(([out], i) => ({                            out,                            x1: testInput[i][0],                            x2: testInput[i][1]                        })));                        await tf.nextFrame();                    }                }            })        }        initModel();    }, []);    return (        <div>            <Canvas data={data} squareAmount={10}/>            <LossPlot loss={lossHistory}/>        </div>    );}function generateInputs(squareAmount) {    const step = 1 / squareAmount;    const input = [];    for (let i = 0; i < 1; i += step) {        for (let j = 0; j < 1; j += step) {            input.push([i, j]);        }    }    return input;}


На следующем рисунке вы увидите частично процесс обучения:


Реализация в планкере:



Моделирование логической операции XOR
Тренировочный набор для данной функции приведен на рисунке 6, а также расставим эти точки также как делали для логической операции ИЛИ на координатной плоскости


Рисунок 6 Тренировочный набор данных и модель для моделирования логической операции ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR)

Обратите внимание, что в отличии от логической операции ИЛИ вы не сможете разделить плоскость одной прямой линией, чтобы по одну сторону находились все TRUE значения, а по другую сторону все FALSE. Однако, мы это можем сделать с помощью двух кривых (рисунок 7).
Очевидно, что в данном случае одним нейроном в слое не обойтись нужен как минимум дополнительно еще один слой с двумя нейронами, каждый из которых определил бы одну из двух линий на плоскости.


Рисунок 7 Модель нейронной сети для логической операции ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR)

В прошлом коде нам необходимо сделать изменения в нескольких местах, одни из которых это непосредственно сам тренировочный набор данных:
const input = [[0, 0], [1, 0], [0, 1], [1, 1]];const inputTensor = tf.tensor(input, [input.length, 2]);const output = [[0], [1], [1], [0]]const outputTensor = tf.tensor(output, [output.length, 1]);

Вторым местом это изменившаяся структура модели, согласно рисунку 7:
model = tf.sequential();model.add(    tf.layers.dense({ inputShape: [2], units: 2, activation: 'sigmoid' }));model.add(    tf.layers.dense({ units: 1, activation: 'sigmoid' }));

Процесс обучения в этом случае выглядит так:


Реализация в планкере:



Тема следующей статьи:
В следующей статье мы опишем каким образом решать задачи, связанных с классификацией объектов по категориям, базируясь на списке каких-то признаков.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru