SPA приложение, без JS фреймворков и потери SEO в Bitrix

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

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

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

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

Оглавление

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

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

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

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

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

  6. Вывод

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

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

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
);

?>

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

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

<p>Папка с компонентами в local → components</p>

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

<p><img src=

firstroute

secondroute

secondroute

firstroute / index.php secondroute / index.php

IncludeComponent(
    "test:first",
    ".default", [
      "TITLE" => "hello from first route",
    ],
    false
);

?>
IncludeComponent(
    "test:second",
    ".default", [
      "TITLE" => "hello from second route",
    ],
    false
);

?>

P.S. Вот и вся структура… Ничего сложного)

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

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

Начнем со структуры компонента.

Структура компонента.

Структура компонента.

в папке dist ничего не находится — её создал сам сборщик (bitrix cli), я выполнил сборку JS сразу в script.js.

Опять же кто хочет — тот может написать JS без bitrix cli. Просто для меня этот инструмент стал немного удобным.

Начнем с класса компонента.

class.php

 [
				'-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




Сразу стоит уточнить почему инициализацию 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
setArResult();
		$this->includeComponentTemplate();
	}
	/* Получение arResult компонента */
	public function setArResult(): void
	{
		$this->arResult = $this->arParams;
	}
}
template.phpstyle.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
setArResult();
		$this->includeComponentTemplate();
	}
	/* Получение arResult компонента */
	public function setArResult(): void
	{
		$this->arResult = $this->arParams;
	}
}
template.php
form -
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 и помещаем на страницу, после чего клонируем контент, и уже потом вставляем на страницу, а если есть то мы просто находим наш тег и клонируем содержимое и так же вставляем на страницу.

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

Встроенный элемент