Покажу как просто и удобно можно сделать главную фишку 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
а значит сходу переписать будет проблематично.
positroid
Можете раскрыть термин "реактивность" в вашем понимании, является ли
.innerHTML = HTML
реактивным?ZiZIGY Автор
Наверное стоило вынести в кавычки)
Нет, не является, в данном примере, это не реактивность.
Когда мы накладываем на window слушатель, и чекаем текущий путь в браузере, если путь изменился - на основе этого делаю запрос к серваку и получаю нужный компонент в зависимости от состояния pathname, но чтобы данный пример был действительно реактивным, стоит использовать Proxy.
А в моем понимании реактивность - это когда у нас есть состояние и при изменении его, что то происходит (Ну тот же самый useState). Но в моем случае у меня нет прям четкого состояния.