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

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

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

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

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

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

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

// MyControl.ts
import { Control } from 'UI/Base';

export default class extends Control {
    // логика работы контрола
}

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

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

<!-- MyControl.wml -->
<div>Hello Wasaby</div>
// MyControl.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!MyControl';

export default class extends Control {
    protected _template: TemplateFunction = template;
    // логика работы контрола
}

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

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

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

<!-- MyControl.wml -->
<div title="{{ text }}">{{ text }}</div>
// MyControl.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!MyControl';

export default class extends Control {
    protected _template: TemplateFunction = template;
    text: "Hello Wasaby!";
}

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

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

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

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

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

<!-- Parent/Template.wml -->
<Demo.Module color="{{ someColor }}" />
<!-- Demo/Template.wml -->
<div style="color: {{ _options.color }};">
   Hello Wasaby!
</div>
// Demo/Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Demo/Template';

export default class extends Control {
    protected _template: TemplateFunction = template;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

_beforeMount

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

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

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

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

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

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

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

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

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

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

_componentDidMount

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

_afterMount

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

_beforeUpdate

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

_afterRender

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

_afterUpdate

// TypeScript
protected _afterUpdate(oldOptions): void {
    // При изменении опции 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

// TypeScript
protected _beforeUnmount(): void {
    this._busChannel.unsubscribe('onItemsChange', this._handleItemsChange);
}

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

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

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

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

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

// TypeScript
protected _beforeMount(options, context, receivedState): void | Promise<unknown>
    // Обратите внимание на эту подписку.
    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-элементов и контролов следует выполнять через обновление свойств родительского контрола. Значительно реже вам может потребоваться манипулировать дочерними элементами напрямую. Для этого в TS-модуле используйте объект this._children, содержащий дочерние элементы (html-элементы, контролы), которым задан атрибут name. В этом объекте название свойства соответствует имени элемента, а значением является сам элемент.

<!-- Template.wml -->
<div name="myDiv">
   <MyControl name="myButton" />
</div>
// Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Template';

export default class extends Control {
    protected _template: TemplateFunction = template;
    protected _afterMount(options): void {
        console.log(this._children.myDiv.width);
        console.log(this._children.myButton.width);
    }
}

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

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

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

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

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

// Demo/Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Demo/Template';

export default class extends Control {
    protected _template: TemplateFunction = template;
    protected _afterMount(options): void {
        this._notify("myEvent");
    }
}

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

<!-- WML -->
<Demo.Module on:myEvent="eventHandler()" />
// Parent/Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Parent/Template';

export default class extends Control {
    protected _template: TemplateFunction = template;
    eventHandler(event): void {
        console.log(event);
    }
}

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

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

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

Файл со стилями необходимо подключать через инструкцию import '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>
// Demo/Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Demo/Template';
import 'css!Demo/Style';

export default class extends Control {
    protected _template: TemplateFunction = template;
}

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

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

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

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

<!-- Demo/Footer.wml -->
<div style="border: 1px solid #ccc; padding: 5px">
   <ws:if data="{{ showCopyright }}">
      <p>©1996-2018 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>
// Demo/Module.ts
import { Control, TemplateFunction } from 'UI/Base';
import * as template from 'wml!Demo/Template';

export default class extends Control {
    protected _template: TemplateFunction = template;
}

Результат:

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>.

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