Базовый класс контрола

Мы руководствуемся концепцией представления интерфейса как набора контролов.

Контрол — это изолированная часть функционала, которую можно использовать повторно при разработке интерфейса. В файловой структуре он представляет собой минимум два файла: JS-модуль, где описана логика, и WML-шаблон, где описано представление.

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

Создание класса контрола

Разработка контрола начинается с описания класса, который должен наследоваться от базового класса UI/Base:Control. Описание класса нужно поместить в отдельном JS-модуле. В основе работы с модулями мы применяем стороннюю OpenSource библиотеку.

В следующем примере продемонстрировано описание класса для контрола MyControl.

// JavaScript
define("MyControl", ["UI/Base"], function(Base) {
   var ModuleClass = Base.Control.extend({
      // логика работы контрола
   });
   return ModuleClass;
});

Также модуль контрола можно описать на TypeScript, о чем подробнее вы можете прочитать в разделе Лучшие практики.

Подключение шаблона

Неотъемлемой частью любого контрола является его визуальное отображение, которое описывают в шаблонах. В Wasaby для шаблонов создано собственное расширение файла — WML (Wasaby Markup Language). Для импорта шаблона используют RequireJS-плагин wml, и импортированный шаблон передают в свойство _template.

<!-- Template.wml -->
<div>Hello Wasaby</div>
// MyControl.js
define("MyControl", ["UI/Base", "wml!Template"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template
   });
   return ModuleClass;
});

Отображение данных

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

Ниже показано: в классе контрола объявлено свойство text, значение которого выводится в WML-шаблон.

<!-- MyControl.wml -->
<div title="{{ text }}">{{ text }}</div>
// JavaScript
define("MyControl", ["UI/Base", "wml!MyControl"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template,
      text: "Hello Wasaby!"
   });
   return ModuleClass;
});

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

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

Опции контрола

Опциями мы называем параметры, переданные при инициализации контрола. В шаблоне опции доступны в переменной _options, а в JS-модуле — в переменной this._options. Опции доступны только на чтение и менять их можно только через родительский контрол

В следующем примере мы инициализируем контрол MyControl и передаем в него опцию color.

<!-- Parent/Template.wml -->
<div>
   <Demo.Module color="{{ someColor }}" />
</div>
<!-- Demo/Template.wml -->
<div style="color: {{ _options.color }};">
   Hello Wasaby!
</div>
// Module.js
define("Demo/Module", ["UI/Base", "wml!Demo/Template"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template
   });
   return ModuleClass;
});

См. демо-пример на CodeSandbox

Подробнее о работе с опциями контрола можно прочитать в разделе Опции.

Жизненный цикл

Жизненным циклом мы называем процесс функционирования контрола начиная от его создания и заканчивая уничтожением его экземпляра и удаление элемента из DOM-дерева.

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

В области видимости любого хука указатель this возвращает экземпляр класса контрола. Через указатель можно получить доступ к дочерним html-элементам или контролам, вызывать методы контрола.

Схема жизненного цикла

Ниже представлен полный список хуков жизненного цикла.

Фаза "Создание"

Сигнатура хукаПредназначениеМомент вызова
_beforeMount(options, context, receivedState)Служит для инициализации состояния по полученным опциям.

Избегайте побочных эффектов в этом методе, потому что они могут привести к утечкам памяти. Подробнее читайте в статье: Опасность побочных эффектов в _beforeMount.

Если из этого хука вернуть Promise, то построение этого контрола отложится до его разрешения. Результат Promise, выполненного на сервере, придёт третьим аргументом при построении на клиенте. Более подробно смотрите в примере.
Вызывается до построения вёрстки контрола. Соответственно, здесь ещё нет DOM и детей.

Единственный хук, который вызывается и на сервере, и на клиенте.
_componentDidMount(options)Служит для манипуляций с DOM перед тем, как пользователь увидит кадр. Например, для корректирования положения скролла.

Хук вызывается синхронно, поэтому может вызвать проблемы с производительность. Поэтому предпочтительнее использовать _afterMount().
Вызывается синхронно после того, как изменения были впервые применены к DOM до того, как браузер отрисовал кадр.
_afterMount(options)Служит для инициализации состояния, которое зависит от DOM, а также подписок на события.

Не стоит без необходимости изменять реактивные свойства в данном методе, т.к. это приведёт к перерисовкам.
Вызывается после построения вёрстки и отрисовки кадра браузером.

Вызывается один раз после построения на клиенте.

Пример использования хука _componentDidMount:

// TypeScript
_componentDidMount() {
    // Вызываем _notify события после фактического монтирования контрола
    this._notify('registerEvent', [{
            id: this._index,
            inst: this
        }, true], {bubbling: true});
}

Фаза "Обновление"

Сигнатура хукаПредназначениеМомент вызова
_beforeUpdate(newOptions)Служит для изменения состояния в ответ на изменение опций. Старые опции доступны на this._options, новые приходят первым аргументом.

