Покажу как просто и удобно можно сделать главную фишку SPA — плавный и бесшовный переход между страницами в Bitrix без тонны JS кода. Ну и самое главное без потери SEO.
Принцип работы будет похож немного на Next.js / Nuxt.js — где первую страницу отдает сервер, после чего остальные страницы уже подгружаются фоном через JS. Но в нашем случае роутинг мы напишем сами.
Оглавление
Просто о сложном
Что такое SPA и как оно работает?
SPA — это сайт одностраничник который работает без перезагрузки страницы, весь контент на странице меняется через JavaScript.
Обычно эти сайты написаны на Vue, React или Angular
А так же могут быть другие подобные фреймворки / библиотеки. Но эти трое самые популярные и на рынке стабильно за них платят. Но в данной статье речь пойдет не о них.
А теперь чуть углубимся в принцип работы.
SPA работает благодаря History API которое позволяет управлять историей браузера, текущей ссылкой и прочими вещами через JS.
И на window добавляют слушатель событий popstate, благодаря которому мы можем отслеживать изменения в пути / URL
Соответственно когда у нас изменяется путь отрабатывает ивент, который видит изменения и быстро подгружает нам нашу страницу.
Только есть один ньюанс — данные фреймворки или библиотеки использует концепт VDOM благодаря которому все это работает очень быстро.
Свой VDOM — мы писать не будем в этой статье, обойдемся лишь апендом напрямую в DOM, либо у кого есть желание может использовать DocumentFragment или играться с темплейтами и апендить их в DOM (Не важно кто понял тот понял).
Ну и собственно анимацию перехода между страницами мы тоже сделаем.
Что нам понадобится?
-
Знание как работают ООП компоненты в Bitrix и умение их писать.
-
Использование библиотеки BX, а именно модуль ajax.
-
Желательно bitrix cli для сборки нашего JavaScript кода, но не обязательно.
-
Правильная структура.
-
При добавлении новой страницы надо в роуте прописывать новый путь).
ООП компоненты нам нужны для того чтобы мы могли подгружать новые компоненты через ajax, и это будет прям компонент, мы подгрузим css и js компонента. И выполним это 1 раз, из за чего последующая загрузка / перех на страницу будет еще быстрее.
Из BX нам понадобится ajax.runComponentAction, который позволяет обратится к действию нашего класса. Это нужно для того чтобы не городить отдельную папку с нашими ajax файлами где мы будем возвращать верстку. У нас будет один маленький удобный action у класса к которому мы обратимся и получим наш компонент.
А так же ajax.history благодаря которому мы будем работать с History API.
Bitrix CLI позволит нам минифицировать наш JS код и собрать его с использованием полифилов для того чтобы наш код работал корректно в других браузерах. Кроме того наш файл будет весить меньше, а значит страница будет грузиться быстрее.
Правильная структура позволит нам и другим разработчикам после нас поддерживать проект + без правильной структуры данное решние будет (50% на 50%).
Под правильной структурой я имею ввиду в index.php не размещать логику страницы + html + css + js. А делить это на компонеты и подключать уже в index.php.
По сути это обычная структура, но я видел хаос про который написал выше и это полный …., ну вы поняли).
Делаем структуру
Для начала разместим наши страницы в корне сайта. И внутри создадим файл index.php где куда потом подключим наш компонент.
В моем случае у меня будут страницы:
-
firstroute
-
secondroute
Далее я добавлю компонент роутинга в header.php и создам не закрытый div
После переходим в footer.php и закрываем наш div. Это нужно для того чтобы когда мы переходили на наши страницы наш контент был в обёртке и мы могли его нормально динамически менять.
header.php
<?php $APPLICATION->IncludeComponent( "test:routing", ".default", [ 'ROUTES' => [ [ "PATH" => "/secondroute/", "NAME" => 'Второй путь', "COMPONENT" => [ "NAME" => 'test:second', "TEMPLATE" => '.default', "PARAMS" => [ "TITLE" => "hello from second route", ], ] ], [ "PATH" => "/firstroute/", "NAME" => 'Первый путь', "COMPONENT" => [ "NAME" => 'test:first', "TEMPLATE" => '.default', "PARAMS" => [ "TITLE" => "hello from first route", ], ] ], ] ], false ); ?> <div id="content">
Далее сделаем эти компоненты которые указали в роутинге и сам роутинг
После чего переходим в наши страницы firstroute и secondroute и подключаем их как и указали в роутинге.
Страницы роутинга
<?require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php"); $APPLICATION->IncludeComponent( "test:first", ".default", [ "TITLE" => "hello from first route", ], false ); ?>
<?require($_SERVER["DOCUMENT_ROOT"]."/bitrix/header.php"); $APPLICATION->IncludeComponent( "test:second", ".default", [ "TITLE" => "hello from second route", ], false ); ?>
P.S. Вот и вся структура… Ничего сложного)
Создаем роутинг
Наш роутинг для нашего же удобства и для других разработчик должен быть написан в ООП стиле. Эти ООП компоненты появились в D7.
Начнем со структуры компонента.
в папке dist ничего не находится — её создал сам сборщик (bitrix cli), я выполнил сборку JS сразу в script.js.
Опять же кто хочет — тот может написать JS без bitrix cli. Просто для меня этот инструмент стал немного удобным.
Начнем с класса компонента.
class.php
<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use Bitrix\Main\Web\Json, Bitrix\Main\Engine\Response\Component, Bitrix\Main\Engine\ActionFilter, Bitrix\Main\Engine\Contract\Controllerable; class Routing extends CBitrixComponent implements Controllerable { /* Получение параметров которые передали при инициализации компонента (нужно для того чтобы получить arParams когда обращаемся к action через ajax) */ protected function listKeysSignedParameters() : array { return [ 'ROUTES' ]; } /*Настраиваем доступ к своим действиям*/ public function configureActions(): array { /**Отключаем у наших экшенов требования к авторизации пользователя на сайте */ return [ 'updateContent' => [ '-prefilters' => [ ActionFilter\Authentication::class, ], ], ]; } /* Запуск компонента */ public function executeComponent(): void { $this->setArResult(); $this->includeComponentTemplate(); } /* Получение arResult компонента */ public function setArResult(): void { $this->arResult['ROUTES'] = $this->arParams['ROUTES']; $this->arResult['SIGNED'] = Json::encode($this->getSignedParameters()); } /* Возвращаем компонент на страницу */ public function updateContentAction(string $routeKey): Component { $this->setArResult(); $route = $this->arResult['ROUTES'][$routeKey]; return new Component( $route['COMPONENT']['NAME'], $route['COMPONENT']['TEMPLATE'], $route['COMPONENT']['PARAMS'], ); } }
Тут довольно все просто и понятно.
Логика компонента написана, далее переходим к фронтовской части.
template.php
<nav class="routing-wrapper"> <ul id="routing" class="routing"> <?php foreach ($arResult['ROUTES'] as $key => $route):?> <li class="routing__item"> <a class="routing__link" data-route="<?=$key?>" href="<?=$route['PATH']?>" > <?=$route['NAME']?> </a> </li> <?php endforeach?> </ul> </nav> <script> //Передаем параметры компонента new BX.Routing( <?=$arResult['SIGNED']?> ) </script>
Сразу стоит уточнить почему инициализацию JS кода лучше делать в template.php
Дело в том что когда мы возвращаем наш компонент в классе, то JS и CSS автоматически вставляются без нашего согласия на страницу. И контролировать мы это не можем. Кроме того JS и CSS загружаются один раз и потом постоянно переиспользуются.
По этому при повторной инициализации компонента, JS код не отработает повторно, по этому его стоить инициализировать внутри template.php
Routing.js
import { ajax, create, processHTML } from "main.core"; export class Routing { #signedParameters; #routing; #content; #routes; constructor(signedParameters) { this.#signedParameters = signedParameters; this.#routes = []; this.#routing = document.getElementById("routing"); this.#content = document.getElementById("content"); this.#initEvents(); } #initEvents() { this.#routing.querySelectorAll("a").forEach((route) => { const path = route.getAttribute("href"); const routeKey = route.dataset.route; this.#routes.push({ path: path, routeKey: routeKey, disabled: false, }); route.onclick = (e) => { e.preventDefault(); ajax.history.put({}, path); this.#updateContent(); }; }); window.onpopstate = () => { this.#updateContent(); }; } async #updateContent() { const path = window.location.pathname; const routeKey = this.#routes.find( (route) => route.path === path )?.routeKey; const closePageTransition = this.#content.animate( { opacity: [1, 0], transform: ["translateY(0)", "translateY(20px)"], }, { duration: 300, fill: "forwards", } ); closePageTransition.onfinish = async () => { try { const response = await ajax.runComponentAction( "test:routing", "updateContent", { mode: "class", data: { routeKey: routeKey, }, signedParameters: this.#signedParameters, } ); const { HTML, SCRIPT, STYLE } = processHTML(response.data.html); this.#content.innerHTML = HTML; SCRIPT.forEach((script) => { const scriptElement = create("script", { html: script.JS, }); this.#content.appendChild(scriptElement); }); } catch (error) { console.error(error); } finally { this.#content.animate( { opacity: [0, 1], transform: ["translateY(20px)", "translateY(0)"], }, { duration: 300, fill: "forwards", } ); } }; } }
Вариант без сборки и bitrix cli:
script.js
BX.namespace("Routing"); class Routing { #signedParameters; #routing; #content; #routes; constructor(signedParameters) { this.#signedParameters = signedParameters; this.#routes = []; this.#routing = document.getElementById("routing"); this.#content = document.getElementById("content"); this.#initEvents(); } #initEvents() { this.#routing.querySelectorAll("a").forEach((route) => { const path = route.getAttribute("href"); const routeKey = route.dataset.route; this.#routes.push({ path: path, routeKey: routeKey, disabled: false, }); route.onclick = (e) => { e.preventDefault(); ajax.history.put({}, path); this.#updateContent(); }; }); window.onpopstate = () => { this.#updateContent(); }; } async #updateContent() { const path = window.location.pathname; const routeKey = this.#routes.find( (route) => route.path === path )?.routeKey; const closePageTransition = this.#content.animate( { opacity: [1, 0], transform: ["translateY(0)", "translateY(20px)"], }, { duration: 300, fill: "forwards", } ); closePageTransition.onfinish = async () => { try { const response = await BX.ajax.runComponentAction( "test:routing", "updateContent", { mode: "class", data: { routeKey: routeKey, }, signedParameters: this.#signedParameters, } ); const { HTML, SCRIPT, STYLE } = BX.processHTML(response.data.html); this.#content.innerHTML = HTML; SCRIPT.forEach((script) => { const scriptElement = BX.create("script", { html: script.JS, }); this.#content.appendChild(scriptElement); }); } catch (error) { console.error(error); } finally { this.#content.animate( { opacity: [0, 1], transform: ["translateY(20px)", "translateY(0)"], }, { duration: 300, fill: "forwards", } ); } }; } } BX.Routing = Routing;
Обратите внимание на данную строчку кода, мы сделали BX.processHTML чтобы получить скрипты которые мы инициализируем в template.php.
Потому что script.js автоматически подключается сам и нам не требуется с ним что либо делать, но вот со скриптом который находится у нас в темплейте нам придется для начала найти его, и потом сделать append в content чтобы он заработал потому что когда я записывал в innerHTML, то у меня скрипты не инициализировались.
Я решил эту проблему вот таким способом:
SCRIPT.forEach((script) => { const scriptElement = BX.create("script", { html: script.JS, });
А теперь перейдем к написанию самих компонентов.
first
class.php
<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use Bitrix\Main\Engine\Contract\Controllerable; class First extends CBitrixComponent implements Controllerable { /* Получение параметров которые передали при инициализации компонента (нужно для того чтобы получить arParams когда обращаемся к action через ajax) */ protected function listKeysSignedParameters() : array { return [ 'TITLE' ]; } /*Настраиваем доступ к своим действиям*/ public function configureActions(): array { return []; } /* Запуск компонента */ public function executeComponent(): void { $this->setArResult(); $this->includeComponentTemplate(); } /* Получение arResult компонента */ public function setArResult(): void { $this->arResult = $this->arParams; } }
template.php
<div class="container"> <?for ($index = 0; $index < 20; $index++):?> <div class="base-card" style="animation-delay: <?=$index * 50?>ms;"> <h3 class="base-card__title"><?=$arResult['TITLE']?></h3> <div class="base-card__content"> </div> <div class="base-card__actions"> <a href="#" class="base-card__link">Подробнее</a> </div> </div> <?endfor?> </div>
style.css
.container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; } .base-card { padding: 20px; background-color: #f3f3f3; box-shadow: 1px 1px 4px 0px rgba(0, 0, 0, 0.2); animation: show-card 300ms ease-in-out forwards; scale: 0.5; opacity: 0; &:hover { box-shadow: 1px 1px 4px 0px rgba(0, 0, 0, 0.4); cursor: pointer; transition: 0.2s; transform: scale(1.01); } & .base-card__title { margin-bottom: 10px; } & .base-card__content { height: 200px; background-color: #fff; margin-bottom: 10px; } } @keyframes show-card { to { opacity: 1; scale: 1; } }
second
class.php
la<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use Bitrix\Main\Engine\Contract\Controllerable; class Second extends CBitrixComponent implements Controllerable { /* Получение параметров которые передали при инициализации компонента (нужно для того чтобы получить arParams когда обращаемся к action через ajax) */ protected function listKeysSignedParameters() : array { return [ 'TITLE' ]; } /*Настраиваем доступ к своим действиям*/ public function configureActions(): array { return [ ]; } /* Запуск компонента */ public function executeComponent(): void { $this->setArResult(); $this->includeComponentTemplate(); } /* Получение arResult компонента */ public function setArResult(): void { $this->arResult = $this->arParams; } }
template.php
<div class="container"> <form class="base-form"> <header class="base-form__header"> form - <?=$arResult['TITLE']?> </header> <div class="base-form__field"> <label for="username"> Username: </label> <input id="username" class="base-form__input" type="text" name="username"> </div> <div class="base-form__field"> <label for="password"> Username: </label> <input id="password" class="base-form__input" type="password" name="password"> </div> <footer class="base-form__footer"> <button class="base-form__button" type="submit">Submit</button> <button class="base-form__button" type="reset">Reset</button> </footer> </form> </div>
style.css
.container { background-color: #deb887; }
По итогу должен получиться вот такой результат:
И возникает уже первая условность данного подхода.
Общие классы нужно выносить в main.css, хотя я бы не назвал это условностью ведь, это просто в целом правильный подход.
Почему же так вышло?
Так как style.css и script.js подключаются сами, у компонентов есть общий класс, где мы переопределяем стили контейнера.
/*second*/ .container { background-color: #deb887; } /*first*/ .container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; }
Из за чего и происходит смена цвета которая у нас останется до тех пор — пока не перезагрузим страницу.
Как избежать лишних запросов на сервер
Есть небольшой дополнительный нюанс, мы можем избежать дополнительной нагрузки на сервер, если пользователь уже посещал данную страницу, а у нас эта страница является полностью статичной.
Условно будем сами кешировать ручками еще и разметку страницы.
Для оптимизации в целом мы можем использовать КЕШ в Bitrix, и кешировать наши запросы к базе в компонентах.
Но мы по прежнему общаемся к серверу чтобы получить страницу на которой пользователь уже был.
Для того чтобы избежать этого у нас есть много вариантов.
-
Можно в нашем роутинге в массиве с роутами сделать еще одно свойство которое будет хранить наш HTML.
-
Воспользоваться HTML тегом — template
Первый вариант неплох, но не так хорош как второй, все таки разметка страницы может быть очень большой. И пользователь явно будет больше посещать чем одну страницу.
А теперь про второй вариант.
Мы можем создать template, и помещать его на страницу с содержимым. И каждый раз когда будем переходить на следующую страницу — мы будем проверять.
Есть ли такой template с таким id ? Если нет — то мы подгружаем контент с сервера, затем создаем тег template и помещаем на страницу, после чего клонируем контент, и уже потом вставляем на страницу, а если есть то мы просто находим наш тег и клонируем содержимое и так же вставляем на страницу.
А теперь о преимуществах:
Встроенный элемент <template> предназначен для хранения шаблона HTML. Браузер полностью игнорирует его содержимое, проверяя лишь синтаксис, но мы можем использовать этот элемент в JavaScript, чтобы создать другие элементы.
В теории, для хранения разметки мы могли бы создать невидимый элемент в любом месте HTML. Что такого особенного в <template>?
Кроме того у данного тега контент сразу работает как DocumentFragment, а значит вставка этого контента на страницу будет более оптимизирована.
Вывод
Данный подход является очень эффективным с точки зрения производительности. Ведь он работает как полноценное SPA.
Как по мне вариант очень солидный когда на проекте нужно SEO + серверный рендеринг и реактивность, а вы привязаны именно к Битриксу, и не можете использовать Next или Nuxt.
Так же хочу уточнить что данный пример, является прототипом, и тут еще очень много моментов которые можно и нужно улучшить.
Плюсы:
-
Мы динамически подгружаем страницы.
-
Мы не загружаем заново все JS и CSS файлы.
-
Есть дополнительный вариант как можно самим «закешировать» страницы чтобы после загрузки мы их не подгружали каждый раз с сервера.
-
Есть возможность сделать красивые анимации перехода между страницами.
-
Не теряем серверный рендеринг, поисковики нормально будут находить наши страницы.
-
Улучшение UX
Минусы:
-
Плохо работает со стандартными компонентами, нужно писать свои либо делать обёртку.
-
Обязательно пишем JS модульно, а инициализацию в template.php
-
Дважды объявляем подключение компонента (на странице и в роутинге)
А так же на legacy проекте — это будет тяжело сделать, ведь уже есть много страниц, и своя структура с JS и CSS.
Очень часто можно встретить на проектах Bitrix где глобальный css переписывается на новый с пометкой !important а значит сходу переписать будет проблематично.
Ссылка на пример
ссылка на оригинал статьи https://habr.com/ru/articles/838664/
Добавить комментарий