Построение масштабируемых приложений на TypeScript. Часть 2 — События или зачем стоит изобретать собственный велосипед

В первой части статьи я рассказывал об асинхронной загрузке модулей при помощи Require.js и стандартных языковых средств TypeScript. Неосторожно я раньше времени задел тему организации работы с абстрактными событиями о чем мне очень быстро напомнили в комментариях. В частности был задан вопрос зачем придумывать собственный велосипед, если существует давно проверенный и отлично работающий Backbone.Events и/или прочие аналоги.

Если вас интересует ответ на этот вопрос, альтернативная реализация на TypeScript и не пугает чтение кода, то прошу под кат.

Все просто — все существующие Javascript фреймворки на сегодняшний день абсолютно не поддер-живают одно из главных преимуществ TypeScript – статическую типизацию и ее следствие — кон-троль типов на стадии компиляции. JS фреймворки в этом винить нет никакого смысла. В языке просто нет средств для этого.

Однако, это далеко не единственна проблема. Что гораздо хуже, в JS очень часто используются примеси, в частности весь Backbone построен на них. Нет, в них нет ничего плохого в самих по себе. Это вполне естественная и жизнеспособная практика в контексте чистого прототипного динамического JS и небольших проектов. В случае TS она приводит к ряду неприятных последствий, особенно при попытке создать приложение хоть сколько-нибудь серьезного размера:

  1. Аналогом примесей в классическом ООП является множественное наследование. Не хотелось бы вступать в «священную войну», но на мой взгляд множественное наследование всегда плохо. Особенно в условиях динамической типизации, без возможности контролировать поведение объектов хотя бы через явную или неявную реализацию интерфейсов а-ля C#. Естественно в JS об этом можно даже не мечтать, поэтому отладка, поддержка и рефакторинг подобного кода — полный кошмар.
  2. Если отвлечься от высоких материй, то TS просто не поддерживает подобное на уровне язы-ка. Бэкбоновский extend это полный аналог наследования в TS и это вполне работает для Model и View, но абсолютно не подходит для событий. Нет, мы конечно можем унаследовать все классы в приложении от Backbone.Event или его аналога в зависимости от фреймворка и добиться результата, но это не решает 3-ей проблемы:
  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); } 

На мой взгляд это полный энтерпрайз головного мозга и убийство всех функциональных черт JS/TS.

Тем не менее, без отписки от событий не обойтись в любом более-менее сложном приложении, т.к. без этого невозможно корректно уничтожать сложные объекты и управлять поведением объектов, участвующих во взаимодействии через события. Например, у нас есть некоторый базовый класс формы 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.

Всем спасибо за положительную оценку первой части.

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

ссылка на оригинал статьи http://habrahabr.ru/post/185160/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *