Давайте рассмотрим новейший API для простой разработки одностраничных приложений.

Как правило, проще сделать пользовательский интерфейс в виде одностраничного приложения (SPA), чем в виде классического многостраничного приложения. Разработка рендеринга на стороне сервера — пустая трата времени, когда содержимое приложения не нужно индексировать поисковыми системами (например, в институциональных приложениях внутренней сети или общедоступных сайтах интернет-банкинга).

В классических веб-приложениях браузер запрашивает с сервера новую HTML-страницу при каждой ссылке. Напротив, SPA загружают только одну HTML-страницу. Сервер отправляет одну и ту же HTML-страницу для любого URL-адреса, обрабатываемого SPA. Например, в примере SPA, иллюстрирующем этот пост, сервер возвращает точно такой же код HTML, JavasScript и CSS для запросов с тремя разными путями URL /defaultScroll/, /defaultScroll/edit/wbr и /defaultScroll/edit/ .

Когда код SPA загружается, изменения текущего URL-адреса перехватываются его модулем маршрутизатора. Когда пользователь щелкает ссылку, маршрутизатор показывает целевой URL-адрес в адресной строке, сохраняет целевой URL-адрес в истории браузера и вызывает функцию, отображающую содержимое для целевого URL-адреса. Функция обычно загружает некоторые данные с сервера, оборачивает данные в HTML-элементы, вставляет их в DOM и скрывает ранее видимые элементы.

События нажатия кнопки также могут обрабатываться маршрутизатором. Но есть важное различие между ссылками и кнопками. Ссылки, образованные тегами <a>, можно открывать в новом окне, ссылаться на них из другого веб-приложения или добавлять в закладки. Новое преимущество ссылок заключается в том, что для их перехвата не нужны прослушиватели событий кликов.

Современный API навигации предлагает простой механизм для перехвата любой предстоящей навигации. Наиболее распространенными триггерами событий навигации, которые можно перехватить, являются ссылки, формы, кнопки браузера Назад или Вперед, location.href или location.assign().

API навигации описан в статье Современная маршрутизация на стороне клиента: API навигации. Пост слишком подробный и неполный — в нем описаны и обычно неактуальные детали, но нет практического примера. В этом посте я пропущу незаменимые, но редко используемые функции и постараюсь рассмотреть наиболее часто используемые функции Navigation API.

Образец СПА

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

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

В режиме редактирования я могу вернуться к списку, нажав кнопку Вернуться к списку. Если представление редактирования было открыто из представления списка, я также могу использовать кнопку браузера Назад. В любом из представлений я также могу щелкнуть внешнюю ссылку API. Затем я могу перемещаться между посещенными представлениями и внешней страницей, нажимая кнопки браузера Назад и Вперед.

Код примера SPA, связанный с маршрутизацией, удивительно прост. main.js регистрирует три маршрута (представление списка, представление редактирования и пустое представление редактирования) и запускает маршрутизатор. Для простоты кода я использую маршруты напрямую в виде регулярных выражений. Регулярные выражения позволяют использовать свои группы захвата в качестве аргументов для обратных вызовов. В примере SPA аргументы необходимы для представления редактирования.

// main.js
import { renderList } from '/js/views/list.js';
import { renderEdit, renderNew } from '/js/views/edit.js';
import { addRoute, start } from './router.js';
addRoute(/^$/, renderList);
addRoute(/^edit\/([a-z]+)$/, id => renderEdit(id));
addRoute(/^edit(\/)?$/, renderNew);
start();

Код роутера такой же простой.

// router.js
const handlers = [];
export function addRoute(route, callback) {
    handlers.unshift({ route, callback });
}
export function start() {
    navigation.addEventListener('navigate', onNavigate);
    location.assign(location.href);
}
function onNavigate(e) {
    const callback = findHandler(e.destination.url);
    if (callback) {
        e.intercept({
            handler() {
                return callback();
            }
        });
    }
}
function argumentsInUrl(route, path) {
    return route.exec(path).slice(1);
}
function findHandler(url) {
    const path = url.replace(document.baseURI, '').split('?')[0]; 
    const handler = handlers.find(o => o.route.test(path));
    if (handler)
        return () =>   handler.callback(...argumentsInUrl(handler.route, path));
}

addRoute() сохраняет URL-адрес вместе с функцией, обрабатывающей его рендеринг. Для точной обработки положения прокрутки важно, чтобы обработчики возвращали обещания, которые разрешаются только после того, как запрошенное представление вставлено в DOM.

start() сначала регистрирует прослушиватель событий onNavigate() в новом объекте navigation, который запускает событие navigate перед любой неизбежной навигацией. Затем для отображения первого представления start() запускает событие navigate с URL-адресом, с которого был загружен SPA.

