Розробка

Як зробити пошук користувачів з 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 ми можемо отримувати його через вами об’єкт.

Функція componentFromStream запускає рендер при кожному новому значенні з нашого вами. Якщо значень ще немає, вона рендрит null.

Конфігурування

Потоки в Recompose слідують документом ECMAScript Вами Proposal. У ньому описано, як повинні працювати об’єкти Вами коли вони будуть реалізовані в сучасних браузерах.

А поки що ми будемо використовувати бібліотеки такі як RxJS, xstream, most, Flyd і т. д.

Recompose не знає, яку бібліотеку ми використовуємо, тому вона надає функцію setObservableConfig. З її допомогою можна перетворити все, що нам потрібно в ES Вами.

Створіть новий файл в папці 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$, який є вами версією props. Ідея в тому, що використовуючи map перетворювати звичайні props в React компоненти.

Якщо ви використовували RxJS, ви повинні бути знайомі з оператором map.

Map

Як випливає з назви, map перетворює Вами(something) в Вами(somethingElse). У нашому випадку — Вами(props) в Вами(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 в свою чергу, є об’єктом вами, передає значення передплатникам.

Ми зв’яжемо між собою 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 як метод створення об’єктів Вами, не як оператор.

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, який повертає Вами об’єкт 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

Цей оператор ідеально підходить для перемикання між декількома вами.

Скажімо користувач набрав ім’я, а ми зробимо запит всередині 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 повинна повертати вами. Ми можемо добитися цього, використовуючи оператор 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} />))
) 

Результат?

Все 🙂

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close