Интерфейс Онлайн-записи
Интерфейс Онлайн-записи

Представьте, что к вам приходит владелец продукта с видео-концептом новой фичи для клиента, функционал которой отдаленно напоминает космический корабль, а у вас всего два месяца на разработку, и один — на тестирование. Если смогли представить, значит эта статья для вас, а если нет — то и для вас тоже, чтобы заранее знать, как выходить из таких ситуаций.

Меня зовут Петр Попов, я разработчик в Битриксе и в этой статье расскажу о том, как мы делали фронтенд для Онлайн-записи, которая вышла в последнем релизе Битрикс24.

Это длинная история, и началась она еще в 2018 году, когда мои коллеги интегрировали Vue.js в Битрикс24 — я попросил их поучаствовать в написании материала и дать свои комментарии. Именно Vue помог нам реализовать все задачи сервиса за два месяца.

Как мы интегрировали Vue

В 2018 году мы пришли к тому, что работать с высоко динамическим SPA-приложениями, такими как чат, по классической концепции оперирования DOM-деревом неэффективно и очень сильно нас тормозит как в скорости разработки, так и в возможностях, которые мы можем реализовать и не сойти с ума.

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

Всем этим критериям подходило всего два фреймворка — популярный React от Meta и Vue.js от независимого разработчика.

Мы пробовали, делали прототипы встройки и несмотря на то, что React был мощнее по возможностям и по количеству потенциальных разработчиков на рынке, выбор пал на Vue.js. Миниатюрный, но мощный фреймворк максимально полно подошел нам по всем критериям.

Спустя 7 лет, могу точно сказать что выбор Vue.js был правильным, его низкий порог входа позволил подключить много разработчиков и мы смогли сделать много интерактивных вещей, где разработчик больше не привязан к макету, а концентрируется на бизнес-логике и данных, а визуальная составляющая меняется достаточно просто.

Евгений Шеленков, тимлид команды разработки Битрикс24

Несмотря на то, что инструмент не новый, он до сих пор обновляется и активно используется в разработке. У нас в Битриксе на Vue уже написаны мессенджер, crm-формы, новая структура компании, календарные свободные слоты, и вот ещё один пример — Онлайн-запись.

О продукте

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

Это даже не онлайн запись, в том понимании, как рынок ее привык слышать и  понимать.

Это продолжение истории с CRM. Аудитория тех, кто оказывает услуги в дополнение к продаже товаров (продал кондиционер - установил) или самостоятельно, именно сервисные компании, оказывающие услуги (сдаю экскаваторы в аренду, ремонтирую машины) Мы очередной раз дополняем нашу CRM новой крутой фичей, которая закрывает задачи бизнеса! И делает это по взрослому.

Екатерина Шеленкова, заместитель руководителя отдела развития продуктов Битрикс24

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

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

Почему решили использовать Vue для Онлайн-записи?

Подходы к разработке фронтенда на примере списка фруктов
Подходы к разработке фронтенда на примере списка фруктов

Чтобы узнать о преимуществах Vue, сначала нужно узнать о недостатках других подходов. Расскажу о трёх из них, начиная с наиболее старого и статичного. Для простоты возьмем пример со списком фруктов.

1. Вся вёрстка на PHP.

В классе компонента мы возвращаем данные:

$arResult['FRUITS'] = [
   [
      'img' => '/img/apple.png',
      'name' => 'Яблоко',
   ],
   // ...
];

$this->IncludeComponentTemplate();

В шаблоне компонента мы их отрисовываем:

<ol class="fruits">
   <?php foreach ($arResult['FRUITS'] as $fruit):?>
      <li class="fruit">
         <img src="<?= $fruit['img'] ?>"/>
         <span><?= $fruit['name'] ?></span>
      </li>
   <?php endforeach;?>
</ol>

Если нам нужна динамика, то мы добавляем на страницу скрипт, который достаёт элементы по id или class и работает с ними.

Плюсов здесь особых нет, а явные минусы в том, что:

  • это статичный подход, который невозможно поддерживать, если речь идёт о динамичных приложениях;

  • код, в котором PHP и HTML перемешаны, становится менее читаемым;

  • такой код тяжело переиспользовать;

  • увеличивается нагрузка на сервер из-за того, что вёрстка генерируется на PHP, а также увеличивается объём передаваемых данных в ответе.

2. Вёрстка, генерируемая Javascript-экстеншеном.

В этом подходе мы создаём через @bitrix/cli расширение, которое управляет всей логикой страницы, а компонент занимается только его подключением и подготовкой начальных параметров.

Здесь помимо способа с $arResult также есть возможность получить данные через запрос к контроллеру, при этом показав пользователю лоадер или скелетную загрузку.

В шаблоне мы подключаем расширение:

<?php

use Bitrix\Main\UI\Extension;
use Bitrix\Main\Web\Json;

Extension::load('fruits');

?>

<div id="fruits"></div>

<script>
   BX.ready(() => {
      const container = document.getElementById('fruits');
      const fruits = <?= Json::encode($arResult['FRUITS']) ?>;

      new BX.Fruits({
         container,
         fruits,
      });
   });
</script>

И оно уже внутри себя отрисовывает весь контент:

import { Tag } from 'main.core';
import type { Params, Fruit } from './types';

export class Fruits
{
   #params: Params;

   constructor(params: Params)
   {
      this.#params = params;

      this.#params.container.append(this.#render());
   }

   #render(): HTMLElement
   {
      return Tag.render`
         <ol class="fruits">
            ${this.#params.fruits.map((fruit) => this.#renderFruit(fruit))}
         </ol>
      `;
   }

   #renderFruit(fruit: Fruit): HTMLElement
   {
      return Tag.render`
         <li class="fruit">
            <img src="${fruit.img}"/>
            <span>${fruit.name}</span>
         </li>
      `;
   }
}

