Немного предисловия кому интересно:

Предисловие

В данном подходе объединены самые свежие подходы нового ядра D7. В целом я предполагаю разработчики будут развивать данный подход и по моему мнению - он является правильным (Так как оно в идеале и должно быть).

Опять же это лишь мое мнение, но я уже успел попробовать этот подход на реальных проектах и остался им доволен.

В целом все начиналось с того что я захотел как то ускорить / улучшить свою работу в данном фреймворке. А затем я столкнулся с проблемой отсутствия стандартизации к разработке и с тем что моим коллегам тяжело воспринимать такой подход ведь он нигде не описан.

Когда все писали на JS / GayQuery JQuery я начинал отдельно от всех использовать BX, в целом я не очень красиво поступал в этом плане, но мне хотелось прям идеально красивого кода, идеального подхода, и так далее использовать грубо говоря фреймворк не на 20 - 50% а на все 200%. Выжимать из него максимум, и делать какие то крутые штуки.

В целом Bitrix не самый приятный фреймворк для этого, уж очень сильно устарела сама база ядра и подход (как и сам PHP), но исходим из того что имеем. Даже сейчас можно делать реально качественные и крутые штуки на Bitrix. Но проблема в том что документации нет, комьюнити не очень продвигает новые интересные подходы (Просто потому что лень это все изучать без документации и как бонус копаться в ядре)

И сами разработчики говорят следующее:

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

Данная цитата на 17:25

А потом на нам говорят как повысить качество кода. Ну в такие моменты хочется сказать следующее: Ребята допишите документацию полностью и качество кода на проектах Bitrix сразу вырастет.

P.S. да я реально докопался до видео 2018 года. Так как он находится на официальном YouTube канале Битрикс24!!!

Ну и вот я так скажем задался целью пока работаю с данным фреймворком записывать для себя и других разработчиков какие-то новые интересные и классные решения. Возможно они кому то помогут, а возможно мне предложат работу на Битрикс за 300к в наносекунду (нет).

Когда послушал совет и дописал ядро и лишился обновлений и тех. поддержки ядра
Когда послушал совет и дописал ядро и лишился обновлений и тех. поддержки ядра

А потом бедолаги фронты сидят и думают как писать фронт без потери возможности кидать запросы к модулям Bitrix, и по итогу дописывают какую то костыльную API, потому что вынесли фронт отдельно, а сам Bitrix только как backend крутится.

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

Кстати как писать как раз таки SPA я показывал в этой статье:

В официальных документациях и видео на тему Bitrix разработке я данный подход не увидел, а увидел лишь частички которые я сейчас объединил в комплекс. Возможно данных подход кто то уже видел и сам активно использует, но статей про это я не находил. Так что возможно я так скажем основатель данного подхода :D, но даже если это не так просьба не кидайте в меня палки, я не знал.

Для начала определимся что входит в наш подход, на примере будем делать свой компонент.

Клиентская часть

  1. Использование JS библиотеки Bitrix на постоянной основе или только для отправки запросов на сервер.

  2. Использование препроцессоров SCSS / SASS для ускорения написания CSS.

  3. Использование Bitrix CLI для сборки JS в bundle , минификации JS и SCSS в CSS.

  4. Использование browserslist для поддержки браузеров.

  5. (Опционально) Для вставки содержимого в DOM будем использовать DocumentFragment или template который работает так же как и DocumentFragment.

  6. Написание unit тестов для расширений.

Серверная

  1. Использование ООП для написания компонентов и модулей.

  2. Для общего функционала реализуем модуль, для единичного случая делаем AJAX файл или action в классе.

  3. Для рендеринга верстки будем создавать отдельный файл и потом с помощью буферизации с ним работать. Или же возвращать компонент.

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

Для начала работы нам нужно установить nodejs и npm после чего установить пакеты bitrix/cli и node-sass

Я установлю их глобально для себя, но в идеале лучше чтоб данные пакеты были установлены на проекте чтобы был package.json и другие разработчики могли установить зависимости через npm install (например: если вы используете Git)

npm install -g @bitrix/cli node-sass

В функционал нашего компонента входит создание записей, просмотр списка с возможностью удаления, а так же фильтр.

Создание расширения уведомлений

Для начала нужно создать расширение для отображения уведомлений, чтобы когда мы создали запись или удалили её - мы увидели уведомление об этом.

Структура расширения уведомлений.
Структура расширения уведомлений.

Код расширения

модуль main.core - это простыми словами объект глобальный объект BX, так что мы все функции достаем оттуда. Или же ядро библиотеки.

Исходный код

bundle.config.js

Нужен для настройки сборки данного расширения.

module.exports = {
  	// Файл, для которого необходимо выполнить сборку.
	// Необходимо указать относительный путь
	input: './src/Notification.js',
    // Путь к бандлу, который будет создан в результате сборки. 
	// Обычно это ./dist/<extension_name>.bundle.js
	// Необходимо указать относительный путь 
	output: {
      js: './dist/Notification.bundle.js',
      css: './dist/Notification.bundle.css'
    },
    // Неймспейс, в который будут добавлены все экспорты из файла, 
	// указанного в свойстве.
    namespace: 'BX',
    // Включает или отключает минификацию.
    // По умолчанию отключено.
    // Может принимать объект настроек Terser:
    // false — не минифицировать (по умолчанию)
    // true — минифицировать с настройками по умолчанию 
    // object — минифицировать с указанными настройками 
    minification: true,
    // Включает или отключает создание Source Maps файлов 
    sourceMap: false,
};

config.php

Нужен для того чтобы объявить данный скрипт модулем. И появилась возможность его подключать через ядро, и рантаймить

<?php
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true)
{
	die();
}

return [
  	// Путь к `css` файлу или массив путей 
	// Рекомендуется указывать относительный путь
    // Можно так же указать массив.
	'css' => './dist/Notification.bundle.css',
    // Путь к `js` файлу или массив путей 
	// Рекомендуется указывать относительный путь
    // Можно так же указать массив.
	'js' => './dist/Notification.bundle.js',
  	// Список зависимостей
	// Необходимо указать имена расширений, которые должны быть 
	// подключены перед подключением текущего расширения
	// Зависимости подключаются рекурсивно и с учетом указанного порядка 
	'rel' => [
		'main.core',
	],
  	// Запрещает подключать `main.core` автоматически, как зависимость
	// По умолчанию `false` — `main.core` подключается. 
	// При сборке бандла значение параметра устанавливается автоматически
	// если в коде нет прямой зависимости на `main.core`
	'skip_core' => false,
];

Notification.js

//Подключение SCSS чтобы он собирался
import "./Notification.scss";

//Импорт функций из BX
import {
  append,
  create,
  findChildrenByClassName,
  findParent,
  insertBefore,
  remove,
} from "main.core";

//Экспортируем функцию чтобы потом её можно было вызывать через BX.Notification

export class Notification {
  constructor(message, status) {
    this.notificationsList =
      this.getNotificationsList() || this.buildNotificationsList();
    this.buildNotification(message, status);
  }
  getNotificationsList() {
    return document.getElementById("notifications-list");
  }
  buildNotificationsList() {
    const fragment = create("ul", {
      attrs: {
        id: "notifications-list",
        className: "notifications-list",
      },
      children: [
        create("button", {
          attrs: {
            type: "button",
            id: "notifications-list__close-all",
          },
          events: {
            //Вешаем обработчик событий на кнопку чтобы удалить все уведомления
            click: ({ target }) => {
              const parent = findParent(target);
              const notifications = findChildrenByClassName(
                parent,
                "notification"
              );

              notifications.forEach((notification) => remove(notification));
            },
          },
          text: "Закрыть все",
        }),
      ],
    });
    append(fragment, document.body);
    return this.getNotificationsList();
  }

  buildNotification(message, status) {
    const notification = create("li", {
      attrs: {
        className: `notification ${status ? "notification_" + status : ""}`,
      },
      children: [
        create("p", {
          attrs: {
            className: "notification__message",
          },
          text: message,
        }),
        create("button", {
          attrs: {
            type: "button",
            className: "notification__close",
          },
          text: "x",
          events: {
            //Вешаем обработчик на кнопку, чтобы удалить уведомление.
            click: ({ target }) => remove(findParent(target)),
          },
        }),
      ],
    });
    this.mountNotification(notification);
  }
  mountNotification(notification) {
    insertBefore(
      notification,
      document.getElementById("notifications-list__close-all")
    );
  }
}

Notification.scss

@keyframes show-notification {
  from {
    left: 200px;
    opacity: 0;
  }

  to {
    opacity: 1;
    left: 0;
  }
}

