Как сделать поиск пользователей по GitHub используя React + RxJS 6 + Recompose

https://medium.freecodecamp.org/how-to-build-a-github-search-in-react-with-rxjs-6-and-recompose-e9c6cc727e7f
  • Перевод

Картинка для привлечения внимания


Эта статья рассчитана на людей имеющих опыт работы с React и RxJS. Я всего лишь делюсь шаблонами, которые я посчитал полезными для создания такого UI.


Вот что мы делаем:



Без классов, работы с жизненным циклом или setState.


Подготовка


Все что нужно лежит в моем репозитории на GitHub.


git clone https://github.com/yazeedb/recompose-github-ui
cd recompose-github-ui
yarn install

В ветке master находится готовый проект. Переключитесь на ветку start если вы хотите продвигаться по шагам.


git checkout start

И запустите проект.


npm start

Приложение должно запуститься по адресу localhost:3000 и вот наш начальный UI.



Запустите ваш любимый редактор и откройте файл src/index.js.



Recompose


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


Это как Lodash/Ramda, только для React.


Так же я очень рад что она поддерживает паттерн Observer. Цитируя документацию:


Получается что большая часть React Component API может быть выраженная в терминах паттерна Observer

Сегодня мы поупражняемся с этой концепцией!


Поточный компонент


Пока что у нас App — самый обычный React компонент. Использую функцию componentFromStream из библиотеки Recompose мы можем получать его через observable объект.


Функция componentFromStream запускает рендер при каждом новом значении из нашего observable. Если значений еще нет, она рендрит null.


Конфигурирование


Потоки в Recompose следуют документу ECMAScript Observable Proposal. В нем описано, как должны работать объекты Observable когда они будут реализованы в современных браузерах.


А пока что мы будем использовать библиотеки такие как RxJS, xstream, most, Flyd и т.д.


Recompose не знает, какую библиотеку мы используем, поэтому она предоставляет функцию setObservableConfig. С её помощью можно преобразовать все что нам нужно в ES Observable.


Создайте новый файл в папке src и назовите его observableConfig.js.


Что бы подключить RxJS 6 к Recompose, напишите в нем следующий код:


import { from } from 'rxjs';
import { setObservableConfig } from 'recompose';
setObservableConfig({
  fromESObservable: from
});

Импортируйте этот файл в index.js:


import './observableConfig';

C этим все!


Recompose + RxJS


Добавьте импорт componentFromStream в index.js:


import { componentFromStream } from 'recompose';

Начнем переопределение компонента App:


const App = componentFromStream(prop$ => {
  ...
});

Обратите внимание что componentFromStream принимает в качестве аргумента функцию с параметром prop$, который является observable версией props. Идея в том, что бы используя map превращать обычные props в React компоненты.


Если вы использовали RxJS, вы должны быть знакомы с оператором map.


Map


Как следует из названия, map превращает Observable(something) в Observable(somethingElse). В нашем случае — Observable(props) в Observable(component).


Имортируйте оператор map:


import { map } from 'rxjs/operators';

Дополним наш компонент App:


const App = componentFromStream(prop$ => {
  return prop$.pipe(
    map(() => (
      <div>
        <input placeholder="GitHub username" />
      </div>
    ))
  )
});

С RxJS 5 мы используем pipe вместо цепочки операторов.


Сохраните файл и проверьте результат. Ничего не изменилось!



Добавляем обработчик событий


Сейчас мы сделаем наше поле ввода немножечко реактивным.


Добавьте импорт createEventHandler:


import { componentFromStream, createEventHandler } from 'recompose';

Использовать будем так:


const App = componentFromStream(prop$ => {
  const { handler, stream } = createEventHandler();
  return prop$.pipe(
    map(() => (
      <div>
        <input
          onChange={handler}
          placeholder="GitHub username"
        />
      </div>
    ))
  )
});

Объект, созданных createEventHandler, имеет два интересных поля: handler и stream.


Под капотом handler — источник событий (event emiter), который передает значения в stream. А stream в свою очередь, является объектом observable, передает значения подписчикам.


Мы свяжем между собой stream и prop$ для получения текущего значения поля ввода.


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


Проблема яйца и курицы


Что бы использовать combineLatest, и stream и prop$ должны выпускать значения. Но stream не будет ничего выпускать пока какое нибудь значение не выпустит prop$ и наоборот.


Исправить это можно задав stream начальное значение.


Ипортируйте оператор startWith из RxJS:


import { map, startWith } from 'rxjs/operators';

Создайте новую переменную для получения значения из обновленного stream:


// App component
const { handler, stream } = createEventHandler();
const value$ = stream.pipe(
  map(e => e.target.value)
  startWith('')
);

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


А поскольку значение по-умолчанию для поля ввода — пустая строка, инициализируем объект value$ значением '' .


Связываем вместе


Теперь мы готовы связать оба потока. Импортируйте combineLatest как метод создания объектов Observable, не как оператор.


import { combineLatest } from 'rxjs';

