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

от автора

Покажу как просто и удобно можно сделать главную фишку 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

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

Бесполезно?

18.75% Да3
50% Нет8
31.25% Сжечь еретика5

Проголосовали 16 пользователей. Воздержались 4 пользователя.

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

Оценка статьи по шкале Тинькова

27.27% 5 (Это было не просто смело… это было п*здец как смело)6
9.09% 4 (Круто! Да это ж круто)2
18.18% 3 (Сомнительно… Но окэй)4
9.09% 2 (Это конечно печально, это печально)2
27.27% 1 (Ну что это за п*здец такой?)6
9.09% 0 (Мы все виноваты в этом п*здеце)2

Проголосовали 22 пользователя. Воздержались 3 пользователя.

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

Удивил такой возможностью?

33.33% Да5
66.67% Нет10

Проголосовали 15 пользователей. Воздержались 2 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/838664/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *