В первой части статьи я рассказывал об асинхронной загрузке модулей при помощи Require.js и стандартных языковых средств TypeScript. Неосторожно я раньше времени задел тему организации работы с абстрактными событиями о чем мне очень быстро напомнили в комментариях. В частности был задан вопрос зачем придумывать собственный велосипед, если существует давно проверенный и отлично работающий Backbone.Events и/или прочие аналоги.
Если вас интересует ответ на этот вопрос, альтернативная реализация на TypeScript и не пугает чтение кода, то прошу под кат.
Все просто — все существующие Javascript фреймворки на сегодняшний день абсолютно не поддер-живают одно из главных преимуществ TypeScript – статическую типизацию и ее следствие — кон-троль типов на стадии компиляции. JS фреймворки в этом винить нет никакого смысла. В языке просто нет средств для этого.
Однако, это далеко не единственна проблема. Что гораздо хуже, в JS очень часто используются примеси, в частности весь Backbone построен на них. Нет, в них нет ничего плохого в самих по себе. Это вполне естественная и жизнеспособная практика в контексте чистого прототипного динамического JS и небольших проектов. В случае TS она приводит к ряду неприятных последствий, особенно при попытке создать приложение хоть сколько-нибудь серьезного размера:
- Аналогом примесей в классическом ООП является множественное наследование. Не хотелось бы вступать в «священную войну», но на мой взгляд множественное наследование всегда плохо. Особенно в условиях динамической типизации, без возможности контролировать поведение объектов хотя бы через явную или неявную реализацию интерфейсов а-ля C#. Естественно в JS об этом можно даже не мечтать, поэтому отладка, поддержка и рефакторинг подобного кода — полный кошмар.
- Если отвлечься от высоких материй, то TS просто не поддерживает подобное на уровне язы-ка. Бэкбоновский extend это полный аналог наследования в TS и это вполне работает для Model и View, но абсолютно не подходит для событий. Нет, мы конечно можем унаследовать все классы в приложении от Backbone.Event или его аналога в зависимости от фреймворка и добиться результата, но это не решает 3-ей проблемы:
- События Backbone или любого другого JS фреймворка не типизированы. Прощай статический анализ и все преимущества TS.
Что вообще такое события и что вообще от них нужно
Если не вдаваться в дебри, то событие это некоторый сигнал, получаемый приложением извне или генерируемый внутри, по получении которого приложение может что-либо делать, а может и не делать. Лично я не знаю, что еще можно сказать о событиях, если меня поднять в 2 часа ночи, направить фонарик в лицо и начать допрашивать)
Но все меняется, если появляется некоторый контекст. В нашем случае контекстом является JavaScript, который без любых надстроек в виде TS уже является 100% ООП языком. В частности все сущности в JS это объекты. Так же объектом являются и DOMEvent, создаваемые браузером. Т.е., если продолжить аналогию, то любое событие является объектом.
Допустим, что в случае Backbone событие это тоже объект. Вопрос — а какой? По сути, у нас есть коллекция callback'ов, которые вызываются по тем или иным правилам. Коллекция универсальна. Она способна принять любые функции. Т.е., опять я на этом остановлюсь, у нас нет типизации.
Но постойте. Какова наша цель? Получить статический анализ кода. Значит, событие должно быть объектом и иметь тип — класс. Это первое требование, которое я выдвигаю. События должны быть описаны классами, чтобы их можно было типизировать.
Отсюда вытекает второе требование — события должны обрабатываться и работать однотипно, т.е. наследоваться от базового класса. Если события наследуются, то даже не вникая в дебри SOLID и т.п., ясно, что наследоваться от них совсем плохая идея.
Третье требование — минимальный необходимый функционал. На событие можно подписаться, отписаться от него, а также его вызвать. Все прочее — не критично. Естественно, событие может иметь ни одного или несколько обработчиков.
Четвертое соображение — мы говорим о событиях в контексте асинхронной загрузки модулей, которые строго типизированы, что контролируется на этапе компиляции. Т.е. У нас есть ситуация позднего связывания и строгой типизации, т.е. подписчики всегда знают о том, на какое событие подписываются, а управление зависимостями не их проблема.
Пятое — я хочу, чтобы события могли быть частью любого объекта, независимо от иерархии наследования.
Собрав мысли в кучу и включив KMFDM я приступаю к решению созданных самому себе проблем.
Исходники, по-прежнему на Codeplex: https://tsasyncmodulesexampleapp.codeplex.com
Первые мысли
И так, любой объект, событие это класс и т.д. означают 2 вещи: во-первых у нас есть базовый класс Event:
export class Event
{
//Реализацию временно опускаю
Add(callback: any): void { /* Делаем полезную работу */ }
Remove(callback: any): void { /* Делаем полезную работу */ }
Trigger(): void { /* Делаем полезную работу */ }
}
Во-вторых использовать мы его будем примерно так, аккуратно украдкой посмотрев в сторону C# и вдохновившись его примером:
/// <reference path="Events.ts" />
import Events = require('Framework/Events');
export class MessagesRepo
{
public MessagesLoaded: Events.Event = new Events.Event();
}
class SomeEventSubscriber
{
//Не пинайте. Это просто пример
private MessagesRepo = new MessagesRepo();
public Foo()
{
this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); });
}
}
Т.е. событие это просто публичный член класса. Не более и не менее. Что нам это дает:
- События известны на стадии компиляции
- События могут быть объявлены в любом классе
- У нас есть минимально необходимый функционал, он сосредоточен в одном месте и легко модифицируется.
- Все события реализуются одним классом или его наследниками, т.е. мы легко можем поме-нять логику их работы, например создав потомка SecureEvent, унаследованного от Event, выполняющего callback'и только при определенных условиях.
- Нет типичного геморроя JS фреймворков с контекстом, который теперь строго зависит от эк-земпляров объектов, опечатками в названиях событий и т.д.
Чего у нас по-прежнему нет:
1. Строгой типизации
2. Из-за отсутствия контекста, невозможно выполнить отписку от события callback'а, заданного анонимной функцией, т.е. любой callback мы должны где-то сохранять, что неудобно.
3. Не типизированные параметры события
Строгая типизация
Разберемся с первой проблемой. Используем нововведение TypeScript 0.9 — обобщения (generics):
export class Event<Callback extends Function>
{
//Реализацию все еще опускаю
Add(callback: Callback): void { /* Делаем полезную работу */ }
Remove(callback: Callback): void { /* Делаем полезную работу */ }
Trigger(): void { /* Делаем полезную работу */ }
}
И посмотрим на применение:
/// <reference path="Events.ts" />
import Events = require('Framework/Events');
export class MessagesRepo
{
public MessagesLoaded: Events.Event<{ (messages: string[]): void }>
= new Events.Event<{ (messages: string[]): void }>();
}
class SomeEventSubscriber
{
//Не пинайте. Это просто пример
private MessagesRepo = new MessagesRepo();
public Foo()
{
this.MessagesRepo.MessagesLoaded.Add(function (messages: string[]) { alert('MessagesLoaded'); });
}
}
При этом, следующий код:
public Foo()
{
this.MessagesRepo.MessagesLoaded.Add(function (message: string) { alert('MessagesLoaded'); });
}
Выдаст ошибку:
Supplied parameters do not match any signature of call target:
Call signatures of types '(message: string) => void' and '(messages: string[]) => void' are incompatible:
Type 'String' is missing property 'join' from type 'string[]'
А callback без параметров (ну не нужны они нам), скомпилируется спокойно:
public Foo()
{
this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); });
}
Конструкция Callback extends Function
необходима для корректной компиляции, т.к. TS должен знать, что Callback можно вызвать.
Анонимные callback'и и возврат состояний подписки
Как я уже писал выше, при данной реализации мы не можем отписать анонимные callback'и, что приводит к абсолютно несвойственной для лаконичного JS с его анонимными функциями многословности и объявлению лишних пременных. Например:
private FooMessagesLoadedCallback = function () { alert('MessagesLoaded'); }
public Foo()
{
this.MessagesRepo.MessagesLoaded.Add(this.FooMessagesLoadedCallback);
}
На мой взгляд это полный энтерпрайз головного
Тем не менее, без отписки от событий не обойтись в любом более-менее сложном приложении, т.к. без этого невозможно корректно уничтожать сложные объекты и управлять поведением объектов, участвующих во взаимодействии через события. Например, у нас есть некоторый базовый класс формы FormBase, от которого унаследованы все формы в нашем приложении. Предположим, что у него есть некоторый метод Destroy, который очищает все ненужные ресурсы, отвязывает события и т.д. Классы-потомки переопределяют его при необходимости. Если все функции сохранены в переменных, то нет никакой проблемы передать их событию, а у события через равенство ссылок не никакjй проблемы определить callback и удалить его из коллекции. Данный сценарий невозможен при использовании анонимных функций.
Я предлагаю решать вторую проблему следующим путем:
export class Event<Callback extends Function>
{
public Add(callback: Callback): ITypedSubscription<Callback, Event<Callback>>
{
var that = this;
var res: ITypedSubscription<Callback, Event<Callback>> =
{
Callback: callback,
Event: that,
Unsubscribe: function () { that.Remove(callback); }
}
/* Делаем полезную работу */
return res;
}
public Remove(callback: Callback): void { /* Делаем полезную работу */ }
public Trigger(): void { /* Делаем полезную работу */ }
}
/** Базовый интерфейс подписки на событие. Минимальная функциональность. Можем просто отпи-саться и все. */
export interface ISubscription
{
Unsubscribe: { (): void };
}
/** Типизированная версия. Включает ссылки на событие и callback */
export interface ITypedSubscription<Callback, Event> extends ISubscription
{
Callback: Callback;
Event: Event;
}
Т.е просто возвращаем в методе Add ссылку на событие, callback и обертку для метода Remove. После этого остается реализовать элементарный «финализатор» у подписчика:
/** Кстати, такие комментарии опознаются IntelliSense ;) */
class SomeEventSubscriber
{
private MessagesRepo = new MessagesRepo();
/** Тут будем хранить все подписки нашего класса */
private Subscriptions: Events.ISubscription[] = [];
public Foo()
{
//Одним движение регистрируем подписку одного события
this.Subscriptions.push(this.MessagesRepo.MessagesLoaded.Add(function () { alert('MessagesLoaded'); }));
//И совершенно другого
this.Subscriptions.push(this.MessagesRepo.ErrorHappened.Add(function (error: any) { alert(error); }));
}
/** Просто проходит по массиву подписок и отписывает все события независимо от типа */
public Destroy()
{
for (var i = 0; i < this.Subscriptions.length; i++)
{
this.Subscriptions[i].Unsubscribe();
}
this.Subscriptions = [];
}
}
Типизация параметров события
Все очень просто. Опять используем обобщения:
export class Event<Callback extends Function, Options>
{
public Add(callback: Callback): ITypedSubscription<Callback, Event<Callback, Options>>
{
var that = this;
var res: ITypedSubscription<Callback, Event<Callback, Options>> =
{
Callback: callback,
Event: that,
Unsubscribe: function () { that.Remove(callback); }
}
/* Делаем полезную работу */
return res;
}
public Remove(callback: Callback): void { /* Делаем полезную работу */ }
public Trigger(options: Options): void { /* Делаем полезную работу */ }
}
Класс-издатель теперь будет выглядеть так:
export interface ErrorHappenedOptions
{
Error: any;
}
export class MessagesRepo
{
public MessagesLoaded: Events.Event<
{ (messages: string[]): void } //Callback
, string[]> //Options
= new Events.Event<{ (messages: string[]): void }, string[]>();
public ErrorHappened: Events.Event<
{ (error: any): void }, //Callback
ErrorHappenedOptions> //Options
= new Events.Event<{ (error: any): void }, ErrorHappenedOptions>();
}
А вызов события так:
var subscriber: Messages.SomeEventSubscriber = new Messages.SomeEventSubscriber();
subscriber.MessagesRepo.MessagesLoaded.Trigger(['Test message 1']);
subscriber.MessagesRepo.ErrorHappened.Trigger({
Error: 'Test error 1'
});
На этом мои хотелки к событиям заканчиваются. За полными исходными кодами и действующим примером прошу на Codeplex.
Всем спасибо за положительную оценку первой части.
В зависимости от интереса к статье и тематики комментариев буду выбирать тему третьей части. Пока планирую написать свой взгляд на виджеты/формы, их загрузку и централизованное «управление памятью» в приложении.
Автор: Keeperovod