#notifications-list {
  position: fixed;
  bottom: 0;
  right: 0;
  padding: 25px;

  &:not(:has(.notification)) {
    display: none;
  }

  button {
    cursor: pointer;
    transition: .3s;

    &:hover {
      background-color: #4a4a7c !important;
      color: white;
    }
  }

  &__close-all {
    width: 100%;
    background-color: #f3f3f3;
    box-shadow: 0px 0px 25px 0px #00000035;
    border-radius: 8px;
    text-align: center;
    border: none;
    font-weight: 600;
    padding: 8px;
  }

  .notification {
    position: relative;
    animation: show-notification .5s linear;
    display: flex;
    gap: 8px;
    margin-bottom: 20px;
    background-color: #f3f3f3;
    padding: 12px;
    align-items: center;
    border-radius: 8px;
    justify-content: space-between;
    width: 250px;
    box-sizing: border-box;
    box-shadow: 0px 0px 25px 0px #00000035;
    border-bottom-right-radius: 24px;

    &__close {
      border-radius: 20px;
      height: 30px;
      aspect-ratio: 4/4;
      background-color: transparent;
      border: none;
      margin-top: auto;
    }

    &__message {
      margin: 0;
      word-break: break-all;
    }
  }
}

Запускаем сборку через терминал находясь в папке local или в папке расширения. При желании можно всегда указать какое расширение собираем.

bitrix build

Вот команда чтобы посмотреть полный список возможностей - bitrix help

И в результате мы в любое время можем подключить наше расширение через BX.Runtime.loadExtension('site.notification') или же мы можем в нашем следующем скрипте импортировать наше расширение чтобы работать с ним.

И так как мы сделали Namespace и сделали export нужных нам функций, мы можем обращаться к функциям объекта по namespace (BX.Notification)

А вот код который мы собрали через Bitrix CLI, чтобы вы убедились что наш код минифицирован, а css добавил поддержку -webkit-, -ms- и собрал SCSS в CSS.

Использование нашего расширения в консоли разработчика
Использование нашего расширения в консоли разработчика
Собранный код
!function(t,i){"use strict";var n=function(){function t(t,i){this.notificationsList=this.getNotificationsList()||this.buildNotificationsList(),this.buildNotification(t,i)}var n=t.prototype;return n.getNotificationsList=function(){return document.getElementById("notifications-list")},n.buildNotificationsList=function(){var t=i.create("ul",{attrs:{id:"notifications-list",className:"notifications-list"},children:[i.create("button",{attrs:{type:"button",id:"notifications-list__close-all"},events:{click:function(t){var n=t.target,o=i.findParent(n);i.findChildrenByClassName(o,"notification").forEach((function(t){return i.remove(t)}))}},text:"Закрыть все"})]});return i.append(t,document.body),this.getNotificationsList()},n.buildNotification=function(t,n){var o=i.create("li",{attrs:{className:"notification "+(n?"notification_"+n:"")},children:[i.create("p",{attrs:{className:"notification__message"},text:t}),i.create("button",{attrs:{type:"button",className:"notification__close"},text:"x",events:{click:function(t){var n=t.target;return i.remove(i.findParent(n))}}})]});this.mountNotification(o)},n.mountNotification=function(t){i.insertBefore(t,document.getElementById("notifications-list__close-all"))},t}();t.Notification=n}(this.BX=this.BX||{},BX);
//# sourceMappingURL=Notification.bundle.js.map

Для удобства форматирую через Prettier

!(function (t, i) {
  "use strict";
  var n = (function () {
    function t(t, i) {
      (this.notificationsList =
        this.getNotificationsList() || this.buildNotificationsList()),
        this.buildNotification(t, i);
    }
    var n = t.prototype;
    return (
      (n.getNotificationsList = function () {
        return document.getElementById("notifications-list");
      }),
      (n.buildNotificationsList = function () {
        var t = i.create("ul", {
          attrs: { id: "notifications-list", className: "notifications-list" },
          children: [
            i.create("button", {
              attrs: { type: "button", id: "notifications-list__close-all" },
              events: {
                click: function (t) {
                  var n = t.target,
                    o = i.findParent(n);
                  i.findChildrenByClassName(o, "notification").forEach(
                    function (t) {
                      return i.remove(t);
                    }
                  );
                },
              },
              text: "Закрыть все",
            }),
          ],
        });
        return i.append(t, document.body), this.getNotificationsList();
      }),
      (n.buildNotification = function (t, n) {
        var o = i.create("li", {
          attrs: {
            className: "notification " + (n ? "notification_" + n : ""),
          },
          children: [
            i.create("p", {
              attrs: { className: "notification__message" },
              text: t,
            }),
            i.create("button", {
              attrs: { type: "button", className: "notification__close" },
              text: "x",
              events: {
                click: function (t) {
                  var n = t.target;
                  return i.remove(i.findParent(n));
                },
              },
            }),
          ],
        });
        this.mountNotification(o);
      }),
      (n.mountNotification = function (t) {
        i.insertBefore(
          t,
          document.getElementById("notifications-list__close-all")
        );
      }),
      t
    );
  })();
  t.Notification = n;
})((this.BX = this.BX || {}), BX);
//# sourceMappingURL=Notification.bundle.js.map
@-webkit-keyframes show-notification {
  from {
    left: 200px;
    opacity: 0; }
  to {
    opacity: 1;
    left: 0; } }

@keyframes show-notification {
  from {
    left: 200px;
    opacity: 0; }
  to {
    opacity: 1;
    left: 0; } }

#notifications-list {
  position: fixed;
  bottom: 0;
  right: 0;
  padding: 25px; }
  #notifications-list:not(:has(.notification)) {
    display: none; }
  #notifications-list button {
    cursor: pointer;
    -webkit-transition: .3s;
    -o-transition: .3s;
    transition: .3s; }
    #notifications-list button:hover {
      background-color: #4a4a7c !important;
      color: white; }
  #notifications-list__close-all {
    width: 100%;
    background-color: #f3f3f3;
    -webkit-box-shadow: 0px 0px 25px 0px #00000035;
            box-shadow: 0px 0px 25px 0px #00000035;
    border-radius: 8px;
    text-align: center;
    border: none;
    font-weight: 600;
    padding: 8px; }
  #notifications-list .notification {
    position: relative;
    -webkit-animation: show-notification .5s linear;
            animation: show-notification .5s linear;
    display: -webkit-box;
    display: -ms-flexbox;
    display: flex;
    gap: 8px;
    margin-bottom: 20px;
    background-color: #f3f3f3;
    padding: 12px;
    -webkit-box-align: center;
        -ms-flex-align: center;
            align-items: center;
    border-radius: 8px;
    -webkit-box-pack: justify;
        -ms-flex-pack: justify;
            justify-content: space-between;
    width: 250px;
    -webkit-box-sizing: border-box;
            box-sizing: border-box;
    -webkit-box-shadow: 0px 0px 25px 0px #00000035;
            box-shadow: 0px 0px 25px 0px #00000035;
    border-bottom-right-radius: 24px; }
    #notifications-list .notification__close {
      border-radius: 20px;
      height: 30px;
      aspect-ratio: 4/4;
      background-color: transparent;
      border: none;
      margin-top: auto; }
    #notifications-list .notification__message {
      margin: 0;
      word-break: break-all; }

Такой подход уменьшает размер нашего JS файла благодаря чему наша страница быстрее загружается. На небольшом проекте это особо заметно не будет, но вот на большом другое дело, а так же делает код более трудным в понимании если человек будет рытся в коде со стороны клиента.

Как выглядит наш код в браузере.
Как выглядит наш код в браузере.

Так же стоит отметить то что пользователь не имеет доступа к нашим исходникам. (Я под админом).

вкладка sources в инструментах разработчика.
вкладка sources в инструментах разработчика.

Далее переходим к тестированию нашего расширения.

Для этого создадим в корне расширения папку test и файл с названием как у нашего скрипта только с постфиксом test.

Структура расширения
Структура расширения

Если у нас к примеру в папке src есть еще вложенность - то нам нужно повторить эту же вложенность в папке test.

Пример
Пример

После этого напишем в Notification.test.js самые простые тесты. И перед этим закоментируем в нашем расширении импорт scss файла иначе получим ошибку во время проведения тестов.

Notification.js в папке src
Notification.js в папке src
// Подключаю функцию проверки на типы из ядра и свою функцию для тестирования
import { Notification } from "myextensions.notification";
import { type } from "main.core";

console.log(Notification);
// Описываем тут для нашей функции тесты
describe("Notification", () => {
  // Проверяем результат работы фукнции это Dom элемент
  it("return type DomNode?", () => {
    assert(type.isDomNode(new Notification()));
  });
  // Проверяем результат работы фукнции это undefined
  it("return type undefined", () => {
    assert(type.isUndefined(new Notification()));
  });
  it("return type object", () => {
    assert(type.isObject(new Notification()));
  });
});

Далее в терминале пишем команду bitrix test и смотрим результат наших тестов.

Результаты тестов
Результаты тестов

Как мы видим в целом все так и должно было получится ведь наша функция возвращает объект. Сильно в эту тему углубляться не буду, написание тестов это прям отдельная стихия.

НЕ РЕКОМЕНДУЮ!!!

