Как стать автором
Обновить

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

Время на прочтение8 мин
Количество просмотров15K
В первой части статьи я рассказывал об асинхронной загрузке модулей при помощи 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, который очищает все ненужные ресурсы, отвязывает события и т.д. Классы-потомки переопределяют его при необходимости. Если все функции сохранены в переменных, то нет никакой проблемы передать их событию, а у события через равенство типов нет никакой проблемы определить 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: ErrorHappenedOptions): void }, //Callback
        ErrorHappenedOptions> //Options
        = new Events.Event<{ (error: ErrorHappenedOptions): 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.

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

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

Работа над ошибками согласно комментариям:
Часть 2.5: Построение масштабируемых приложений на TypeScript — Часть 2.5. Работа над ошибками и делегаты
Теги:
Хабы:
Всего голосов 35: ↑30 и ↓5+25
Комментарии16

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань