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

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

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

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

ee238ffb7d6fedeaad3fe94a7d48f662.gif

Без классов, работы с жизненным циклом или 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.

ea208dd1132f4741a9e2dc11254d58fd.png

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

d84a0082b40436e76d20168ac85f7486.png


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(() => (
      
)) ) });

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

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

4dd79548478c954c7a4c63a9fe6eca2a.png


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

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

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

import { componentFromStream, createEventHandler } from 'recompose';

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

const App = componentFromStream(prop$ => {
  const { handler, stream } = createEventHandler();
  return prop$.pipe(
    map(() => (
      
)) ) });

Объект, созданных 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(() => (
      
)) ) });

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

ba67491e04f751ddf4ef69c1811633a5.png


Компонент 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 => (
      

{user}

)) ); return getUser$; }); export default User;

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

.


debounceTime

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

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


pluck

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


filter

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


map

Делаем из user тег

.


Подключаем

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

import User from './User';

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

  return combineLatest(prop$, value$).pipe(
    tap(console.warn),
    map(([props, value]) => (
      
)) );

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

53f3c20335a8d92919160c286c0704ca.gif

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


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

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 => (
      

{user}

)) );

И теперь вместо имени пользователя на экран выводит 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 запрос. Когда приходит ответ, он передает его нашему «тупому» компоненту.

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

c492fa663f3335735f1e54f105f8c08c.gif


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

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

5aac1874c745577075bba847de824902.png

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


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))
  )
)

77d01121f2c607263cd69fcfd0bed64e.png

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


Компонент Error

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

import React from 'react';

const Error = ({ response, status }) => (
  

Oops!

{status}: {response.message}

Please try searching again.

); 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())
) 

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

359e528b0fc7f9e637060011bfc2c74c.gif


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

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

Что если место 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())
    )
  )
);  

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

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

return merge(loading$, getUser$).pipe(
  map(result => (result === true ? 

Loading...

: 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())
)   

Результат?

4531dfa46df7fe675bc466067639ab22.gif

Все:)

© Habrahabr.ru