использовать команды сборщика:

  • bitrix build -w

  • bitrix build -t

К сожалению мы все еще работаем с Bitrix по этому не может быть все так хорошо. Данные команды работают очень плохо и не стабильно: к примеру bitrix build -t собирает bundle в любом случае даже если все тесты провалены. А bitrix build -w во время наблюдения не собирает CSS зачастую. По этому лучше запускать это все ручками, так получается надежнее.

После тестов раскомитим импорт scss файла. И запустим сборку чтобы получить финальный результат.

Сюда можно было бы добавить еще локализацию, но я её никогда не использовал. Если надо могу потом дописать как это делается или же написать про это - отдельную статью.

Если нужно собирать код для определенных браузеров и опираясь на их версии, нам нужно создать в корне файл с названием .browserslistrc.

А так же добавить в bundle.config.js поддержку browserslist.

module.exports = {
  // Файл, для которого необходимо выполнить сборку.
  // Необходимо указать относительный путь
  input: "./src/Notification.js",
  // Путь к бандлу, который будет создан в результате сборки.
  // Обычно это ./dist/<extension_name>.bundle.js
  // Необходимо указать относительный путь
  output: {
    js: "./dist/Notification.bundle.js",
    css: "./dist/Notification.bundle.css",
  },
  // Неймспейс, в который будут добавлены все экспорты из файла,
  // указанного в свойстве.
  namespace: "BX",
  // Включает или отключает минификацию.
  // По умолчанию отключено.
  // Может принимать объект настроек Terser:
  // false — не минифицировать (по умолчанию)
  // true — минифицировать с настройками по умолчанию
  // object — минифицировать с указанными настройками
  minification: true,
  //Сборка будет происходить с учетом .browserslistrc
  browserslist: true,
  // Включает или отключает создание Source Maps файлов
  sourceMap: false,
};
Структура нашего расширения
Структура нашего расширения

Далее внутри файла уже указываем нашу конфигурацию, для тех кто делает это впервые, вам поможет официальный сайт в подборе конфигураций.

И после чего запустить сборку.

Можете поиграться с этим и увидеть разницу сборки с разными версиями браузера.

Написание компонента

Далее приступаем к созданию нашего компонента.

Для начала создам highloadblock, он будет максимально простой для наших целей.

Почему хайлоад

Ну потому что мне не нравится работа с инфоблоками в старом и новом ядре. + считается что highload блоки более оптимизированы и быстрые, хотя мне просто нравится их API.

Но вы можете использовать и инфоблоки вам никто не запрещает.

Поля хайлоадблока с тестовой записью.
Поля хайлоадблока с тестовой записью.

После этого создаем свой компонент и подключаем на странице. И передаем туда параметр своего хайлоад блока.

<?php $APPLICATION->IncludeComponent(
    "test:todolist", ".default", [
        'HIGHLOAD_BLOCK' => 'TodoList',
        'ADDED_TASK_NOTIFICATION_TEXT' => 'Задача успешно добавлена',
    ],
    false
);
Структура нашего компонента
Структура нашего компонента
Код компонента
class.php
<?php

/**
 * Проверка на пролог
 */
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
    die();
}

/** 
 * Подключение зависимостей
 */
use Bitrix\Main\Loader,
    Bitrix\Main\Entity,
    Bitrix\Main\Web\Json,
    Bitrix\Main\UI\Extension,
    Bitrix\Highloadblock\HighloadBlockTable,
    Bitrix\Main\Engine\Contract\Controllerable;

/* Подключаю нотификации */
Extension::load('myextensions.notification');

/**
* Подключения модуля по работе с хайлоад блоками
*/
Loader::includeModule("highloadblock");

/** Создание своего кастомного ООП класса с наследованием от CBitrixComponent 
 * и реализацией интерфейса Controllerable
 * для работы с AJAX запросами от JS библиотеки Bitrix
 */
class TodoList extends CBitrixComponent implements Controllerable
{
    /**
     * Получение параметров компонента которые будут учтены при обращении к экшену компонента 
     * через AJAX для этого надо будет при вызове этого action
     * передать signedParameters в параметры BX.ajax.runComponentAction;
     * @return array
     */
	public function listKeysSignedParameters() : array
	{
		return [
            'HIGHLOAD_BLOCK',
            'ADDED_TASK_NOTIFICATION_TEXT'
		];
	}
    /** Настройка экшенов которые к которым будем обращаться через AJAX ядра
     * @return array
    */
    public function configureActions() : array
    {
        /**Отключаем у наших экшенов требования к авторизации пользователя на сайте */
        return [
            'getFilteredTasks' => [
                '-prefilters' => [
                    ActionFilter\Authentication::class,
                ],
            ],
            'addTask' => [
                '-prefilters' => [
                    ActionFilter\Authentication::class,
                ],
            ], 
        ];
    }
    /**Выполнение компонента и подключение шаблона */
    public function executeComponent() : void
    {
        $this->getArResult();
        $this->includeComponentTemplate();
    }
    /** Получение ArResult */
    protected function getArResult(): void
    {
        /*Получаем todo задачи */
        $arResult['TASKS'] = $this->getTasks();

        /**Параметры которые мы передадим в JS
         * чтобы когда мы обращались к нашему экшену через AJAX,
         * у нашего компонента были arParams, и он мог выполнить логику*/
        $arResult['SIGNED_PARAMETERS'] = Json::encode($this->getSignedParameters());

        $this->arResult = $arResult;
    }

    /** Получение todo задач по фильтру, если входных параметров нет используем фильтр по умолчанию
     * @return array
     */
    protected function getTasks(array $filter = []): array
    {
        return HighloadBlockTable::compileEntity($this->arParams['HIGHLOAD_BLOCK'])->getDataClass()::getList([
            'select' => ['*'],
            'order' => ['ID' => 'ASC'],
            'filter' => $filter,
        ])->fetchAll();
    }

    
    /**Фильтруем пост */
    protected function filterPost(): void
    {
        /**Удаляем из поста параметры потому что они нам больше не нужны, 
         * и будут мешать когда мы будем передавать в POST в filter */
        unset($_POST['signedParameters']);
        
        $_POST = array_filter($_POST, fn($value) => !empty(trim($value)));
    }
    
    /**Экшен по получению отфильтрованных задач */
    public function getFilteredTasksAction(): array
    {
        $this->filterPost();
        return $this->getTasks($_POST);
    }
    /**Экшен изменения статуса у задания */
    public function changeTaskStatusAction(int $taskId, string $taskCompleted): void
    {
        /**Обновляем статус */
        HighloadBlockTable::compileEntity($this->arParams['HIGHLOAD_BLOCK'])->getDataClass()
            ::update($taskId, ['UF_COMPLETED' => ($taskCompleted === "true")]);
    }
    /**Экшен добавления в базу */
    public function addTaskAction(): string
    {
        $this->filterPost();

        $addedTask = HighloadBlockTable::compileEntity($this->arParams['HIGHLOAD_BLOCK'])->getDataClass()::add($_POST);

        return $this->arParams['ADDED_TASK_NOTIFICATION_TEXT'];
    }
}

template.php
<section id="todo" class="todo">
    <h2>TodoList</h2>
    <form class="todo__filter form" id="todo__filter">
        <div class="form__field">
            <label for="">Поиск по тексту</label>
            <input type="text" name="%UF_TEXT">
        </div>
        <div class="form__buttons">
            <button class="form__button" type="submit">Найти</button>
        </div>
    </form>
    <ul class="todo__list" id="todo__list">
        <?foreach ($arResult['TASKS'] as $key => $task):?>
        <li class="todo__item">
            <label>
                <input type="checkbox" <?=$task['UF_COMPLETED'] ? 'checked' : '' ?> data-id="<?=$task['ID']?>" />
                <?=$task['UF_TEXT']?>
            </label>
        </li>
        <?endforeach?>
    </ul>
    <form id="todo__add-form" class="form">
        <div class="form__field">
            <label for="">Новая задача</label>
            <textarea name="UF_TEXT"></textarea>
        </div>
        <div class="form__buttons">
            <button class="form__button" type="submit">Добавить</button>
            <button class="form__button" type="reset">Сбросить</button>
        </div>
    </form>
</section>


<script>
// Передаем SIGNED_PARAMETERS в JS глобальный объкт BX.message
BX.message({
    signedParameters: <?=$arResult['SIGNED_PARAMETERS']?>
})
//Рекомендую иницализировать скрипт компонента тут. Далее в статье объясню почему.
new BX.TodoList()
</script>

/src/TodoList.js
import "./TodoList.scss";

import {
  PreventDefault,
  ajax,
  append,
  bind,
  cleanNode,
  create,
  createFragment,
  data,
  findChildren,
  fireEvent,
  message,
} from "main.core";

/**Подключаем наш экстеншен с нотификациями который сделали ранее */
import { Notification } from "myextensions.notification";

/**Так же создаем класс */
export class TodoList {
  constructor() {
    /**
     * Function provides access to language messages.
     * If the param parameter is a string, the language message with the identifier param will be returned.
     * Otherwise, the parameter is interpreted as an object of the form {identifier: message, identifier: message}
     * language messages are added to the current ones (overwriting existing ones).cd
     *
     * @param {string|Object} param - The identifier or an object of language messages.
     * @returns {string} - The language message with the given identifier.
     * @example <caption>
     *
     * BX.message({test: 123}) //returns undefined
     *
     * BX.message('test') //returns 123
     * </caption>
     */
    this.signedParameters = message("signedParameters");

    this.todoList = document.getElementById("todo__list");
    this.filter = document.getElementById("todo__filter");
    this.addForm = document.getElementById("todo__add-form");
    /**Выносим накидывание ивентов в отдельную функцию, т.к. наши todo задачи, будут динамическими */
    this.initTaskEvent();

    /**
     * Binds an event handler to an element or a collection of elements.
     * @param {Node} el - The element to bind the event handler to.
     * @param {String} event - The name of the event to bind the handler to.
     * @param {Function} handler - The function to execute when the event is triggered.
     */
    bind(this.filter, "submit", (e) => this.submitFilter(e, this));
    bind(this.addForm, "submit", (e) => this.submitAddForm(e, this));
  }
  /**Накидываем события на наши задачи. */
  initTaskEvent() {
    /**
     * Finds all direct child nodes of the specified DOMNode.
     *
     * @param {Node} node - The DOMNode to search for children.
     * @param {Object} [params] - An optional object containing parameters to filter the child nodes.",
     * @param {string} [params.tagName] - The tag name of the desired child node.",
     * @param {string} [params.className] - The class name of the desired child node.",
     * @param {Object} [params.attrs] - An object containing attribute key-value pairs to filter the child nodes.",
     * @param {Object} [params.props] - An object containing property key-value pairs to filter the child nodes.",
     * @param {boolean} [recursive=false] - If true, also search for children of the children.
     * @returns {array<Node>} - An array of all direct child nodes.
     */
    /**Получаем все наши задачи */
    const tasks = findChildren(this.todoList, { tagName: "input" }, true);

    tasks.forEach((task) => {
      const taskId = data(task, "id");
      /**Используем здесь замыкание чтобы когда человек менял data-id, значение все равно приходило на бэкэнд старое */
      bind(task, "change", (e) =>
        this.changeTaskStatus(taskId, e.target.checked, this)
      );
    });
  }
  /**Отправка форму фильтра на бэкэнд чтобы получить задачи по фильтру */
  async submitFilter(e, classContext) {
    /**
     * Prevents the default action of an event from occurring.
     * @param {Event} e - The event object.
     */
    PreventDefault(e);
    /**
     * This function makes an AJAX request to a component.
     * @param {string} componentName - The name of the component. Example: 'mysitetemplate:mycomponent
     * @param {string} action - The name of the function in ajax.php or class.php .
     * @param {Object} [params] - The parameters for the request.
     * @param {string} [params.mode] - The mode for the request. Can be either "class" or "ajax".
     * @param {string} [params.method] - default POST
     * @param {Object|FormData} [params.data] - The data for the request.
     * @param {boolean} [params.json] - A flag indicating whether to send JSON data. In this case, when the request is sent, the Content-type header
     * will be set to application/json, and the controllers will be able to access the original JSON, which will make it easier to work with numbers and empty values.
     * @param {Object} [params.navigation] - The navigation data for the request.
     * @param {string|Object} [params.analyticsLabel] - The analytics label for the request. It is used as a marker for analytics to indicate popular content.
     * @param {string} [params.signedParameters] - The signed parameters for the request.
     * @returns {Promise} - A promise that resolves with the response from the server.
     */
    /**Если на бэкэнде будет error он автоматически попадет в catch*/
    try {
      const { data } = await ajax.runComponentAction(
        "test:todolist",
        "getFilteredTasks",
        {
          mode: "class",
          data: new FormData(classContext.filter),
          signedParameters: classContext.signedParameters,
        }
      );
      /**Получаем наши задачи и рендерим их */
      const template = classContext.renderTasks(data);

      /**
       * Cleans the specified node and its descendants.
       * @param {Node} node - The node to clean.
       * @param {boolean} [removeSelf=false] - Indicates whether to remove the node itself.
       */
      /**Очищаем содержимое нашего списка */
      cleanNode(classContext.todoList);
      /**
       * Appends a node to the end of another node.
       *
       * @param {Node} node - The node to be appended.
       * @param {Node} dstNode - The destination node where the node will be appended.
       */
      /**Вставляем наше значение */
      append(template.content, classContext.todoList);
      /**Заново иницилизируем обработчики на задачах */
      classContext.initTaskEvent();
    } catch ({ errors }) {
      /**Выводим ошибку пользователю на экран если что то сломалось */
      errors.forEach((error) => new Notification(error.message));
    }
  }
  async submitAddForm(e, classContext) {
    PreventDefault(e);
    try {
      const { data } = await ajax.runComponentAction(
        "test:todolist",
        "addTask",
        {
          mode: "class",
          data: new FormData(classContext.addForm),
          signedParameters: classContext.signedParameters,
        }
      );
      new Notification(data);
    } catch ({ errors }) {
      errors.forEach((error) => new Notification(error.message));
    }
  }
  /**Отправляем данные на бэкэнд что статус задания изменился */
  async changeTaskStatus(taskId, taskCompleted, classContext) {
    try {
      await ajax.runComponentAction("test:todolist", "changeTaskStatus", {
        mode: "class",
        data: {
          taskId: taskId,
          taskCompleted: taskCompleted,
        },
        signedParameters: classContext.signedParameters,
      });
    } catch ({ errors }) {
      errors.forEach((error) => new Notification(error.message));
    }
  }
  /**Создание template с нашими задачами */
  renderTasks(tasks) {
    const html = tasks
      .map((task) => {
        return /*html*/ `
        <li class="todo__item">
          <label>
            <input
              type="checkbox"
              ${Number(task["UF_COMPLETED"]) && "checked"}
              data-id="${task["ID"]}"
            />
            ${task["UF_TEXT"]}
          </label>
        </li>
      `;
      })
      .join(" ");
    return create("template", { html: html });
  }
}

/src/TodoList.scss
$shadow: 0px 0px 5px 0px #00000040;

#todo {
  padding: 100px;
  max-width: 900px;
  box-sizing: border-box;
  width: 100%;
  margin: auto;

  .form {
    $filter-color: #f7f7f7;

    border-radius: 12px;
    border: 1px solid $filter-color;
    background-color: $filter-color;
    padding: 12px;

    &__field {
      margin-bottom: 12px;
      display: flex;
      gap: 4px;
      flex-direction: column;
    }

    &__button {
      outline: none;
      border: none;
      background-color: #f7f7f7;
      border-radius: 6px;
      padding: 6px 12px;
      cursor: pointer;
      transition: background-color .2s ease-in-out;
      box-shadow: $shadow;

      &[type="submit"] {
        background-color: #b6b6ff;

        &:hover {
          background-color: #9a9af1;
        }
      }

      &:hover {
        background-color: #e6e6e6;
      }
    }
  }



  .todo {
    &__list {
      padding: 0;
      list-style: none;
      margin: 0;
      height: 500px;
      overflow: auto;
    }

    &__item {
      margin-bottom: 6px;
      padding: 6px;

      cursor: pointer;
    }
  }
}

bundle.config.js
module.exports = {
  input: "./src/TodoList.js",

  output: {
    js: "./script.js",
    css: "./style.css",
  },
  namespace: "BX",
  minification: true,
  treeshake: false,
};

config.php
<?php
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true)
{
	die();
}

return [
	'css' => 'script.css',
	'js' => 'script.js',
	'rel' => [
		'main.core',
		'myextensions.notification',
	],
	'skip_core' => false,
];

