Базовый класс контрола
Мы руководствуемся концепцией представления интерфейса как набора контролов.
Контрол — это изолированная часть функционала, которую можно использовать повторно при разработке интерфейса. В файловой структуре он представляет собой минимум два файла: 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 и детей. Единственный хук, который вызывается и на сервере, и на клиенте. |
_afterMount(options) | Служит для инициализации состояния, которое зависит от DOM, а также подписок на события. Не стоит без необходимости изменять реактивные свойства в данном методе, т.к. это приведёт к перерисовкам. | Вызывается после построения вёрстки и отрисовки кадра браузером. Вызывается один раз после построения на клиенте. |
Фаза "Обновление"
Сигнатура хука | Предназначение | Момент вызова |
---|---|---|
_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
изменилась.
Данная возможность не рекомендуется к использованию, потому что она ведёт к замедлению оживления. Большинство мест, где нужна асинхронная загрузка данных сводится к двум вариантам:
- Загрузка данных при построении страницы. В данном случае, нужно использовать предзагрузку данных, подробнее читайте в статье.
- Загрузка данных при открытии карточки. Можно открывать карточку по данным из реестра и одновременно посылать запрос за дополнительными данными.
Если вам всё-таки нужен этот функционал, то ниже дан пример его использования:
_beforeMount(options, context, receivedState) {
if (receivedState) {
// Если состояние пришло с сервера, то используем его
this._items = receivedState;
} else {
return options.source.query().then((items) => {
// Инициализируем состояние на сервере, чтобы оттуда пришла правильная вёрстка
this._items = items;
// Это значение будет сериализовано при построении на сервере
return items;
});
}
}
Если вы уверены, что вам никак не обойтись без этого, то не забывайте про две основные ошибки, допускаемые при использовании асинхронного построения:
- Загрузка данных водопадом. Если кратко, то это ситуация, когда сначала родитель грузит данные, потом ребёнок, а потом его ребёнок и т.д. Более подробно читайте в статье Загрузка данных водопадом.
- Возвращение
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()
.
Доступ к элементам вёрстки и дочерним контролам
Задание динамически-генерируемого имени не поддерживается
То есть в опцию name
можно передавать только строчку. В случае определения значения опции name
с использованием оператора {{ }} например так:
<Controls.buttons:Button name="{{myChildControlName}}" />
возникнут ошибки с очищением ссылок на контролы в объекте _children
.В 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
Механизм событий можно использовать для передачи данных. Подробнее о работе с событиями можно прочитать в разделе Работа с событиями.
Подключение стилей
Для подключения стилей следует задать статическое свойство _styles
. Значением свойства является массив с именами 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.js
define("Demo/Module", ["UI/Base", "wml!Demo/Template"], function(Base, template) {
var ModuleClass = Base.Control.extend({
_template: template
});
ModuleClass._styles = ['Demo/Style'];
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>
.
Более подробно про области видимости мы поговорим в других разделах руководства.