Создание поисковой панели на RxJS

Реактивное программирование – это парадигма, связанная с асинхронными потоками данных, в которой модель программирования рассматривает все как поток данных, распределенный во времени (включая нажатия клавиш, HTTP-запросы, файлы для печати и даже элементы массива, которые можно считать синхронизированными по очень небольшим интервалам). Такой подход идеален для JavaScript, так как в этом языке распространены асинхронные данные.

RxJS – это популярная библиотека реактивного программирования на JavaScript. ReactiveX, общая платформа, на которой основана RxJS, имеет свои расширения для многих других языков (Java, Python, C++, Swift и Dart). RxJS также широко используется библиотеками, такими как Angular и React.

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

В этом мануале мы расскажем, как с помощью RxJS создать многофункциональную панель поиска, которая будет возвращать пользователям результаты в реальном времени. Для форматирования панели используются HTML и CSS.

Этот мануал покажет вам, как RxJS может превратить довольно сложный набор требований в код — управляемый и простой для понимания.

Требования

  • Текстовый редактор с поддержкой выделения синтаксиса JavaScript: например, AtomVisual Studio Code или Sublime Text. Эти редакторы доступны для Windows, macOS и Linux.
  • Умение совмещать HTML и JavaScript. О том, как это делать, мы рассказывали ранее в мануале Добавление кода JavaScript в HTML.
  • Знакомство с форматом JSON, о котором мы писали в мануале Использование JSON в JavaScript.

1: Создание и стилизация панели поиска

На этом этапе мы создадим и стилизуем панель поиска с помощью HTML и CSS. Код будет использовать несколько общих элементов из Bootstrap для ускорения структурирования и стилизации страницы, чтобы вы могли сосредоточиться на добавлении пользовательских элементов. Bootstrap — это CSS-фреймворк, который содержит шаблоны для общих элементов, таких как типографика, формы, кнопки, навигация, сетки и другие компоненты интерфейса. Ваше приложение также будет использовать Animate.css для поддержки анимации.

Создайте файл search-bar.html с помощью nano или другого текстового редактора:

nano search-bar.html

Создайте базовую структуру приложения. Добавьте следующий код HTML:

<!DOCTYPE html>
<html>
<head>
<title>RxJS Tutorial</title>
<!-- Load CSS -->
<!-- Load Rubik font -->
<!-- Add Custom inline CSS -->
</head>
<body>
<!-- Content -->
<!-- Page Header and Search Bar -->
<!-- Results -->
<!-- Load External RxJS -->
<!-- Add custom inline JavaScript -->
<script>
</script>
</body>
</html>

Поскольку вам нужен CSS из библиотеки Bootstrap, загрузите его и Animate.css.

Добавьте следующий код под комментарием Load CSS:

...
<!-- Load CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.0/animate.min.css" />
...

В этом руководстве для стилизации панели поиска будет использоваться шрифт Rubik из библиотеки Google Fonts. Загрузите шрифт, добавив выделенный код под комментарием Load Rubik font:


<!— Load Rubik font —>
<link href=»https://fonts.googleapis.com/css?family=Rubik» rel=»stylesheet»>

Затем добавьте пользовательский CSS на страницу в разделе Add Custom inline CSS. Это обеспечит удобство чтения заголовков и использования панели и результатов на странице.

...
<!-- Add Custom inline CSS -->
<style>

body {


background-color: #f5f5f5;


font-family: "Rubik", sans-serif;


}


.search-container {


margin-top: 50px;


}


.search-container .search-heading {


display: block;


margin-bottom: 50px;


}


.search-container input,


.search-container input:focus {


padding: 16px 16px 16px;


border: none;


background: rgb(255, 255, 255);


box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1) !important;


}


.results-container {


margin-top: 50px;


}


.results-container .list-group .list-group-item {


background-color: transparent;


border-top: none !important;


border-bottom: 1px solid rgba(236, 229, 229, 0.64);


}


.float-bottom-right {


position: fixed;


bottom: 20px;


left: 20px;


font-size: 20px;


font-weight: 700;


z-index: 1000;


}


.float-bottom-right .info-container .card {


display: none;


}


.float-bottom-right .info-container:hover .card,


.float-bottom-right .info-container .card:hover {


display: block;


}


</style>

...