script.js
!(function (t, e, r) {
  "use strict";
  function n() {
    /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ n =
      function () {
        return t;
      };
    var t = {},
      e = Object.prototype,
      r = e.hasOwnProperty,
      o =
        Object.defineProperty ||
        function (t, e, r) {
          t[e] = r.value;
        },
      a = "function" == typeof Symbol ? Symbol : {},
      i = a.iterator || "@@iterator",
      c = a.asyncIterator || "@@asyncIterator",
      s = a.toStringTag || "@@toStringTag";
    function u(t, e, r) {
      return (
        Object.defineProperty(t, e, {
          value: r,
          enumerable: !0,
          configurable: !0,
          writable: !0,
        }),
        t[e]
      );
    }
    try {
      u({}, "");
    } catch (t) {
      u = function (t, e, r) {
        return (t[e] = r);
      };
    }
    function l(t, e, r, n) {
      var a = e && e.prototype instanceof d ? e : d,
        i = Object.create(a.prototype),
        c = new T(n || []);
      return o(i, "_invoke", { value: k(t, r, c) }), i;
    }
    function f(t, e, r) {
      try {
        return { type: "normal", arg: t.call(e, r) };
      } catch (t) {
        return { type: "throw", arg: t };
      }
    }
    t.wrap = l;
    var h = {};
    function d() {}
    function p() {}
    function v() {}
    var m = {};
    u(m, i, function () {
      return this;
    });
    var y = Object.getPrototypeOf,
      g = y && y(y(P([])));
    g && g !== e && r.call(g, i) && (m = g);
    var w = (v.prototype = d.prototype = Object.create(m));
    function b(t) {
      ["next", "throw", "return"].forEach(function (e) {
        u(t, e, function (t) {
          return this._invoke(e, t);
        });
      });
    }
    function x(t, e) {
      var n;
      o(this, "_invoke", {
        value: function (o, a) {
          function i() {
            return new e(function (n, i) {
              !(function n(o, a, i, c) {
                var s = f(t[o], t, a);
                if ("throw" !== s.type) {
                  var u = s.arg,
                    l = u.value;
                  return l &&
                    "object" == babelHelpers.typeof(l) &&
                    r.call(l, "__await")
                    ? e.resolve(l.__await).then(
                        function (t) {
                          n("next", t, i, c);
                        },
                        function (t) {
                          n("throw", t, i, c);
                        }
                      )
                    : e.resolve(l).then(
                        function (t) {
                          (u.value = t), i(u);
                        },
                        function (t) {
                          return n("throw", t, i, c);
                        }
                      );
                }
                c(s.arg);
              })(o, a, n, i);
            });
          }
          return (n = n ? n.then(i, i) : i());
        },
      });
    }
    function k(t, e, r) {
      var n = "suspendedStart";
      return function (o, a) {
        if ("executing" === n) throw new Error("Generator is already running");
        if ("completed" === n) {
          if ("throw" === o) throw a;
          return j();
        }
        for (r.method = o, r.arg = a; ; ) {
          var i = r.delegate;
          if (i) {
            var c = E(i, r);
            if (c) {
              if (c === h) continue;
              return c;
            }
          }
          if ("next" === r.method) r.sent = r._sent = r.arg;
          else if ("throw" === r.method) {
            if ("suspendedStart" === n) throw ((n = "completed"), r.arg);
            r.dispatchException(r.arg);
          } else "return" === r.method && r.abrupt("return", r.arg);
          n = "executing";
          var s = f(t, e, r);
          if ("normal" === s.type) {
            if (((n = r.done ? "completed" : "suspendedYield"), s.arg === h))
              continue;
            return { value: s.arg, done: r.done };
          }
          "throw" === s.type &&
            ((n = "completed"), (r.method = "throw"), (r.arg = s.arg));
        }
      };
    }
    function E(t, e) {
      var r = e.method,
        n = t.iterator[r];
      if (void 0 === n)
        return (
          (e.delegate = null),
          ("throw" === r &&
            t.iterator.return &&
            ((e.method = "return"),
            (e.arg = void 0),
            E(t, e),
            "throw" === e.method)) ||
            ("return" !== r &&
              ((e.method = "throw"),
              (e.arg = new TypeError(
                "The iterator does not provide a '" + r + "' method"
              )))),
          h
        );
      var o = f(n, t.iterator, e.arg);
      if ("throw" === o.type)
        return (e.method = "throw"), (e.arg = o.arg), (e.delegate = null), h;
      var a = o.arg;
      return a
        ? a.done
          ? ((e[t.resultName] = a.value),
            (e.next = t.nextLoc),
            "return" !== e.method && ((e.method = "next"), (e.arg = void 0)),
            (e.delegate = null),
            h)
          : a
        : ((e.method = "throw"),
          (e.arg = new TypeError("iterator result is not an object")),
          (e.delegate = null),
          h);
    }
    function L(t) {
      var e = { tryLoc: t[0] };
      1 in t && (e.catchLoc = t[1]),
        2 in t && ((e.finallyLoc = t[2]), (e.afterLoc = t[3])),
        this.tryEntries.push(e);
    }
    function _(t) {
      var e = t.completion || {};
      (e.type = "normal"), delete e.arg, (t.completion = e);
    }
    function T(t) {
      (this.tryEntries = [{ tryLoc: "root" }]),
        t.forEach(L, this),
        this.reset(!0);
    }
    function P(t) {
      if (t) {
        var e = t[i];
        if (e) return e.call(t);
        if ("function" == typeof t.next) return t;
        if (!isNaN(t.length)) {
          var n = -1,
            o = function e() {
              for (; ++n < t.length; )
                if (r.call(t, n)) return (e.value = t[n]), (e.done = !1), e;
              return (e.value = void 0), (e.done = !0), e;
            };
          return (o.next = o);
        }
      }
      return { next: j };
    }
    function j() {
      return { value: void 0, done: !0 };
    }
    return (
      (p.prototype = v),
      o(w, "constructor", { value: v, configurable: !0 }),
      o(v, "constructor", { value: p, configurable: !0 }),
      (p.displayName = u(v, s, "GeneratorFunction")),
      (t.isGeneratorFunction = function (t) {
        var e = "function" == typeof t && t.constructor;
        return (
          !!e && (e === p || "GeneratorFunction" === (e.displayName || e.name))
        );
      }),
      (t.mark = function (t) {
        return (
          Object.setPrototypeOf
            ? Object.setPrototypeOf(t, v)
            : ((t.__proto__ = v), u(t, s, "GeneratorFunction")),
          (t.prototype = Object.create(w)),
          t
        );
      }),
      (t.awrap = function (t) {
        return { __await: t };
      }),
      b(x.prototype),
      u(x.prototype, c, function () {
        return this;
      }),
      (t.AsyncIterator = x),
      (t.async = function (e, r, n, o, a) {
        void 0 === a && (a = Promise);
        var i = new x(l(e, r, n, o), a);
        return t.isGeneratorFunction(r)
          ? i
          : i.next().then(function (t) {
              return t.done ? t.value : i.next();
            });
      }),
      b(w),
      u(w, s, "Generator"),
      u(w, i, function () {
        return this;
      }),
      u(w, "toString", function () {
        return "[object Generator]";
      }),
      (t.keys = function (t) {
        var e = Object(t),
          r = [];
        for (var n in e) r.push(n);
        return (
          r.reverse(),
          function t() {
            for (; r.length; ) {
              var n = r.pop();
              if (n in e) return (t.value = n), (t.done = !1), t;
            }
            return (t.done = !0), t;
          }
        );
      }),
      (t.values = P),
      (T.prototype = {
        constructor: T,
        reset: function (t) {
          if (
            ((this.prev = 0),
            (this.next = 0),
            (this.sent = this._sent = void 0),
            (this.done = !1),
            (this.delegate = null),
            (this.method = "next"),
            (this.arg = void 0),
            this.tryEntries.forEach(_),
            !t)
          )
            for (var e in this)
              "t" === e.charAt(0) &&
                r.call(this, e) &&
                !isNaN(+e.slice(1)) &&
                (this[e] = void 0);
        },
        stop: function () {
          this.done = !0;
          var t = this.tryEntries[0].completion;
          if ("throw" === t.type) throw t.arg;
          return this.rval;
        },
        dispatchException: function (t) {
          if (this.done) throw t;
          var e = this;
          function n(r, n) {
            return (
              (i.type = "throw"),
              (i.arg = t),
              (e.next = r),
              n && ((e.method = "next"), (e.arg = void 0)),
              !!n
            );
          }
          for (var o = this.tryEntries.length - 1; o >= 0; --o) {
            var a = this.tryEntries[o],
              i = a.completion;
            if ("root" === a.tryLoc) return n("end");
            if (a.tryLoc <= this.prev) {
              var c = r.call(a, "catchLoc"),
                s = r.call(a, "finallyLoc");
              if (c && s) {
                if (this.prev < a.catchLoc) return n(a.catchLoc, !0);
                if (this.prev < a.finallyLoc) return n(a.finallyLoc);
              } else if (c) {
                if (this.prev < a.catchLoc) return n(a.catchLoc, !0);
              } else {
                if (!s)
                  throw new Error("try statement without catch or finally");
                if (this.prev < a.finallyLoc) return n(a.finallyLoc);
              }
            }
          }
        },
        abrupt: function (t, e) {
          for (var n = this.tryEntries.length - 1; n >= 0; --n) {
            var o = this.tryEntries[n];
            if (
              o.tryLoc <= this.prev &&
              r.call(o, "finallyLoc") &&
              this.prev < o.finallyLoc
            ) {
              var a = o;
              break;
            }
          }
          a &&
            ("break" === t || "continue" === t) &&
            a.tryLoc <= e &&
            e <= a.finallyLoc &&
            (a = null);
          var i = a ? a.completion : {};
          return (
            (i.type = t),
            (i.arg = e),
            a
              ? ((this.method = "next"), (this.next = a.finallyLoc), h)
              : this.complete(i)
          );
        },
        complete: function (t, e) {
          if ("throw" === t.type) throw t.arg;
          return (
            "break" === t.type || "continue" === t.type
              ? (this.next = t.arg)
              : "return" === t.type
              ? ((this.rval = this.arg = t.arg),
                (this.method = "return"),
                (this.next = "end"))
              : "normal" === t.type && e && (this.next = e),
            h
          );
        },
        finish: function (t) {
          for (var e = this.tryEntries.length - 1; e >= 0; --e) {
            var r = this.tryEntries[e];
            if (r.finallyLoc === t)
              return this.complete(r.completion, r.afterLoc), _(r), h;
          }
        },
        catch: function (t) {
          for (var e = this.tryEntries.length - 1; e >= 0; --e) {
            var r = this.tryEntries[e];
            if (r.tryLoc === t) {
              var n = r.completion;
              if ("throw" === n.type) {
                var o = n.arg;
                _(r);
              }
              return o;
            }
          }
          throw new Error("illegal catch attempt");
        },
        delegateYield: function (t, e, r) {
          return (
            (this.delegate = { iterator: P(t), resultName: e, nextLoc: r }),
            "next" === this.method && (this.arg = void 0),
            h
          );
        },
      }),
      t
    );
  }
  var o = (function () {
    function t() {
      var r = this;
      babelHelpers.classCallCheck(this, t),
        (this.signedParameters = e.message("signedParameters")),
        (this.todoList = document.getElementById("todo__list")),
        (this.filter = document.getElementById("todo__filter")),
        (this.addForm = document.getElementById("todo__add-form")),
        this.initTaskEvent(),
        e.bind(this.filter, "submit", function (t) {
          return r.submitFilter(t, r);
        }),
        e.bind(this.addForm, "submit", function (t) {
          return r.submitAddForm(t, r);
        });
    }
    var o, a, i;
    return (
      babelHelpers.createClass(t, [
        {
          key: "initTaskEvent",
          value: function () {
            var t = this;
            e.findChildren(this.todoList, { tagName: "input" }, !0).forEach(
              function (r) {
                var n = e.data(r, "id");
                e.bind(r, "change", function (e) {
                  return t.changeTaskStatus(n, e.target.checked, t);
                });
              }
            );
          },
        },
        {
          key: "submitFilter",
          value:
            ((i = babelHelpers.asyncToGenerator(
              n().mark(function t(o, a) {
                var i, c, s;
                return n().wrap(
                  function (t) {
                    for (;;)
                      switch ((t.prev = t.next)) {
                        case 0:
                          return (
                            e.PreventDefault(o),
                            (t.prev = 1),
                            (t.next = 4),
                            e.ajax.runComponentAction(
                              "test:todolist",
                              "getFilteredTasks",
                              {
                                mode: "class",
                                data: new FormData(a.filter),
                                signedParameters: a.signedParameters,
                              }
                            )
                          );
                        case 4:
                          (i = t.sent),
                            (c = i.data),
                            (s = a.renderTasks(c)),
                            e.cleanNode(a.todoList),
                            e.append(s.content, a.todoList),
                            a.initTaskEvent(),
                            (t.next = 16);
                          break;
                        case 12:
                          (t.prev = 12),
                            (t.t0 = t.catch(1)),
                            t.t0.errors.forEach(function (t) {
                              return new r.Notification(t.message);
                            });
                        case 16:
                        case "end":
                          return t.stop();
                      }
                  },
                  t,
                  null,
                  [[1, 12]]
                );
              })
            )),
            function (t, e) {
              return i.apply(this, arguments);
            }),
        },
        {
          key: "submitAddForm",
          value:
            ((a = babelHelpers.asyncToGenerator(
              n().mark(function t(o, a) {
                var i, c;
                return n().wrap(
                  function (t) {
                    for (;;)
                      switch ((t.prev = t.next)) {
                        case 0:
                          return (
                            e.PreventDefault(o),
                            (t.prev = 1),
                            (t.next = 4),
                            e.ajax.runComponentAction(
                              "test:todolist",
                              "addTask",
                              {
                                mode: "class",
                                data: new FormData(a.addForm),
                                signedParameters: a.signedParameters,
                              }
                            )
                          );
                        case 4:
                          (i = t.sent),
                            (c = i.data),
                            new r.Notification(c),
                            (t.next = 13);
                          break;
                        case 9:
                          (t.prev = 9),
                            (t.t0 = t.catch(1)),
                            t.t0.errors.forEach(function (t) {
                              return new r.Notification(t.message);
                            });
                        case 13:
                        case "end":
                          return t.stop();
                      }
                  },
                  t,
                  null,
                  [[1, 9]]
                );
              })
            )),
            function (t, e) {
              return a.apply(this, arguments);
            }),
        },
        {
          key: "changeTaskStatus",
          value:
            ((o = babelHelpers.asyncToGenerator(
              n().mark(function t(o, a, i) {
                return n().wrap(
                  function (t) {
                    for (;;)
                      switch ((t.prev = t.next)) {
                        case 0:
                          return (
                            (t.prev = 0),
                            (t.next = 3),
                            e.ajax.runComponentAction(
                              "test:todolist",
                              "changeTaskStatus",
                              {
                                mode: "class",
                                data: { taskId: o, taskCompleted: a },
                                signedParameters: i.signedParameters,
                              }
                            )
                          );
                        case 3:
                          t.next = 9;
                          break;
                        case 5:
                          (t.prev = 5),
                            (t.t0 = t.catch(0)),
                            t.t0.errors.forEach(function (t) {
                              return new r.Notification(t.message);
                            });
                        case 9:
                        case "end":
                          return t.stop();
                      }
                  },
                  t,
                  null,
                  [[0, 5]]
                );
              })
            )),
            function (t, e, r) {
              return o.apply(this, arguments);
            }),
        },
        {
          key: "renderTasks",
          value: function (t) {
            var r = t
              .map(function (t) {
                return '\n        <li class="todo__item">\n          <label>\n            <input\n              type="checkbox"\n              '
                  .concat(
                    Number(t.UF_COMPLETED) && "checked",
                    '\n              data-id="'
                  )
                  .concat(t.ID, '"\n            />\n            ')
                  .concat(
                    t.UF_TEXT,
                    "\n          </label>\n        </li>\n      "
                  );
              })
              .join(" ");
            return e.create("template", { html: r });
          },
        },
      ]),
      t
    );
  })();
  t.TodoList = o;
})((this.BX = this.BX || {}), BX, BX);
//# sourceMappingURL=script.js.map