Не забывайте сравнивать старые и новые опции перед изменением состояния. Более подробно о том, к каким проблемам это приведёт, читайте в статье: Контрол безусловно меняет состояние в _beforeUpdate.
Вызывается перед тем, как контрол обновится с новыми опциями.
_afterRender()Служит для манипуляций с DOM перед тем, как пользователь увидит кадр. Например, для корректирования положения скролла.

Использование данного хука обычно приводит к проблемам с производительностью, например, к forced reflow. Поэтому для большинства операций предпочтительнее использовать _afterUpdate(), чтобы не откладывать отрисовку кадра.
Вызывается синхронно после того, как изменения были применены к DOM до того, как браузер отрисовал кадр.
_afterUpdate(oldOptions)Служит для изменения состояния после обновления. Здесь можно взаимодействовать с DOM, например, измерять размеры элементов.

Чтобы избежать зацикливания синхронизаций, меняйте состояние только при наличии изменений.
Вызывается после обновления контрола и, в отличие от _afterRender(), уже после того, как кадр был отрисован.

Этот метод вызывается только после обновлений. Если вам нужно что-то сделать после построения, то используйте _afterMount().

Фаза "Уничтожение"

Сигнатура хукаПредназначениеМомент вызова
_beforeUnmount()Служит для очистки состояния контрола, отписок от событий и т.д. После вызова этого метода любое взаимодействие с контролом (обращения к свойствам, вызовы методов и т.д.) является ошибкой.

В этом методе не нужно зачищать поля контрола, т.к. Wasaby делает это автоматически.
Вызывается перед уничтожением контрола.

Примеры использования хуков

_beforeMount

Инициализация состояния по опциям

_beforeMount(options) {
    // Изменяем формат элементов, которые пришли в опции.
    this._items = options.items.map((item) => {
        return {
            id: item.id,
            caption: getCaption(item)
      };
   })
}

Важно не забывать, что подобный код должен быть в _beforeUpdate с одним изменением — он должен вызываться только при условии, что опция items изменилась.

Асинхронное построение

Данная возможность не рекомендуется к использованию, потому что она ведёт к замедлению оживления. Большинство мест, где нужна асинхронная загрузка данных сводится к двум вариантам:

  1. Загрузка данных при построении страницы. В данном случае, нужно использовать предзагрузку данных, подробнее читайте в статье.
  2. Загрузка данных при открытии карточки. Можно открывать карточку по данным из реестра и одновременно посылать запрос за дополнительными данными.

Если вам всё-таки нужен этот функционал, то ниже дан пример его использования:

_beforeMount(options, context, receivedState) {
    if (receivedState) {
        // Если состояние пришло с сервера, то используем его
        this._items = receivedState;
   } else {
      return options.source.query().then((items) => {
         // Инициализируем состояние на сервере, чтобы оттуда пришла правильная вёрстка
         this._items = items;
         // Это значение будет сериализовано при построении на сервере
         return items;
      });
   }
}

Если вы уверены, что вам никак не обойтись без этого, то не забывайте про две основные ошибки, допускаемые при использовании асинхронного построения:

  1. Загрузка данных водопадом. Если кратко, то это ситуация, когда сначала родитель грузит данные, потом ребёнок, а потом его ребёнок и т.д. Более подробно читайте в статье Загрузка данных водопадом.
  2. Возвращение Promise на сервере и на клиенте. Это замедляет оживление и создаёт лишнюю нагрузку на бизнес-логику. Более подробно читайте в статье Загрузка данных дважды: на сервере и на клиенте.

_afterMount

_afterMount() {
    // Нужно не забывать про отписку в _beforeUnmount.
    EventBus.channel('TestChannel').subscribe('onExampleEvent', this._handleEvent);
 
    // Мы не можем узнать ни высоту ребёнка, ни высоту окна до построения контрола, так что здесь изменение состояния оправдано.
    this._showShadow = this._children.list.clientHeight > window.innerHeight;
}

_beforeUpdate

_beforeUpdate(newOptions) {
    // Обязательно сравниваем новое значение со старым.
    if (this._options.items !== newOptions.items) {
        this._items = newOptions.items.map((item) => {
            return {
                id: item.id,
                caption: getCaption(item)
         };
      })
   }
}

_afterRender

_afterRender() {
    // Восстанавливаем позицию скролла до того, как пользователь увидел изменения.
    this._children.list.scrollTop = this._savedScrollPosition;
}

_afterUpdate

_afterUpdate(oldOptions) {
    // При изменении опции items обновляем высоты детей.
    if (oldOptions.items !== this._options.items) {
        this._heights = [];
        Object.entries(this._children).forEach(([childName, child]) => {
            if (childName.startsWith('item')) {
                this._heights.push(child.clientHeight);
         }
      });
   }
}

_beforeUnmount

_beforeUnmount() {
    this._busChannel.unsubscribe('onItemsChange', this._handleItemsChange);
}

