Многие знают о механизме Event-Dispatcher-Listener'ов, реализованному во многих языках программирования. Я же создам подобный механизм не для Event'ов, а для любого метода объекта Javascript — Object.
Я не претендую на оригинальность, нет. Основная цель статьи — рассмотреть интересные механизмы прототипирования в Javascript, создание декораторов и, собственно, постараться хоть немного раскрыть мощь и гибкость это чудесного языка, который так часто обижают и недооценивают.

Итак, первый (и, надеюсь, не последний) рецепт в моей поваренной книге Javascript.

Блюдо

Function Call Listener, фаршированный (многофункциональный), запечённый в собственном соку (без использования каких-то библиотек, на чистом Javascript).

Ингредиенты

1. Понимание прототипирования в Javascript
2. Понимание анонимных функций Javascript
3. Приблизительное знание класса Function

Не пугайтесь, я опишу ингредиенты поподробней.

Рецепт

Нырнём на пару метров вглубь

Итак, приступим. Вначале позаимствуем небольшого помощника у John Resig'а (из его книги):

Function.prototype.method = function(methodName, f) {
      return this.prototype[methodName] = f;
    }

    * This source code was highlighted with Source Code Highlighter

.

Что делает этот метод? Дословно: он позволяет легко добавлять новый метод в прототип текущей функции.
Как вы знаете, функция в Javascript является также классом и конструктором класса (не бейте, сдаюсь — нет в Javascript никаких классов, в привычном понимании).

Верно и следующее: конструктор любого класса — это функция (или по-научному — экземпляр класса Function).
Исходя из принципов прототипирования — после добавления нового метода method(...) в прототип класса Function, у всех экземпляров класса Function появился новый метод method(...) (но помни: в Javascript нет никаких методов, Нео).

Любой класс — Object, Array, Number, YourClassName — является экземпляром класса Function, т.е. просто функцией. А значит у нас появились: Object.method(...), Array.method(...), YourClassNam.method(...)

Если считать функцию — привычным классом, то мы по сути добавили статический метод всем-всем-всем классам (Javascript реально вставляет :-) ).

Строим декоратор

Ладно, хватит о гениальном. Перейдём теперь к моему коду:

Function.method("decorate", function(f) {
  var oldMe = this;
  var newMe = f;
  newMe.old = oldMe;
  return newMe;
})

Вуаля! Как я говорил, класс Function сам по себе является функцией, или экземпляром класса Function (а-а-а :-))
А значит, как только мы добавили:

Function.prototype.method = function(...) {}

У нас тут же появился Function.method(...) (уже не в прототипе, а в экземпляре класса Function)

После выполнения кода выше у нас появится новый метод Function.prototype.decorate(...). И опять же — метод появился в прототипе Function, а значит и во всех-всех-всех классах. Но вот тут как раз мне это не принципиально, а важно лишь присутствие метода decorate(...) у всех функций.

Что делает метод decorate(...)?

// сохраняем старую функцию
var oldMe = this;

// возвращаем переданную в метод новую функцию, которая "за пазухой" хранит старую.
var newMe = f;
newMe.old = oldMe;
return newMe;

Конечно этот код можно сильно сократить, но так — более наглядно.

Но это не всё, самое интересное — впереди. Вы думаете я зря назвал мой метод — decorate? Нет, не зря! Это и есть знакомый многим декоратор.

Пример декорирования

Создадим простенький класс:

// мега-класс, содержащий число
function MyCoolNumber(initNumber) {
  this.value = initNumber;
}

Но число ли? Нет, конечно. Я могу передать туда всё, что угодно.

new MyCoolNumber('на ура') // пройдёт "на ура"

Но что же делать? Мне категорически не хочется менять конструктор. Выход есть: напишем декоратор для ограничения передаваемых параметров и применим его к нашему классу.

function strictArgs() { // здесь будет переменное число аргументов, поэтому я их не именую
  var types = [].slice.apply(arguments); // передаём типы
  return function() {
    var params = [].slice.apply(arguments);
    if (params.length != types.length)
      throw "Ошибка! Ожидалось " + types.length + " аргумент(ов), а пришло " + params.length;
    for (var i=0, l=params.length; i < l; i++) {
      if (!(params[i] instanceof types[i]) &#038;& !(params[i].constructor == types[i]))
        throw "Ошибка! Аргумент #" + (i+1) + " должен быть " + types[i].name;
    }
    arguments.callee.old.apply(this, arguments); // собственно, вызов "старой" функции
  }
}

Вернёмся к нашему подопытному:

function MyCoolNumber(initNumber) {
  this.value = initNumber;
}
MyCoolNumber = MyCoolNumber.decorate(strictArgs(Number))

new MyCoolNumber(); // Ошибка! Ожидалось 1 аргумент(ов), а пришло 0
new MyCoolNumber(1, 2, 3); // Ошибка! Ожидалось 1 аргумент(ов), а пришло 3
new MyCoolNumber("строка"); // Ошибка! Аргумент #1 должен быть Number
var x = new MyCoolNumber(6); // OK!
alert(x.value) // 6, что и следовало ожидать, значит декоратор отработал нормально.

Рассмотрим всё это безобразие повнимательнее. Начнём с момента применения декоратора.

1. вызывается ф-ция strictArgs с одним аргументом — Number
2. var types = [].slice.apply(arguments); // мы создали отдельный объект params == [Number] - массив из 1 эл-та: класса Number.
3. strictArgs(...) возвращает новую ф-цию, внутри которой:
> 1. var params = [].slice.apply(arguments); // мы точно так же достаём массив уже аргументов, переданных в наш будущий обновлённый MyCoolNumber;
> 2. начинаем сравнивать эти 2 массива на совпадение размерностей и типов

4. мы декорируем ф-цию MyCoolNumber той, которая вернулась из strictArgs(...)
Заметим: связь оригинальной ф-ции с декоратором осуществляется через arguments.callee.old.apply(this, arguments):
> * arguments — стандартный объект для описания аргументов вызываемой функции
> * arguments.callee — сама функция-декоратор
> * arguments.callee.old — помните, что такое — old? Когда мы передаём функцию-декоратор в метод decorate(...), он добавляет этой ф-ции атрибут old, ссылающийся на «старую» ф-цию
> * arguments.callee.old.apply(...) — стандартный метод класса Function. Не буду о нём, скажу лишь, что он вызывает ф-цию с заданным scope и arguments
> * arguments.callee.old.apply(this, arguments) — собственно, подтверждение вышесказанного

Вот так, вроде бы на основных моментах я заострил внимание.
Ах да, забыл! Как «скинуть» с функции декоратор и вернуть старую? Нет ничего проще:

Function.method("recover", function() {
  return this.old || this;
})

Теперь продолжим!

Смотрим на объект, слушаем методы

Мы плавно подходим к завершению моего рецепта. Еще чуть-чуть специй, и можно в топку… тьфу, в духовку! :)

Object.method('before', function(methodName, f){
  var method = listenerInit.call(this, methodName);
  if (method)
    method.listenersBefore.push(f);
})

Object.method('after', function(methodName, f){
  var method = listenerInit.call(this, methodName);
  if (method)
    method.listenersAfter.push(f);
})

Как можно догадаться, всё самое важное происходит внутри некой ф-ции listenerInit(...), но о ней — позже. Пока-что просто поверим, что она делает все необходимые приготовления.
Как добавить listener — понятно. Теперь нужна возможность его «убрать»:

Object.method('removeBefore', function(methodName, f){
  var method = listenerInit.call(this, methodName);
  if (method) {
    var _nl = [];
    while (method.listenersBefore.length) {
      var _f = method.listenersBefore.shift();
      if (_f != f)
        _nl.push(_f);
    }
    method.listenersBefore = _nl;
  }
})

Object.method('removeAfter', function(methodName, f){
  var method = listenerInit.call(this, methodName);
  if (method) {
    var _nl = [];
    while (method.listenersAfter.length) {
      var _f = method.listenersAfter.shift();
      if (_f != f)
        _nl.push(_f);
    }
    method.listenersAfter = _nl;
  }
})

Может этот способ и не оптимальный, я просто взял первый из головы.
Наконец-то, венец этого рецепта, связь декоратора и event-listener схемы — та самая ф-ция listenerInit:

function listenerInit(methodName) {

  var method = this[methodName];
  if (typeof method != "function")
    return false;

  // продекорировано, или ещё нет?
  if (!method.listenable) {
    this[methodName] = method.decorate(function(){
      var decorator = arguments.callee;
      decorator.listenable = true;

      var list = decorator.listenersBefore;
      for (var i = 0, l = list.length; i < l; i++) {
        if (typeof list[i] == "function" &#038;& list[i].apply(this, arguments) === false)
          return;
      }

      var ret = decorator.old.apply(this, arguments);
      list = decorator.listenersAfter;
      for (var i = 0, l = list.length; i < l; i++)
        list[i].apply(this, arguments);

      return ret;
    });
    method = this[methodName];
  }

  method.listenersBefore = method.listenersBefore instanceof Array ? method.listenersBefore : [];
  method.listenersAfter = method.listenersAfter instanceof Array ? method.listenersAfter : [];

  return method;
}

Эту функцию можно условно разделить на блоки.

Блок 1: проверка — не обманывают ли нас, есть ли в этом объекте такой метод?

var method = this[methodName];
if (typeof method != "function")
  return false;

А в конце видим: return method, т.е. listenerInit(...) возвращает либо false, либо уже «украшенный» метод.

Блок 2: создание соответствующего декоратора, если таковой ещё не определён.
Что же в этом декораторе?

1. Запускаем все listener'ы из массива listenersBefore. Если хоть 1 из них возвращает Boolean false — прекращаем выполнение
2. Вызов базового метода
3. Запускаем все listener'ы из массива listenersAfter
4. Декоратор возвращает то значение, которое вернул базовый метод

Блок 3: инициализация массивов method.listenersBefore и method.listenersAfter.

Плюшки

Модификация 1: спрячем ф-цию listenerInit с глаз долой. Для этого используем Javascript-замыкание:

(function(){
 // listenerInit(...) и все-все-все
 // .....
})()

Модификация 2: загрязнять стандартные классы вроде Object — очень плохо, поэтому можно модифицировать ваш конкретный класс:

YourClass.method('before', function(methodName, f){
  var method = listenerInit.call(this, methodName);
  if (method)
    method.listenersBefore.push(f);
})

Ну и так далее. Как говорится — нет предела совершенству!

Всё! Пирог слеплен, теперь в духовку (т.е. в ваш мозг) на пол-часа, и — готово. Приятного аппетита!

Как это есть

Ну и конкретный пример использования:

// Создадим простенький класс
var Num = function(x) {
  this.x = x;
}
// Опишем прототип
Num.prototype.x = null;
Num.prototype.getX = function() {
  return this.x;
};
Num.prototype.setX = function(x) {
  return this.x = x;
}

// Создадим экземпляр
var t = new Num(6);

// Добавим слушателя after
t.after("getX", function(){
  alert('Поздравляем! Ваш X == ' + this.x + '!');
})

// Добавим слушателя after
t.after("getX", function(){
  alert('И ещё раз поздравляем!');
})

// Добавим слушателя before, с проверкой
var f = function(x){
  if (x < 0 || x > 10) {
    alert('Нет! Значение должно быть в отрезке [0, 10]');
    return false;
  }
}
t.before("setX", f)

// поиграем:
t.getX(); // Поздравляем! Ваш X == 6! -> 'И ещё раз поздравляем! -> вызов базового getX(...)
t.setX(100); // Нет! Значение должно быть в отрезке [0, 10] -> базовый setX(100) - не вызвался
alert(t.x); // 6
t.setX(4); // Всё ОК, вызывается базовый метод setX(4)
alert(t.x); // 4
t.removeBefore("setX", f) // удаляем нашу проверку f(...)
t.setX(100); // всё ОК, сработал базовый setX(100)
alert(t.x); // 100

Кстати, весь приведённый код — кросс-браузерный, таков мой принцип работы.

(c)пасибо ,,(o_O),,,



Постоянные ссылки

При копировании ссылка на TeaM RSN обязательна!

URI

Html (ЖЖ)

BB-код (Для форумов)