[Перевод] Как сделать поиск пользователей по GitHub используя React + RxJS 6 + Recompose
Эта статья рассчитана на людей имеющих опыт работы с 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(() => (
))
)
});
С RxJS 5 мы используем pipe
вместо цепочки операторов.
Сохраните файл и проверьте результат. Ничего не изменилось!
Добавляем обработчик событий
Сейчас мы сделаем наше поле ввода немножечко реактивным.
Добавьте импорт 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]
.
Компонент 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]) => (
))
);
Теперь наше значение выводится на экран с задержкой в одну секунду.
Неплохо, теперь надо получать информацию о пользователе.
Запрос данных
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 запрос. Когда приходит ответ, он передает его нашему «тупому» компоненту.
И вот результат!
Обработка ошибок
Попробуйте ввести несуществующее имя пользователя.
Наше приложение сломано.
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 }) => (
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 выглядит гораздо лучше:
Индикатор загрузки
Пора ввести управление состоянием. Как еще можно реализовать индикатор загрузки?
Что если место 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( ))
)
Результат?
Все:)