Теперь, когда у вас есть все стили, добавьте HTML-код, который определит заголовок и строку ввода под комментарием Page Header and Search Bar:

...
<!-- Content -->
<!-- Page Header and Search Bar -->
<div class="container search-container">

<div class="row justify-content-center">


<div class="col-md-auto">


<div class="search-heading">


<h2>Search for Materials Published by Author Name</h2>


<p class="text-right">powered by <a href="https://www.crossref.org/">Crossref</a></p>


</div>


</div>


</div>


<div class="row justify-content-center">


<div class="col-sm-8">


<div class="input-group input-group-md">


<input id="search-input" type="text" class="form-control" placeholder="eg. Richard" aria-label="eg. Richard" autofocus>


</div>


</div>


</div>


</div>

...

Этот код использует сетку из Bootstrap для структурирования заголовка страницы и панели поиска. Здесь панели поиска присваивается идентификатор search-input, который позже вы будете использовать для привязки к слушателю.

Далее нужно создать место для отображения результатов поиска. Под комментарием Results создайте div с идентификатором response-list, который позже позволит вам добавить результаты:

...
<!-- Results -->
<div class="container results-container">

<div class="row justify-content-center">


<div class="col-sm-8">


<ul id="response-list" class="list-group list-group-flush"></ul>


</div>


</div>


</div>

...

Сейчас файл search-bar.html выглядит так:

<!DOCTYPE html>
<html>
<head>
<title>RxJS Tutorial</title>
<!-- Load CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.0/animate.min.css" />
<!-- Load Rubik font -->
<link href="https://fonts.googleapis.com/css?family=Rubik" rel="stylesheet">
<!-- Add Custom inline CSS -->
<style>
body {
background-color: #f5f5f5;
font-family: "Rubik", sans-serif;
}
.search-container {
margin-top: 50px;
}
.search-container .search-heading {
display: block;
margin-bottom: 50px;
}
.search-container input,
.search-container input:focus {
padding: 16px 16px 16px;
border: none;
background: rgb(255, 255, 255);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1) !important;
}
.results-container {
margin-top: 50px;
}
.results-container .list-group .list-group-item {
background-color: transparent;
border-top: none !important;
border-bottom: 1px solid rgba(236, 229, 229, 0.64);
}
.float-bottom-right {
position: fixed;
bottom: 20px;
left: 20px;
font-size: 20px;
font-weight: 700;
z-index: 1000;
}
.float-bottom-right .info-container .card {
display: none;
}
.float-bottom-right .info-container:hover .card,
.float-bottom-right .info-container .card:hover {
display: block;
}
</style>
</head>
<body>
<!-- Content -->
<!-- Page Header and Search Bar -->
<div class="container search-container">
<div class="row justify-content-center">
<div class="col-md-auto">
<div class="search-heading">
<h2>Search for Materials Published by Author Name</h2>
<p class="text-right">powered by <a href="https://www.crossref.org/">Crossref</a></p>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-sm-8">
<div class="input-group input-group-md">
<input id="search-input" type="text" class="form-control" placeholder="eg. Richard" aria-label="eg. Richard" autofocus>
</div>
</div>
</div>
</div>
<!-- Results -->
<div class="container results-container">
<div class="row justify-content-center">
<div class="col-sm-8">
<ul id="response-list" class="list-group list-group-flush"></ul>
</div>
</div>
</div>
<!-- Load RxJS -->
<!-- Add custom inline JavaScript -->
<script>
</script>
</body>
</html>

Вы наметили общую структуру панели поиска с помощью HTML и CSS. Далее нужно написать функцию JavaScript, которая принимает поисковые запросы и возвращает результат.

2: Код JavaScript

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

Поскольку в этом руководстве вам не понадобятся функции, которые предоставляют Bootstrap и JavaScript, можно их не загружать. Но здесь нужно использовать RxJS. Загрузите эту библиотеку, добавив под комментарием Load RxJS следующее:

...
<!-- Load RxJS -->
<script src="https://unpkg.com/@reactivex/rxjs@5.0.3/dist/global/Rx.js"></script>
...

Теперь нужно сохранить ссылки на div из HTML-кода, к которым будут добавлены результаты. Добавьте выделенный код JavaScript в теге <script> в раздел Add custom inline JavaScript:

...
<!-- Add custom inline JavaScript -->
<script>
const output = document.getElementById("response-list");
</script>
...

Затем добавьте код для преобразования JSON-ответа от API в элементы HTML для отображения их на странице. Этот код сначала очистит содержимое панели поиска, а затем установит задержку для анимации результата поиска.

Добавьте выделенную функцию между тегами <script>:

...
<!-- Add custom inline JavaScript -->
<script>
const output = document.getElementById("response-list");
function showResults(resp) {

var items = resp['message']['items']


output.innerHTML = "";


animationDelay = 0;


if (items.length == 0) {


output.innerHTML = "Could not find any :(";


} else {


items.forEach(item => {


resultItem = `


<div class="list-group-item animated fadeInUp" style="animation-delay: ${animationDelay}s;">


<div class="d-flex w-100 justify-content-between">

<^>                <h5 class="mb-1">${(item['title'] && item['title'][0]) || "&lt;Title not available&gt;"}</h5>
</div>

<p class="mb-1">${(item['container-title'] && item['container-title'][0]) || ""}</p>


<small class="text-muted"><a href="${item['URL']}" target="_blank">${item['URL']}</a></small>


<div>


<p class="badge badge-primary badge-pill">${item['publisher'] || ''}</p>


<p class="badge badge-primary badge-pill">${item['type'] || ''}</p>


</div>


</div>

`;

output.insertAdjacentHTML("beforeend", resultItem);


animationDelay += 0.1;


});


}


}

</script>
...

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

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

3: Настройка слушателя

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

Для начала обратите внимание на идентификатор search-input , который вы добавили ранее:

...
<input id="search-input" type="text" class="form-control" placeholder="eg. Richard" aria-label="eg. Richard" autofocus>
...

Затем создайте переменную, которая будет содержать ссылки для элемента search-input. Получится Observable, который код будет использовать для прослушивания входных событий. Observables – это набор будущих значений или событий, которые прослушивает Observer, они также называются функциями обратного вызова.

Добавьте выделенную строку в теги <script> под код JavaScript из предыдущего шага:

...
output.insertAdjacentHTML("beforeend", resultItem);
animationDelay += 0.1;
});
}
}
let searchInput = document.getElementById("search-input");
...

Теперь, когда вы добавили переменную для входных данных, используйте оператор fromEvent для прослушивания событий. Это добавит в DOM слушателя (Document Object Model), элемент для определенного типа события. Элементом DOM может быть элемент html, body, div или img. В этом случае элемент DOM – это строка поиска.

Добавьте следующую выделенную строку под переменной searchInput, чтобы передать параметры из fromEvent. Ваш DOM-элемент searchInput является первым параметром. За ним в качестве второго параметра следует событие input (этот тип событий будет прослушивать код).

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
...

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

4: Добавление операторов

Операторы – это чистые функции с одной задачей – выполнять операции с данными. На этом этапе мы покажем, как использовать операторы для выполнения различных задач, таких как буферизация параметра input, выполнение HTTP-запросов и фильтрация результатов.

Сначала убедитесь, что результаты обновляются в режиме реального времени, по мере того, как пользователь вводит запросы. Для этого нужно использовать событие input из предыдущего раздела. Событие DOM input содержит различные детали, но в этом примере нас интересуют значения, введенные в целевой элемент. Добавьте следующий код, чтобы использовать оператор pluck для получения объекта и возврата значения по указанному ключу:

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
...

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

Используйте оператор filter, чтобы установить этот минимум. Он будет передавать данные дальше по потоку, если эти данные отвечают указанному условию. Установите длину больше 2, чтобы для поиска требовалось не менее трех символов.

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
...

Также нужно сделать так, чтоб запросы отправлялись только с интервалами в 500 мс – это облегчит загрузку сервера API. Для этого вы будете использовать оператор debounceTime, чтобы поддерживать минимальный указанный интервал между каждым событием, которое проходит через поток. Добавьте выделенный код под оператором filter:

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
...

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

В качестве примера пользователь может ввести запрос super cars, удалить последний символ (получится запрос super car), а затем добавить удаленный символ обратно. В результате сам запрос не изменился, и поэтому не должны меняться и результаты поиска. В таких случаях имеет смысл не выполнять никаких операций.

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

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
.distinctUntilChanged()
...

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