style.css
#todo {
  padding: 100px;
  max-width: 900px;
  -webkit-box-sizing: border-box;
          box-sizing: border-box;
  width: 100%;
  margin: auto; }
  #todo .form {
    border-radius: 12px;
    border: 1px solid #f7f7f7;
    background-color: #f7f7f7;
    padding: 12px; }
    #todo .form__field {
      margin-bottom: 12px;
      display: -webkit-box;
      display: -ms-flexbox;
      display: flex;
      gap: 4px;
      -webkit-box-orient: vertical;
      -webkit-box-direction: normal;
          -ms-flex-direction: column;
              flex-direction: column; }
    #todo .form__button {
      outline: none;
      border: none;
      background-color: #f7f7f7;
      border-radius: 6px;
      padding: 6px 12px;
      cursor: pointer;
      -webkit-transition: background-color .2s ease-in-out;
      -o-transition: background-color .2s ease-in-out;
      transition: background-color .2s ease-in-out;
      -webkit-box-shadow: 0px 0px 5px 0px #00000040;
              box-shadow: 0px 0px 5px 0px #00000040; }
      #todo .form__button[type="submit"] {
        background-color: #b6b6ff; }
        #todo .form__button[type="submit"]:hover {
          background-color: #9a9af1; }
      #todo .form__button:hover {
        background-color: #e6e6e6; }
  #todo .todo__list {
    padding: 0;
    list-style: none;
    margin: 0;
    height: 500px;
    overflow: auto; }
  #todo .todo__item {
    margin-bottom: 6px;
    padding: 6px;
    cursor: pointer; }

P.S. Тесты я не писал для этого компонента ибо было лень, + я уже показывал как они пишутся.

В данном компоненты мы реализовали обычный TodoList с добавлением в хайлоад.

А теперь объясняю зачем мы вынесли инициализацию скрипта компонента в template.php

template.php
template.php

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

Важное уточнение!!!

Не верстку, не CSS и JS отдельно, а именно полноценный компонент.

<?
public function testIncludeComponentAction()
{
    return new Bitrix\Main\Engine\Response\Component('test:test', '.default', []);
}

И мы можем так же туда докидывать наши параметры, и можем подключать его на страницу.

НО ВОТ БЕДА!!!

Когда мы обратимся к нашему действию класса или модуля, наш компонент не вставится автоматически на страницу, мы в нашем ajax получим целый assets нашего компонента, причем JavaScript автоматически вставится и выполнится на нашей странице, так же как и CSS, и проблема в том что если в нашем скрипте есть функционал получение DOM элементов, то значения всегда будут undefined и наш скрипт будет не корректно работать.

