Покажу как просто и удобно можно сделать главную фишку SPA - плавный и бесшовный переход между страницами в Bitrix без тонны JS кода. Ну и самое главное без потери SEO.

Принцип работы будет похож немного на Next.js / Nuxt.js - где первую страницу отдает сервер, после чего остальные страницы уже подгружаются фоном через JS. Но в нашем случае роутинг мы напишем сами.

Принцип работы нашего SPA
Принцип работы нашего SPA

Оглавление

  1. Просто о сложном

  2. Что нам понадобится?

  3. Делаем структуру

  4. Создаем роутинг

  5. Как избежать лишних запросов на сервер

  6. Вывод

  7. Ссылка на пример


Просто о сложном

Что такое 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">

Далее сделаем эти компоненты которые указали в роутинге и сам роутинг

Папка с компонентами в local -> components
Папка с компонентами в local -> components

После чего переходим в наши страницы firstroute и secondroute и подключаем их как и указали в роутинге.

firstroute
firstroute
secondroute
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, и кешировать наши запросы к базе в компонентах.

Но мы по прежнему общаемся к серверу чтобы получить страницу на которой пользователь уже был.

Для того чтобы избежать этого у нас есть много вариантов.

  1. Можно в нашем роутинге в массиве с роутами сделать еще одно свойство которое будет хранить наш HTML.

  2. Воспользоваться HTML тегом - template

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

А теперь про второй вариант.

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

Есть ли такой template с таким id ? Если нет - то мы подгружаем контент с сервера, затем создаем тег template и помещаем на страницу, после чего клонируем контент, и уже потом вставляем на страницу, а если есть то мы просто находим наш тег и клонируем содержимое и так же вставляем на страницу.

А теперь о преимуществах:

Встроенный элемент <template> предназначен для хранения шаблона HTML. Браузер полностью игнорирует его содержимое, проверяя лишь синтаксис, но мы можем использовать этот элемент в JavaScript, чтобы создать другие элементы.

В теории, для хранения разметки мы могли бы создать невидимый элемент в любом месте HTML. Что такого особенного в <template>?

Во-первых, его содержимым может быть любой корректный HTML-код, даже такой, который обычно нуждается в специальном родителе.

Кроме того у данного тега контент сразу работает как DocumentFragment, а значит вставка этого контента на страницу будет более оптимизирована.


Вывод

Данный подход является очень эффективным с точки зрения производительности. Ведь он работает как полноценное SPA.

Как по мне вариант очень солидный когда на проекте нужно SEO + серверный рендеринг и реактивность, а вы привязаны именно к Битриксу, и не можете использовать Next или Nuxt.

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

Плюсы:

  1. Мы динамически подгружаем страницы.

  2. Мы не загружаем заново все JS и CSS файлы.

  3. Есть дополнительный вариант как можно самим "закешировать" страницы чтобы после загрузки мы их не подгружали каждый раз с сервера.

  4. Есть возможность сделать красивые анимации перехода между страницами.

  5. Не теряем серверный рендеринг, поисковики нормально будут находить наши страницы.

  6. Улучшение UX

Минусы:

  1. Плохо работает со стандартными компонентами, нужно писать свои либо делать обёртку.

  2. Обязательно пишем JS модульно, а инициализацию в template.php

  3. Дважды объявляем подключение компонента (на странице и в роутинге)

А так же на legacy проекте - это будет тяжело сделать, ведь уже есть много страниц, и своя структура с JS и CSS.

Очень часто можно встретить на проектах Bitrix где глобальный css переписывается на новый с пометкой !important а значит сходу переписать будет проблематично.


Ссылка на пример

https://github.com/ZiZIGY/HABR-BitrixSPA

Комментарии (11)


  1. positroid
    29.08.2024 11:26
    +1

    Как по мне вариант очень солидный когда на проекте нужно SEO + серверный рендеринг и реактивность

    Можете раскрыть термин "реактивность" в вашем понимании, является ли .innerHTML = HTML реактивным?


    1. ZiZIGY Автор
      29.08.2024 11:26
      +2

      Наверное стоило вынести в кавычки)


      Нет, не является, в данном примере, это не реактивность.

      Когда мы накладываем на window слушатель, и чекаем текущий путь в браузере, если путь изменился - на основе этого делаю запрос к серваку и получаю нужный компонент в зависимости от состояния pathname, но чтобы данный пример был действительно реактивным, стоит использовать Proxy.

      А в моем понимании реактивность - это когда у нас есть состояние и при изменении его, что то происходит (Ну тот же самый useState). Но в моем случае у меня нет прям четкого состояния.