Такой подход позволяет сразу сохранить отрисованные элементы в переменную (например, this.#layout), добавлять новые элементы, если есть изменяющийся список данных, и также на лету встраивать компонент в любую точку страницы. Всё это полезно при создании приложений, работающих без перезагрузки страницы.

Минус в том, что нужно вручную следить за данными, обновлять нужные элементы, а также при разделении приложения на несколько компонентов становится сложно перерисовывать блоки одновременно в разных местах. Есть несколько способов коммуникации между ними:

  • прокидывание колбэков в дочерние компоненты (например onUpdated, для последующего вызова props.onUpdated());

  • создание методов, обновляющих дочерние компоненты (из родителя вызываем child.update());

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

Это всё делает код запутанным. И все эти проблемы решает…

3. Приложение на Vue.

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

import { BitrixVue } from 'ui.vue3';
import type { Params } from './types';

export class Fruits
{
   constructor(params: Params)
   {
      BitrixVue.createApp({
         props: {
            fruits: {
               type: Array,
               required: true,
            },
         },
         template: `
            <ol class="fruits">
               <template v-for="fruit of fruits" :key="fruit.name">
                  <li class="fruit">
                     <img :src="fruit.img"/>
                     <span>{{ fruit.name }}</span>
                  </li>
               </template>
            </ol>
         `,
      }, params).mount(params.container);
   }
}

Вот тут у не искушенного читателя, но знакомого с тем, что такое Vue, дернется глаз. Битрикс? Опять придумал свой космолет и переписал готовый фреймворк!

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

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

Евгений Шеленков,  Тимлид команды разработки Битрикс24

Самый главный плюс Vue в том, что он позволяет четко отделить данные от визуализации, сам следит за хранилищем и перерисовывает только те элементы, которые зависят от измененных данных.

В данном примере, если мы добавим в массив fruits новый элемент, то на страницу добавится <li>, а если переименуем какой-нибудь фрукт, то обновится только innerText у нужного <span>.

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

Некоторые подробности реализации на примере

Для централизованного хранения данных в Vue3 есть две библиотеки: Vuex и Pinia.

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

Рассмотрим пример.

Нам нужно, чтобы при наведении на боковую панель подсвечивался ряд, и при клике применялся фильтр слотов, который оставляет только доступные в выбранный промежуток времени ресурсы, и с анимацией скрывает остальные:

Фильтр слотов в Онлайн-записи
Фильтр слотов в Онлайн-записи

Что для этого нужно сделать? Расскажу о некоторых принципах, которым мы следовали, а также о некоторых полезностях.

1. Нужно начинать со структуры данных

Это самый важный этап. Всё хранилище мы разделили на несколько моделей, каждая из которых хранит определённую сущность, это что-то наподобие таблиц в базе данных. Сущностями являются типы ресурсов (например, специалист, автомобиль, помещение), сами ресурсы (например Валера, KAMAZ-54901, Салон на проспекте Мира), бронирования (например, запись Владислава Михайлова на 14:00-15:00) и прочие.

Вот пример модели:

// model/resources/src/resources.js
export class Resources extends BuilderModel
{
   getName(): string
   {
      return Model.Resources;
   }

   getState(): ResourcesState
   {
      return {
         collection: {},
      };
   }

   getGetters(): GetterTree
   {
      return {
         /** @function resources/get */
         get: (state: ResourcesState): ResourceModel[] => Object.values(state.collection),
         // ...
      };
   }

   getActions(): ActionTree
   {
      return {
         /** @function resources/insertMany */
         insertMany: (store: Store, resources: ResourceModel[]): void => {
            resources.forEach((resource: ResourceModel) => store.commit('insert', resource));
         },
         // ...
      };
   }

   getMutations(): MutationTree
   {
      return {
         insert: (state: ResourcesState, resource: ResourceModel): void => {
            state.collection[resource.id] ??= resource;
         },
         // ...
      };
   }
}

Здесь у пользователей дернется глаз еще раз! Дело в том, что модели данных во Vuex не описываются в формате класса, там это обычный объект.  Мы же, чтобы вывести это на несколько новый уровень, сделали свою обертку VuexBuilder, которая позволяет работать с моделями как с классами, автоматически их группировать и подключать в удобном формате. Но концептуально — это не переработка Vuex, это просто слой между разработчиком и Vuex.

Евгений Шеленков,  Тимлид команды разработки Битрикс24

! Важно типизировать все свойства и аргументы, ведь благодаря этому код легче считывается. Хоть в JavaScript нет Runtime-проверки типов, но прописанная типизация поможет вам спасти много нервных клеток, особенно это полезно не на примитивах, а на сложных данных, вроде моделей хранилища или DTO, возвращаемых с сервера.

2. Модель Interface для текущего состояния

Зачем она нужна? Иногда нам нужно показывать не все ресурсы, которые есть в хранилище, а только отфильтрованные (например, при загрузке страницы получены ресурсы Валера, Михаил и Анна, а во время фильтрации нашлись бронирования только у Анны), поэтому нужно где-то хранить resourcesIds. Ещё есть ряд свойств, относящихся к интерфейсу, от которых зависит поведение сразу нескольких элементов, например draggedBookingId, который заполняется при начале перетаскивания записи, и при этом становятся неактивными нерабочие часы и появляется область для удаления.

Drag&drop в Онлайн-записи
Drag&drop в Онлайн-записи

И помимо некоторых других переменных в эту же модель мы кладём quickFilter.

3. Заполняем хранилище данными с помощью провайдера

Всю логику с добавлением/изменением/удалением данных мы вынесли в отдельный слой — provider. Внутри него есть два источника данных: provider/pull и provider/service — соответственно для обработки пушей и обращений к API модуля.

Вот пример провайдера:

// provider/service/resources-service/src/resources-service.js
class ResourceService
{
   async add(resource: ResourceModel): Promise<AjaxResponse>
   {
      try
      {
         const resourceDto = mapModelToDto(resource);
         const data = await (new ApiClient()).post('Resource.add', { resource: resourceDto });
         const createdResource = mapDtoToModel(data);

         Core.getStore().commit(`${Model.Resources}/upsert`, createdResource);

         return data;
      }
      catch (error)
      {
         console.error('ResourceService: add error', error);

         return error;
      }
   }

   async update(resource: ResourceModel): Promise<AjaxResponse>
   {
      // ...
   }

   async delete(resourceId: number): Promise<void>
   {
      // ...
   }
}

export const resourceService = new ResourceService();

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

4. Делаем компоненты. Сначала локально, потом глобально

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

В компоненте Header из модели Interface получаем список текущих resourcesIds, который уже отфильтрован по quickFilter.

// application/booking/src/components/header/header.js
export const Header = {
   computed: mapGetters({
      resourcesIds: `${Model.Interface}/resourcesIds`,
   }),
   components: {
      Resource,
   },
   template: `
      <div class="booking-booking-header">
         <template v-for="resourceId of resourcesIds" :key="resourceId">
            <Resource :resourceId="resourceId"/>
         </template>
      </div>
   `,
};

Далее, в каждом компоненте Resource получаем ресурс по id уже целиком:

// application/booking/src/components/header/resource/resource.js
export const Resource = {
   props: {
      resourceId: {
         type: Number,
         required: true,
      },
   },
   computed: {
      resource(): ResourceModel
      {
         return this.$store.getters[`${Model.Resources}/getById`](this.resourceId);
      },
      resourceType(): ResourceTypeModel
      {
         return this.$store.getters[`${Model.ResourceTypes}/getById`](this.resource.typeId);
      },
   },
   template: `
      <div class="booking-booking-header-resource">
         <div class="booking-booking-header-resource-name">
            {{ resource.name }}
         </div>
         <div class="booking-booking-header-resource-type">
            {{ resourceType.name }}
         </div>
      </div>
   `,
};

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

Как итог, получаем примерно такую структуру:

  • application — папка для приложений

  • component — здесь храним общие компоненты (например button, popup)

  • const — опциональная директория для хранения констант

  • core — ядро, которое инициализирует хранилище и подписывается на пулл

  • lib — папка для любой вспомогательной логики

  • model — слой данных

  • provider — слой для обновления данных в хранилище

Заключение

Vue — удобный инструмент, с помощью которого можно быстро сделать динамичные интерфейсы, в которых логика должна быть четко отделена от представления. Если бы у нас под рукой не было Vue, мы вряд ли смогли бы воплотить в сервисе онлайн-записи все требования продакт-менеджеров и успеть закончить решение с таким количеством возможностей всего за 3 месяца.

Спасибо за прочтение! Желаю всем успехов в разработке! ☺️
Спасибо за прочтение! Желаю всем успехов в разработке! ☺️

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


  1. zikkich
    10.02.2025 14:17

    А что на беке в таком случае происходит?


    1. pyotrpopov443 Автор
      10.02.2025 14:17

      От бекенда нужно только API, с которым общается приложение.

      На примере provider/service/resources-service/src/resources-service.js, при вызове метода Resource.add, запрос попадает в контроллер Resource в метод addAction, где происходит добавление ресурса в базу данных, после чего сервер в ответе возвращает созданную сущность, вместе с id

      То, как может быть написан метод addAction, зависит от архитектуры бекенда.


      1. zikkich
        10.02.2025 14:17

        Все понятно, получается все на онли контролерах. Интересно. А как это все реализовано на странице? То есть раньше это были компоненты где шаблоны и логика, тут в этом нужды вроде бы нет.


        1. pyotrpopov443 Автор
          10.02.2025 14:17

          Сейчас тоже на странице есть компонент.

          Но в этом компоненте почти нет никакой логики, он просто подключает js-экстеншн с приложением, и передаёт в него начальные параметры (как в примере 2. Вёрстка, генерируемая Javascript-экстеншеном.)


  1. iFFgen
    10.02.2025 14:17

    Поделитесь, пожалуйста, кратким примером кастомизации идущего из коробки компонента (например, структуры компании).


    1. pyotrpopov443 Автор
      10.02.2025 14:17

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

      Кастомизация шаблона
      https://dev.1c-bitrix.ru/learning/course/index.php?COURSE_ID=43&CHAPTER_ID=04778

      Примеры кастомизации публичной части
      https://dev.1c-bitrix.ru/learning/course/index.php?COURSE_ID=43&LESSON_ID=9015