Приведу пример:

Создаем в классе нашего компонента еще один экшен.

<?  public function getComponentAction()
    {
         return new Bitrix\Main\Engine\Response\Component('test:todolist', '.default', [
            'HIGHLOAD_BLOCK' => 'TodoList',
            'ADDED_TASK_NOTIFICATION_TEXT' => 'Задача успешно добавлена',
         ]);
    }

Уходим на другую страницу и вызываем его через консоль.

BX.ajax.runComponentAction('test:todolist', 'getComponent', {
    mode: 'class'
}).then(res => {
    console.log(res)
}) 

И далее можем через namespace вызывать Notification и сам наш класс TodoList.

Но с классом мы получим ошибку.

Так как signedParameters мы передаем только в нашем template.php и после вызываем наш класс.

А вот собственно говоря то что мы получили от вызова нашего экшена.

{
  "status": "success",
  "data": {
    "html": "<section id=\"todo\" class=\"todo\">\n    <h2>TodoList</h2>\n    <form class=\"todo__filter form\" id=\"todo__filter\">\n        <div class=\"form__field\">\n            <label for=\"\">Поиск по тексту</label>\n            <input type=\"text\" name=\"%UF_TEXT\">\n        </div>\n        <div class=\"form__buttons\">\n            <button class=\"form__button\" type=\"submit\">Найти</button>\n        </div>\n    </form>\n    <ul class=\"todo__list\" id=\"todo__list\">\n                <li class=\"todo__item\">\n            <label>\n                <input type=\"checkbox\" checked data-id=\"6\" />\n                Test            </label>\n        </li>\n                <li class=\"todo__item\">\n            <label>\n                <input type=\"checkbox\"  data-id=\"7\" />\n                asdasdasdas            </label>\n        </li>\n                <li class=\"todo__item\">\n            <label>\n                <input type=\"checkbox\"  data-id=\"8\" />\n                UUUUU\r\n            </label>\n        </li>\n                <li class=\"todo__item\">\n            <label>\n                <input type=\"checkbox\"  data-id=\"9\" />\n                zxczzxczxczxczx            </label>\n        </li>\n            </ul>\n    <form id=\"todo__add-form\" class=\"form\">\n        <div class=\"form__field\">\n            <label for=\"\">Новая задача</label>\n            <textarea name=\"UF_TEXT\"></textarea>\n        </div>\n        <div class=\"form__buttons\">\n            <button class=\"form__button\" type=\"submit\">Добавить</button>\n            <button class=\"form__button\" type=\"reset\">Сбросить</button>\n        </div>\n    </form>\n</section>\n\n\n<script>\n// Передаем SIGNED_PARAMETERS в JS глобальный объкт BX.message\nBX.message({\n    signedParameters: \"YToyOntzOjE0OiJISUdITE9BRF9CTE9DSyI7czo4OiJUb2RvTGlzdCI7czoyODoiQURERURfVEFTS19OT1RJRklDQVRJT05fVEVYVCI7czo0Njoi0JfQsNC00LDRh9CwINGD0YHQv9C10YjQvdC+INC00L7QsdCw0LLQu9C10L3QsCI7fQ==.8b94b928477bfc1a77c5d7ca10f4ddcc9c6e1dafc18d4421bca4832ed936c290\"})\n//Рекомендую иницализировать скрипт компонента тут. Далее в статье объясню почему.\nnew BX.TodoList()\n</script>",
    "assets": {
      "css": [
        "/local/js/myextensions/notification/dist/Notification.bundle.css?17172271471600",
        "/local/components/test/todolist/templates/.default/style.css?17175728821460"
      ],
      "js": [
        "/bitrix/js/pull/protobuf/protobuf.js?1713462987274055",
        "/bitrix/js/pull/protobuf/model.js?171346298770928",
        "/bitrix/js/main/core/core_promise.js?17134629805220",
        "/bitrix/js/rest/client/rest.client.js?171346298717414",
        "/bitrix/js/pull/client/pull.client.js?171346298681036",
        "/local/js/myextensions/notification/dist/Notification.bundle.js?17172271471419",
        "/local/components/test/todolist/templates/.default/script.js?17175728829870"
      ],
      "string": [
        "<script type=\"text/javascript\">(window.BX||top.BX).message({'pull_server_enabled':'Y','pull_config_timestamp':'1671782091','pull_guest_mode':'N','pull_guest_user_id':'0'});(window.BX||top.BX).message({'PULL_OLD_REVISION':'Для продолжения корректной работы с сайтом необходимо перезагрузить страницу.'});</script>",
        "<script type=\"text/javascript\">(window.BX||top.BX).message({'JS_CORE_LOADING':'Загрузка...','JS_CORE_NO_DATA':'- Нет данных -','JS_CORE_WINDOW_CLOSE':'Закрыть','JS_CORE_WINDOW_EXPAND':'Развернуть','JS_CORE_WINDOW_NARROW':'Свернуть в окно','JS_CORE_WINDOW_SAVE':'Сохранить','JS_CORE_WINDOW_CANCEL':'Отменить','JS_CORE_WINDOW_CONTINUE':'Продолжить','JS_CORE_H':'ч','JS_CORE_M':'м','JS_CORE_S':'с','JSADM_AI_HIDE_EXTRA':'Скрыть лишние','JSADM_AI_ALL_NOTIF':'Показать все','JSADM_AUTH_REQ':'Требуется авторизация!','JS_CORE_WINDOW_AUTH':'Войти','JS_CORE_IMAGE_FULL':'Полный размер'});</script>\r\n",
        "<script type=\"text/javascript\" src=\"/bitrix/js/main/core/core.js?1713462980487984\"></script>\r\n",
        "<script>BX.setJSList(['/bitrix/js/main/core/core_ajax.js','/bitrix/js/main/core/core_promise.js','/bitrix/js/main/polyfill/promise/js/promise.js','/bitrix/js/main/loadext/loadext.js','/bitrix/js/main/loadext/extension.js','/bitrix/js/main/polyfill/promise/js/promise.js','/bitrix/js/main/polyfill/find/js/find.js','/bitrix/js/main/polyfill/includes/js/includes.js','/bitrix/js/main/polyfill/matches/js/matches.js','/bitrix/js/ui/polyfill/closest/js/closest.js','/bitrix/js/main/polyfill/fill/main.polyfill.fill.js','/bitrix/js/main/polyfill/find/js/find.js','/bitrix/js/main/polyfill/matches/js/matches.js','/bitrix/js/main/polyfill/core/dist/polyfill.bundle.js','/bitrix/js/main/core/core.js','/bitrix/js/main/polyfill/intersectionobserver/js/intersectionobserver.js','/bitrix/js/main/lazyload/dist/lazyload.bundle.js','/bitrix/js/main/polyfill/core/dist/polyfill.bundle.js','/bitrix/js/main/parambag/dist/parambag.bundle.js']);\n</script>",
        "<script type=\"text/javascript\">\n\t\t\t\t\t(function () {\n\t\t\t\t\t\t\"use strict\";\n\n\t\t\t\t\t\tvar counter = function ()\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar cookie = (function (name) {\n\t\t\t\t\t\t\t\tvar parts = (\"; \" + document.cookie).split(\"; \" + name + \"=\");\n\t\t\t\t\t\t\t\tif (parts.length == 2) {\n\t\t\t\t\t\t\t\t\ttry {return JSON.parse(decodeURIComponent(parts.pop().split(\";\").shift()));}\n\t\t\t\t\t\t\t\t\tcatch (e) {}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t})(\"BITRIX_CONVERSION_CONTEXT_s1\");\n\n\t\t\t\t\t\t\tif (cookie && cookie.EXPIRE >= BX.message(\"SERVER_TIME\"))\n\t\t\t\t\t\t\t\treturn;\n\n\t\t\t\t\t\t\tvar request = new XMLHttpRequest();\n\t\t\t\t\t\t\trequest.open(\"POST\", \"/bitrix/tools/conversion/ajax_counter.php\", true);\n\t\t\t\t\t\t\trequest.setRequestHeader(\"Content-type\", \"application/x-www-form-urlencoded\");\n\t\t\t\t\t\t\trequest.send(\n\t\t\t\t\t\t\t\t\"SITE_ID=\"+encodeURIComponent(\"s1\")+\n\t\t\t\t\t\t\t\t\"&sessid=\"+encodeURIComponent(BX.bitrix_sessid())+\n\t\t\t\t\t\t\t\t\"&HTTP_REFERER=\"+encodeURIComponent(document.referrer)\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\n\t\t\t\t\t\tif (window.frameRequestStart === true)\n\t\t\t\t\t\t\tBX.addCustomEvent(\"onFrameDataReceived\", counter);\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\tBX.ready(counter);\n\t\t\t\t\t})();\n\t\t\t\t</script>",
        "<script type=\"text/javascript\">(window.BX||top.BX).message({'pull_server_enabled':'Y','pull_config_timestamp':'1671782091','pull_guest_mode':'N','pull_guest_user_id':'0'});(window.BX||top.BX).message({'PULL_OLD_REVISION':'Для продолжения корректной работы с сайтом необходимо перезагрузить страницу.'});</script>",
        "<script type=\"text/javascript\">(window.BX||top.BX).message({'JS_CORE_LOADING':'Загрузка...','JS_CORE_NO_DATA':'- Нет данных -','JS_CORE_WINDOW_CLOSE':'Закрыть','JS_CORE_WINDOW_EXPAND':'Развернуть','JS_CORE_WINDOW_NARROW':'Свернуть в окно','JS_CORE_WINDOW_SAVE':'Сохранить','JS_CORE_WINDOW_CANCEL':'Отменить','JS_CORE_WINDOW_CONTINUE':'Продолжить','JS_CORE_H':'ч','JS_CORE_M':'м','JS_CORE_S':'с','JSADM_AI_HIDE_EXTRA':'Скрыть лишние','JSADM_AI_ALL_NOTIF':'Показать все','JSADM_AUTH_REQ':'Требуется авторизация!','JS_CORE_WINDOW_AUTH':'Войти','JS_CORE_IMAGE_FULL':'Полный размер'});</script>\r\n",
        "<script type=\"text/javascript\" src=\"/bitrix/js/main/core/core.js?1713462980487984\"></script>\r\n",
        "<script>BX.setJSList(['/bitrix/js/main/core/core_ajax.js','/bitrix/js/main/core/core_promise.js','/bitrix/js/main/polyfill/promise/js/promise.js','/bitrix/js/main/loadext/loadext.js','/bitrix/js/main/loadext/extension.js','/bitrix/js/main/polyfill/promise/js/promise.js','/bitrix/js/main/polyfill/find/js/find.js','/bitrix/js/main/polyfill/includes/js/includes.js','/bitrix/js/main/polyfill/matches/js/matches.js','/bitrix/js/ui/polyfill/closest/js/closest.js','/bitrix/js/main/polyfill/fill/main.polyfill.fill.js','/bitrix/js/main/polyfill/find/js/find.js','/bitrix/js/main/polyfill/matches/js/matches.js','/bitrix/js/main/polyfill/core/dist/polyfill.bundle.js','/bitrix/js/main/core/core.js','/bitrix/js/main/polyfill/intersectionobserver/js/intersectionobserver.js','/bitrix/js/main/lazyload/dist/lazyload.bundle.js','/bitrix/js/main/polyfill/core/dist/polyfill.bundle.js','/bitrix/js/main/parambag/dist/parambag.bundle.js']);\n</script>",
        "<script type=\"text/javascript\">\n\t\t\t\t\t(function () {\n\t\t\t\t\t\t\"use strict\";\n\n\t\t\t\t\t\tvar counter = function ()\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar cookie = (function (name) {\n\t\t\t\t\t\t\t\tvar parts = (\"; \" + document.cookie).split(\"; \" + name + \"=\");\n\t\t\t\t\t\t\t\tif (parts.length == 2) {\n\t\t\t\t\t\t\t\t\ttry {return JSON.parse(decodeURIComponent(parts.pop().split(\";\").shift()));}\n\t\t\t\t\t\t\t\t\tcatch (e) {}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t})(\"BITRIX_CONVERSION_CONTEXT_s1\");\n\n\t\t\t\t\t\t\tif (cookie && cookie.EXPIRE >= BX.message(\"SERVER_TIME\"))\n\t\t\t\t\t\t\t\treturn;\n\n\t\t\t\t\t\t\tvar request = new XMLHttpRequest();\n\t\t\t\t\t\t\trequest.open(\"POST\", \"/bitrix/tools/conversion/ajax_counter.php\", true);\n\t\t\t\t\t\t\trequest.setRequestHeader(\"Content-type\", \"application/x-www-form-urlencoded\");\n\t\t\t\t\t\t\trequest.send(\n\t\t\t\t\t\t\t\t\"SITE_ID=\"+encodeURIComponent(\"s1\")+\n\t\t\t\t\t\t\t\t\"&sessid=\"+encodeURIComponent(BX.bitrix_sessid())+\n\t\t\t\t\t\t\t\t\"&HTTP_REFERER=\"+encodeURIComponent(document.referrer)\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t};\n\n\t\t\t\t\t\tif (window.frameRequestStart === true)\n\t\t\t\t\t\t\tBX.addCustomEvent(\"onFrameDataReceived\", counter);\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\tBX.ready(counter);\n\t\t\t\t\t})();\n\t\t\t\t</script>",
        ""
      ]
    },
    "additionalParams": [],
    "componentResult": []
  },
  "errors": []
}

Как же так вышло?

Дело в том что BX.ajax.runComponentAction грубо говоря наследуется от Ajax, а вот у него уже условие: Если в response есть assets, то js и css автоматически вставляются в DOM.

Код ядра
var assets = BX.prop.getObject(BX.prop.getObject(response, "data", {}), "assets", {});
		var promise = new Promise(function(resolve, reject) {
			var css = BX.prop.getArray(assets, "css", []);
			BX.load(css, function(){
				BX.loadScript(
					BX.prop.getArray(assets, "js", []),
					resolve
				);
			});
		});
		promise.then(function(){
			var strings = BX.prop.getArray(assets, "string", []);
			var stringAsset = strings.join('\n');
			BX.html(document.head, stringAsset, { useAdjacentHTML: true }).then(function(){
				assetsLoaded.fulfill(response);
			});
		});

		return assetsLoaded;

Данный фрагмент находится в core_ajax.js в функции buildAjaxPromiseToRestoreCsrf, а эта функция есть в нашем runComponentAction.

Я не нашел способ как это обойти, по этому чтобы наш компонент заработал после вставки DOM, нужно вынести инициализацию скриптов компонента в template.php.

После того как мы вставим в DOM наш HTML - компонент будет работать. Так как в этой разметки присутствует наш скрипт.

Да и на самом деле ничего зазорного в этом нет, учитывая то что разработчики, сами регулярно так делают. (Достаточно просто порыться в их компонентах).

Ответ от сервера при обращении к действию нашего компонента.
Ответ от сервера при обращении к действию нашего компонента.

Важный момент!!!

СКРИПТ ЗАГРУЗИТСЯ СО СТИЛЯМИ ОДИН РАЗ!!!

По этому не получится сделать иначе, это было специально сделано разработчиками, чтобы скрипты не дублировались. Чтобы мы (разработчики сайтов на Bitrix) писали именно модули и классы.

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

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


Выводы

Мы приблизились максимально плотно к скорее всего правильной разработке как это и планируется у разработчиков Bitrix. С точки зрения использования их ядра.

И можно так же написать не класс, а отдельный модуль, который так же будет иметь action который будет возвращать компонент.

Мы научились писать unit тесты для фронта. (Посредственные и не полные, но далее можете у сами эту тему развивать, я лишь показал что это возможно).

Мы научились возвращать полноценный компонент. Многие 100% не знали как это сделать, и писали отельные ajax файлы с версткой и логикой.

Мы освоили максимально свежий и крутой подход который обеспечит проекты хорошую адаптивность и поддержку.

Мы научились создавать JS бандлы и применять их, а так же импортировать их в компоненты. (Ну можно так и рантаймить, я это описал как делается). А так же минифицировать, чтобы страница грузилась быстрее и код со стороны клиента был менее читабелен. (Но опять же никто не мешает открыть исходник и запихнуть его в нейронку которая все преобразует в читабельный вид с точностью 99% если не лучше. GPT 4 - Привет!!!).

Ну и в целом мы объединили Frontend разработку с Backend - причем на довольно хорошем уровне. (Для Bitrix Framework)

P.S.

Статья писалась очень долго, и было в неё вложено огромное количество сил и времени. И буквально правилась на ходу когда что то не получалось. К примеру изначально не получалось у меня сделать import расширения с нотификациями внутри компонента. А потом внезапно получилось в результате чего я опять много чего переписывал.

Если найдете косяки и ошибки, отпишите в комментариях или в ЛС, я все поправлю потом.

И пока вы читали это. Уже идет написание очень крутой статьи. Где я покажу как можно сделать SPA приложение:

  • без JS фреймворков (Vue, React, Svelte и т.д. т.п.).

  • без потери SSR.

  • без потери SEO.

  • и полностью средствами Bitrix.

  • и с анимацией перехода между страницами.

  • а так же у нас будет работать кнопка в браузере (назад, вперед и т.д. т.п.)

Опытные Backend разработчики Bitrix уже догадались как я это буду делать, учитывая то что способ/метод я уже показал. Но в плане как сделать это прям SPA и без потери SSR и SEO вряд-ли.

Если остались вопросы, так же пишите в комментариях и если понравилась статья - лайкни bro )))).

Надеюсь расписал все довольно понятно и моих комментариев в коде - хватило.

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