Слушатель onNavigate() ищет зарегистрированный обработчик для URL-адреса назначения и выполняет его вместе с intercept(), специализированной альтернативой preventDefault(). Вызов intercept() необходим для указания браузеру не запрашивать целевой URL из сети. Если обработчик, соответствующий целевому URL-адресу, не найден, обычно при нажатии внешней ссылки onNavigate() не вызывает intercept(), а браузер загружает целевой URL-адрес из сети.

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

Эффективное управление состоянием прокрутки с помощью маршрутизатора на основе Navigation API

Правильное положение прокрутки — важный аспект удобной навигации по представлениям. Когда пользователи нажимают кнопку Назад, они хотят вернуться к тому же месту в предыдущем представлении. И наоборот, когда пользователи нажимают на ссылку, они не хотят попадать в середину страницы.

Чтобы изучить, как новый API обрабатывает прокрутку, я сделал так, чтобы оба представления переполняли окно. Представление списка имеет полосу прокрутки, потому что таблица с тегами достаточно длинная. Чтобы показать полосу прокрутки в режиме редактирования любого тега, я установил ее минимальную высоту на 200% от высоты окна. Из-за этого вертикальная полоса прокрутки отображается, даже если содержимое соответствует окну. Вы видите это на скриншоте выше.

Позиция прокрутки автоматически восстанавливается при перезагрузке страницы

Чтобы засвидетельствовать это, я прокручиваю таблицу с тегами до конца.

Я нажимаю кнопку Обновить в браузере. Страница перезагружается из сети, представление списка повторно отображается, а затем прокручивается браузером вниз, точно так же, как на скриншоте выше.

При переходе по ссылке страница не прокручивается вверх

Теперь давайте посмотрим на ограничение API навигации.

Я снова перехожу к нижней части списка. Затем я нажимаю тег. Представление редактирования с тегом clicked отображается прокручиваемым в нижнюю часть страницы.

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

Позиция прокрутки восстанавливается во время навигации по истории браузера

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

listBtn.addEventListener("click", () => {
    if (navigation.entries()[navigation.currentEntry.index - 1]?.url === document.baseURI) {
        navigation.back();
    } else { 
        location.assign("");
    }
});

Метод navigation.entries() возвращает URL-адреса, по которым пользователь перешел после открытия страницы. navigation.currentEntry.index возвращает индекс текущего URL в массиве, возвращенном navigation.entries(). При нажатии кнопки Вернуться к списку прослушиватель проверяет, является ли предыдущая запись в истории URL-адресом представления списка. Если это так, прослушиватель запускает событие navigate, указывая браузеру вернуться к предыдущей записи в истории. В качестве альтернативы, если представление редактирования было открыто путем открытия ссылки в новом окне, в истории нет представления списка. Затем прослушиватель запускает событие navigate, вызывая location.assign() с относительным URL-адресом представления списка.

Чтобы проиллюстрировать, как работает код, я продолжаю скриншот выше с неправильно прокручиваемым описанием тега wbr. Если я нажму кнопку Вернуться к списку или кнопку Назад, отобразится правильно прокручиваемый список.

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

Маршрутизатор отлично сбрасывает и восстанавливает положение прокрутки

Поэтому разработчикам, использующим Navigation API, необходимо предоставить код, сбрасывающий положение прокрутки в представлениях, созданных после нажатия на ссылку. Свойство navigationType события navigation позволяет различать переход по ссылке и перемещение по истории. Когда навигация вызвана ссылкой, navigationType равно push. Когда браузер переходит назад или вперед по истории, navigationType равно traverse.

В расширенной версии примера SPA https://navigationapi.onrender.com/restoreScroll/ onNavigate() слушатель ожидает возврата обработчика маршрута, а затем проверяет, нужно ли обнулить позицию прокрутки.

function onNavigate(e) {
    const callback = findHandler(e.destination.url);
if (callback) {
        e.intercept({
            async handler() {
                await callback();
                if (e.navigationType === 'push')
                    document.scrollingElement.scrollTop = 0
            }
        });
    }
}

Выводы

Из своего образца SPA я делаю несколько практических выводов. По сравнению с устаревшими маршрутизаторами, основанными на History API, современные маршрутизаторы проще в использовании, потому что:

  • Кроме основного модуля, регистрирующего маршруты, никакие модули не должны импортировать маршрутизатор. Маршрутизатору не нужно иметь более двух публичных методов — addRoute() и start(). Маршрутизатор перехватывает все навигационные триггеры, включая location.href или location.assign(), поэтому router.navigate() не имеет смысла.
  • Код SPA короче без прослушивателей кликов по ссылкам.
  • Без дополнительного кода положение прокрутки надежно восстанавливается при обходе истории и перезагрузке страницы. Поведение по умолчанию можно легко изменить.

Пример кода можно загрузить с https://github.com/marianc000/navigationAPI или просмотреть на странице примера https://navigationapi.onrender.com/.

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn и Discord.