Вы также можете импортировать оператор tap для изучения входящих значений.


import { map, startWith, tap } from 'rxjs/operators';

Используйте его вот так:


const App = componentFromStream(prop$ => {
  const { handler, stream } = createEventHandler();
  const value$ = stream.pipe(
    map(e => e.target.value),
    startWith('')
  );
  return combineLatest(prop$, value$).pipe(
    tap(console.warn), // <--- вывод приходящих значений в консоль
    map(() => (
      <div>
        <input
          onChange={handler}
          placeholder="GitHub username"
        />
      </div>
    ))
  )
});

Сейчас, если вы начнете вводить что-то в наше поле ввода, в консоле будет появляться значения [props, value].



Компонент User


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


JSX/CSS


Все это основано на замечательном проекте GitHub Cards. Большинство кода, особенно стили, скопированы или адаптированны.


Создайте папку src/User. Создайте в ней файл User.css и скопируйте в него этот код.


А этот код скопируйте в файл src/User/Component.js .


Этот компонент просто заполняет шаблон данными от вызова к GitHub API.


Контейнер


Сейчас этот компонент "тупой" и нам с ним не по пути, давайте сделаем "умный" компонент.


Вот src/User/index.js


import React from 'react';
import { componentFromStream } from 'recompose';
import {
  debounceTime,
  filter,
  map,
  pluck
} from 'rxjs/operators';
import Component from './Component';
import './User.css';
const User = componentFromStream(prop$ => {
  const getUser$ = prop$.pipe(
    debounceTime(1000),
    pluck('user'),
    filter(user => user && user.length),
    map(user => (
      <h3>{user}</h3>
    ))
  );
  return getUser$;
});
export default User;

Мы определили User как componentFromStream, который возвращает Observable объект prop$ конвертирующий входящие свойства в <h3>.


debounceTime


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


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


pluck


Мы ожидаем что объект user будет передан как props.user. Оператор pluck забирает указанное поле из объекта и возвращает его значение.


filter


Тут мы убедимся что user передан и не является пустой строкой.


map


Делаем из user тег <h3>.


Подключаем


Вернёмся в src/index.js и импортируем компонент User:


import User from './User';

Передадим значение value как параметр user:


  return combineLatest(prop$, value$).pipe(
    tap(console.warn),
    map(([props, value]) => (
      <div>
        <input
          onChange={handler}
          placeholder="GitHub username"
        />
        <User user={value} />
      </div>
    ))
  );

Теперь наше значение выводится на экран с задержкой в одну секунду.



Неплохо, теперь надо получать информацию о пользователе.


Запрос данных


GitHub предоставляет API для получения информации о пользователе: https://api.github.com/users/${user}. Мы легко можем написать вспомогательную функцию:


const formatUrl = user => `https://api.github.com/users/${user}`;

А теперь мы можем добавить map(formatUrl) после filter:


const getUser$ = prop$.pipe(
    debounceTime(1000),
    pluck('user'),
    filter(user => user && user.length),
    map(formatUrl), // <-- Вот сюда
    map(user => (
      <h3>{user}</h3>
    ))
  );

И теперь вместо имени пользователя на экран выводит URL.


Нам нужно сделать запрос! На помощь приходят switchMap и ajax.


switchMap


Этот оператор идеально подходит для переключения между несколькими observable.


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


Что произойдет если пользователь введет что-то еще до того как придет ответ от API? Следует ли нам беспокоится о предыдущих запросах?


Нет.


Оператор switchMap отменит старый запрос и переключится на новый.


ajax


RxJS предоставляет собственную реализацию ajax которая прекрасно работает со switchMap!


Пробуем


Импортируем оба оператора. Мой код выглядит так:


import { ajax } from 'rxjs/ajax';
import {
  debounceTime,
  filter,
  map,
  pluck,
  switchMap
} from 'rxjs/operators';

И используем их так:


const User = componentFromStream(prop$ => {
  const getUser$ = prop$.pipe(
    debounceTime(1000),
    pluck('user'),
    filter(user => user && user.length),
    map(formatUrl),
    switchMap(url =>
      ajax(url).pipe(
        pluck('response'),
        map(Component)
      )
    )
  );
  return getUser$;
});

Оператор switchMap переключается с нашего поля ввода на AJAX запрос. Когда приходит ответ, он передает его нашему "тупому" компоненту.


И вот результат!



Обработка ошибок


Попробуйте ввести несуществующее имя пользователя.



Наше приложение сломано.


catchError


С оператором catchError мы можем вывести на экран вменяемый ответ, вместо того что бы тихо сломаться.


Импортируем:


import {
  catchError,
  debounceTime,
  filter,
  map,
  pluck,
  switchMap
} from 'rxjs/operators';

И вставим его в конец нашего AJAX запроса:


switchMap(url =>
  ajax(url).pipe(
    pluck('response'),
    map(Component),
    catchError(({ response }) => alert(response.message))
  )
)


Уже неплохо, но конечно можно сделать лучше.


Компонент Error


Создадим файл src/Error/index.js с содержимым:


import React from 'react';

const Error = ({ response, status }) => (
  <div className="error">
    <h2>Oops!</h2>
    <b>
      {status}: {response.message}
    </b>
    <p>Please try searching again.</p>
  </div>
);

export default Error;

Он красиво отобразит response и status нашего AJAX запроса.


Импортируем его в User/index.js, а заодно и оператор of из RxJS:


import Error from '../Error';
import { of } from 'rxjs';

Помните что функция переданная в componentFromStream должна возвращать observable. Мы можем добиться этого используя оператор of:



ajax(url).pipe(
  pluck('response'),
  map(Component),
  catchError(error => of(<Error {...error} />))
) 

Сейчас наш UI выглядит гораздо лучше:



Индикатор загрузки


Пора ввести управление состоянием. Как еще можно реализовать индикатор загрузки?


Что если место setState мы будем использовать BehaviorSubject?


Документация Recompose предлагает следующее:


Вместо setState() объедините несколько потоков

Ок, нужно два новых импорта:


import { BehaviorSubject, merge, of } from 'rxjs';

Объект BehaviorSubject будет содержать статус загрузки, а merge свяжет его с компонентом.


Внутри componentFromStream:


const User = componentFromStream(prop$ => {
  const loading$ = new BehaviorSubject(false);
  const getUser$ = ...

Объект BehaviorSubject инициализируется начальным значением, или "состоянием". Раз мы ничего не делаем, до тех пор пока пользователь не начнет вводить текст, инициализируем его значением false.


Мы будем менять состояние loading$ используя оператор tap:


import {
  catchError,
  debounceTime,
  filter,
  map,
  pluck,
  switchMap,
  tap // <---
} from 'rxjs/operators';

Использовать его будем так:


const loading$ = new BehaviorSubject(false);
const getUser$ = prop$.pipe(
  debounceTime(1000),
  pluck('user'),
  filter(user => user && user.length),
  map(formatUrl),
  tap(() => loading$.next(true)), // <---
  switchMap(url =>
    ajax(url).pipe(
      pluck('response'),
      map(Component),
      tap(() => loading$.next(false)), // <---
      catchError(error => of(<Error {...error} />))
    )
  )
);  

Сразу перед switchMap и AJAX запросом мы передаем в loading$ значение true, а после успешного ответа — false.


И сейчас мы просто соединяем loading$ и getUser$.


return merge(loading$, getUser$).pipe(
  map(result => (result === true ? <h3>Loading...</h3> : result))
);

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


import {
  catchError,
  debounceTime,
  delay,
  filter,
  map,
  pluck,
  switchMap,
  tap
} from 'rxjs/operators'; 

Добавим delay перед map(Component):


ajax(url).pipe(
  pluck('response'),
  delay(1500),
  map(Component),
  tap(() => loading$.next(false)),
  catchError(error => of(<Error {...error} />))
)   

Результат?



Все :)

  • +11
  • 3,1k
  • 8
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 8
    +1
    Понимаю что это лишь перевод, но не могу не отметить, что автор похоже садист и мазохист в одном лице.
      +3
      Минуснувшему, может вы мне тогда расскажете зачем так сложно решать такую простую задачу, а потом еще рассказывать об этом всем остальным? Слышали про принцип KISS и что код должен быть понятным и поддерживаемым? Так вот, это не такой код.
        –1
        Я не минусовал, но объясню почему. Понятное дело что для таких простых случаев вообще достаточно чистого JavaScript. Вы бы еще придрались что получившиеся приложение абсолютно бесполезно.
          +1
          Не согласен. Суть не в простоте кейса, а в том, что его без труда можно сделать как на чистом React, так и на чистом Rx. При этом код будет понятнее и чище. Смысл их связки, да ещё и Recompose вообще не раскрыта.

          Как мне кажется, если автор предлагает использовать инструменты вместе, то должен был потрудиться показать такой кейс, где они реально полезны друг другу. Иначе это выглядит не более чем нецелесообразное усложнение.
            0
            Вы бы не могли подробнее описать плюсы подхода со стримами. Я вижу его в ng6 а теперь в Реакте но не понимаю его бенефитов. Задачу с поиском обычно решаю так.

            1. Описываем state
            const state = {availableOptions: []}
            2. тупой компонент который внутри имеет debounce и отдает term наружу в onChange event.

            3. fetchData просто async экшен который заполняет стейт

            Это дает разделение логики фетча от компоненты и состояния. По факту у нас тупая компонента, фетч можно делать при помощи чего угодно (thunk, saga).

              –1
              Польза от такого подхода может проявится когда у вас не 2-3 входящих источника информации, а скажем, 100. Тогда использование observable и операций над ними может сильно упростить код.
          +1
          Полностью согласен
          0
          Не могу не заметить, глядя на картинку к статье.
          На картинке к статье Линус Торвальдс, который держит исходники ядра Linux на гитхабе, который принадлежит Microsoft.
          Теперь Linux принадлежит Microsoft!

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

          Самое читаемое