Затем добавьте код для использования switchMap, чтобы связать AJAX с вашим приложением. Также нужно использовать map  для связи ввода с выводом. Этот код будет применять переданную ему функцию к каждому элементу, передаваемому Observable.

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
.distinctUntilChanged()
.switchMap(searchKey => Rx.Observable.ajax(`https://api.crossref.org/works?rows=50&query.author=${searchKey}`)

.map(resp => ({


"status" : resp["status"] == 200,


"details" : resp["status"] == 200 ? resp["response"] : [],


"result_hash": Date.now()


})


)


)

...

Этот код разбивает ответ API на три части:

  • status: код состояния HTTP, возвращаемый сервером API. Этот код будет принимать только ответы с кодом 200, то есть только успешные.
  • details: фактические данные ответа. Здесь содержатся результаты по запросу.
  • result_hash: хэш-значение ответов, возвращаемых сервером API, которое в рамках данного руководства является меткой времени UNIX. Это хэш, который изменяется при изменении результатов. Уникальное значение хеш-функции позволяет приложению определить, изменились ли результаты и следует ли их обновить.

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

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
.distinctUntilChanged()
.switchMap(searchKey => Rx.Observable.ajax(`https://api.crossref.org/works?rows=50&query.author=${searchKey}`)
.map(resp => ({
"status" : resp["status"] == 200,
"details" : resp["status"] == 200 ? resp["response"] : [],
"result_hash": Date.now()
})
)
)
.filter(resp => resp.status !== false)
...

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

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

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
.distinctUntilChanged()
.switchMap(searchKey => Rx.Observable.ajax(`https://api.crossref.org/works?rows=50&query.author=${searchKey}`)
.map(resp => ({
"status" : resp["status"] == 200,
"details" : resp["status"] == 200 ? resp["response"] : [],
"result_hash": Date.now()
})
)
)
.filter(resp => resp.status !== false)
.distinctUntilChanged((a, b) => a.result_hash === b.result_hash)
...

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

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

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

5: Активация поисковой строки с помощью оператора subscribe

Оператор subscribe является последним оператором ссылки, который позволяет наблюдателю видеть события данных из Observable. Он реализует следующие три метода:

  • onNext: указывает, что делать при получении события.
  • onError: отвечает за обработку ошибок. Вызовы onNext и onCompleted не будут выполняться после вызова этого метода.
  • onCompleted: этот метод вызывается, когда onNext был вызван в последний раз. Здесь больше не будет данных, которые нужно передать в конвейере.

Эта подпись – это то, что позволяет добиться ленивых вычислений (lazy execution), то есть способности определять Observable конвейер и приводить его в движение, только когда вы подписываетесь на него. Мы не будем использовать этот пример в своем коде, но ниже покажем, как можно подписаться на Observable.

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

...
let searchInput = document.getElementById("search-input");
Rx.Observable.fromEvent(searchInput, 'input')
.pluck('target', 'value')
.filter(searchTerm => searchTerm.length > 2)
.debounceTime(500)
.distinctUntilChanged()
.switchMap(searchKey => Rx.Observable.ajax(`https://api.crossref.org/works?rows=50&query.author=${searchKey}`)
.map(resp => ({
"status" : resp["status"] == 200,
"details" : resp["status"] == 200 ? resp["response"] : [],
"result_hash": Date.now()
})
)
)
.filter(resp => resp.status !== false)
.distinctUntilChanged((a, b) => a.result_hash === b.result_hash)
.subscribe(resp => showResults(resp.details));
...

Сохраните и закройте файл.

Теперь, когда вы завершили написание кода, пора просмотреть и протестировать панель поиска. Дважды щелкните файл search-bar.html, чтобы открыть его в веб-браузере. Если код был введен правильно, вы увидите панель поиска.

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

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

Заключение

В этом мануале вы создали многофункциональную панель поиска с помощью RxJS, CSS и HTML, которая предоставляет пользователям результаты в реальном времени. Строка поиска принимает запросы, состоящие минимум из трех символов, обновляется автоматически и оптимизирована как для клиента, так и для сервера API.

Набор требований, который считается сложным, был создано с помощью 18 строк кода RxJS. Такой код не только удобен для чтения, но и намного чище, чем отдельная реализация JavaScript. Это означает, что вам будет легче понимать, обновлять и поддерживать код в будущем.

Чтобы узнать больше об использовании RxJS, ознакомьтесь с официальной документацией по API.

Tags: , ,