Опасность побочных эффектов в _beforeMount

Побочные эффекты в _beforeMount могут привести к проблемам, например, к утечкам памяти.

Пример. Есть 2 контрола:

<Tab>
    <ws:if data="{{ _showList }}">
        <List />
    </ws:if>
</Tab>

_beforeMount у List выглядит вот так:

_beforeMount(options, context, receivedState) {
    // Обратите внимание на эту подписку.
    EventBus.channel('ListChannel').subscribe('onExampleEvent', this._handleEvent);
 
    if (receivedState) {
        // Если состояние пришло с сервера, то используем его.
        this._items = receivedState;
   } else {
       return options.source.query().then((items) => {
           this._items = items;
           return items;
      });
   }
}

За то время, пока List ходит за данными, состояние приложения изменяется, и Tab умирает. В данном случае у List _beforeUnmount не вызовется, потому что он не был монтирован в DOM. Значит не произойдёт отписка от события, и шина будет держать этот контрол в памяти.

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

Чтобы избежать этой проблемы, все подписки нужно делать в _afterMount().

Доступ к элементам вёрстки и дочерним контролам

В Wasaby изменение состояния дочерних html-элементов и контролов следует выполнять через обновление свойств родительского контрола. Значительно реже вам может потребоваться манипулировать дочерними элементами напрямую. Для этого в JS-модуле используйте объект this._children, содержащий дочерние элементы (html-элементы, контролы), которым задан атрибут name. В этом объекте название свойства соответствует имени элемента, а значением является сам элемент.

<!-- Template.wml -->
<div name="myDiv">
   <MyControl name="myButton" />
</div>
// Module.js
define("Module", ["UI/Base", "wml!Template"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template,
      _afterMount: function(options) {
         console.log(this._children.myDiv.width);
         console.log(this._children.myButton.width);
      }
   });
   return ModuleClass;
});

Обращение к дочерним контролам возможно на всех фазах кроме хука _beforeMount.

Запрещено обращаться к дочерним контролам дочерних контролов, поскольку это нарушает принцип инкапсуляции.

Использование кастомных событий

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

Для объявления события контрола достаточно вызывать метод _notify() вот так:

define("Demo/Module", ["UI/Base", "wml!Demo/Template"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template,
      _afterMount: function(options) {
         this._notify("myEvent");
      }
   });
   return ModuleClass;
});

Подписку на событие задают в WML-шаблоне контрола с помощью атрибута on:<имя события>. В качестве значения атрибут принимает имя метода-обработчика.

<!-- WML -->
<div>
   <Demo.Module on:myEvent="eventHandler()" />
</div>
// JavaScript
define("Parent/Module", ["UI/Base", "wml!Parent/Template"], function(Base, template){
   var ModuleClass = Base.Control.extend({
      _template: template,
      eventHandler: function(event) {
         console.log(event);
      }
   });
   return ModuleClass;
});

См. демо-пример на CodeSandbox

Механизм событий можно использовать для передачи данных. Подробнее о работе с событиями можно прочитать в разделе Работа с событиями.

Подключение стилей

Файл со стилями необходимо подключать через 'css!...'.

В следующем примере показано создание CSS-файла и его подключение в контрол.

/* Demo/Style.css */
.myClass {
   border: 1px solid #ccc;
   padding: 5px;
}
<!-- Demo/Template.wml -->
<div class="myClass">
   <p>©1996-2019 Tensor. All rights reserved</p>
</div>
define("Demo/Module", ["UI/Base", "wml!Demo/Template", "css!Demo/Style"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template
   });
   return ModuleClass;
});

Встраивание шаблона в шаблон

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

Для встраивания шаблона применяют директиву <ws:partial>.

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

<!-- Demo/Footer.wml -->
<div style="border: 1px solid #ccc; padding: 5px">
   <ws:if data="{{ showCopyright }}">
      <p>©1996-2019 Tensor. All rights reserved</p>
   </ws:if>
   <ws:if data="{{ showLink }}">
      <a href="https://tensor.ru/about/corporation">About company</a>
   </ws:if>
</div>
<!-- Demo/Template.wml -->
<div>
   <p>Tensor is a large holding company engaged in information technologies.</p>
   <ws:partial template="wml!Demo/Footer" showCopyright="{{true}}" />
</div>
// JavaScript
define("Demo/Module", ["UI/Base", "wml!Demo/Template"], function(Base, template) {
   var ModuleClass = Base.Control.extend({
      _template: template
   });
   return ModuleClass;
});

Результат:

Tensor is a large holding company engaged in information technologies.
©1996-2018 Tensor. All rights reserved

См. демо-пример на CodeSandbox

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

Переменная showCopyright была передана в директиве <ws:partial> с помощью одноимённого атрибута (см. Demo/Template.wml). Переменная showLink отсутствует, и в результате приводится к значению false в условии для директивы <ws:if>.

Более подробно про области видимости мы поговорим в других разделах руководства.