Перехват вызова функции в JavaScript

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

Немного об АОП

Идея перехватов вызовов взята из парадигмы аспектно-ориетированного программирования (АОП), но я не хочу вдаваться в дебри этой парадигмы и использовать её терминологию, чтобы ничего не усложнять. Я просто попытаюсь изложить основную идею этой парадигмы.

АОП представляет способ модуляризации (сборки в одном месте) так называемой сквозной, или разбросанной функциональности. Типичный пример такой функциональности это логирование, при котором выводить информацию о том, что происходит в коде, нужно в совершенно разных местах. Очень удобно было бы написать функцию для вывода информации о событиях какого-то типа, но не писать явно её вызов при возникновении события такого типа, а просто сказать, где мы хотим её вызывать. Это и позволяет сделать АОП.

Как это будет выглядеть

Как известно, слова мало чего стоят, поэтому показываю код. Перехватывать будем вызов чего-нибудь простого и всем известного, например, функций, которые производят математические операции над двумя числами.

// Файл functions.js
 
function sum(a, b) {
  return a + b;
}
 
function sub(a, b) {
  return a - b;
}
 
function mult(a, b) {
  return a * b;
}
 
function div(a, b) {
  return a / b;
}

До и после вызова функции будем выводить информацию о её имени, аргументах и возвращаемом результате.

// Файл interceptors.js
 
function printCallInfo(callback, args) {
  var logStr = "Invoking function '" + callback.name
    + "' with args: " + args.join(', ');
 
  console.log(logStr);
}
 
function printCallResult(callback, args, result) {
  var logStr = "Function '" + callback.name
    + "' is successfully invoke\nresult: " + result + "\n";
 
  console.log(logStr);
}

Теперь создадим перехватчик, который будет хранить информацию о том, что мы хотим делать до перехватываемого вызова функции и после.

// Файл interceptors.js

var logInterceptor = new Interceptor(printCallInfo,  printCallResult);

Про реализацию Interceptor’a поговорим в конце статьи. Сейчас важнее понять, зачем он вообще нужен, а не как работает.

Скажем, что хотим перехватывать вызовы наших функций.

sum = logInterceptor.interceptInvokes(sum);
sub = logInterceptor.interceptInvokes(sub);
div = logInterceptor.interceptInvokes(div);
mult = logInterceptor.interceptInvokes(mult);

Обратите внимание, что никакого повторяющегося кода в теле функций нет. Просто теперь при их вызовах волшебным образом будет вестись лог. Если нам понадобится изменить его формат или вовсе выключить, сделать это можно, изменив код только в одном месте.

sum(1, 2);
sub(3, 2);
mult(5, 4);
div(20, 4);
 
// turn off logging
logInterceptor.preInvoke = function (){};
logInterceptor.postInvoke = function (){};
 
sum(1, 2);
 
// turn on logging
logInterceptor.preInvoke = printCallInfo;
logInterceptor.postInvoke = printCallResult;
 
sum(10, 2);

На консоли при выполнении этого скрипта увидим:

Invoking function ‘sum’ with args: 1, 2
Function ‘sum’ is successfully invoke
result: 3

Invoking function ‘sub’ with args: 3, 2
Function ‘sub’ is successfully invoke
result: 1

Invoking function ‘mult’ with args: 5, 4
Function ‘mult’ is successfully invoke
result: 20

Invoking function ‘div’ with args: 20, 4
Function ‘div’ is successfully invoke
result: 5

Invoking function ‘sum’ with args: 10, 2
Function ‘sum’ is successfully invoke
result: 12

Ещё пару примеров

Очень часто внутри функции приходится приводить её аргументы к нужному формату перед тем, как заняться собственно тем, для чего эта функция написана. Не редко конвертировать аргументы в разных функциях нужно к одному и тому же формату. Давайте создадим перехватчик, который будет заниматься приведением значений аргументов наших математических функций к числам.

// Файл interceptors.js
 
var convertToNumberInterceptor = new Interceptor(function (callback, args) {
  for (var i = 0; i < args.length; i++) {
    args[i] = parseFloat(args[i]);
  }
});

Используем этот перехватчик для функции сложения.

console.log(sum(3, '2')); // 32
sum = convertToNumberInterceptor.interceptInvokes(sum);
console.log(sum(3, '2')); // 5

Если же мы хотим кидать исключение, когда значение аргумента какой-нибудь нашей математической функции не является числом, то для этого тоже можно использовать перехватчик, который может выглядеть как-то так:

// Файл interceptors.js
 
var checkArgsInterceptor = new Interceptor(function (callback, args) {
  for (var i = 0; i < args.length; i++) {
    if (typeof args[i] !== 'number') {
      throw new Error("Argument with index " + i + " is not a number");
    }
  }
});

sum = checkArgsInterceptor.interceptInvokes(sum);
console.log(sum(1, 2)); // 3
 // Uncaught Error: Argument with index 0 is not a number 
console.log(sum('1', 2));

Реализация

Теперь можно наконец-то посмотреть, как это реализуется. Функция-конструктор перехватчика принимает в качестве параметров функции, которые будут вызваны до и после перехватываемого вызова функции. Оба параметра необязательны, и не переданный или не являющийся функцией параметр будет заменён на пустую функцию.

// Файл interceptor.js
 
function emptyFunction() {};
 
function Interceptor(preInvoke, postInvoke) {
  /*
   * Если preInvoke не функция, то заменяем этот аргумент
   * на пустую функцию. Вдруг нам не нужно будет ничего делать
   * до перехватываемого вызова.
   */
  var preInvoke = typeof preInvoke === 'function' ?
    preInvoke : emptyFunction,
 
    // Аналогично с postInvoke
    postInvoke = typeof postInvoke === 'function' ?
    postInvoke : emptyFunction;
 
  this.preInvoke = preInvoke;
  this.postInvoke = postInvoke;
}

Прототип перехватчика будет содержать единственный метод interceptInvokes, который будет преобразовывать функцию таким образом, чтобы её вызовы перехватывались.

// Файл interceptor.js
 
Interceptor.prototype = {
  constructor: Interceptor,
  interceptInvokes: function (callback) {
    /*
     * Запоминаем текущий контекст, так как в
     * в следующей анонимной функции, но уже другой
     */
    var self = this;
    return function () { // преобразованная функция
      // конвертируем arguments в массив
      var args = Array.prototype.slice.call(arguments, 0),
        /*
         * Массив с аргументами для preInvoke и postInvoke.
         * Добавим в него в качестве первого элемента функцию,
         * вызов которой перехватывается. Вдруг нам понадобится дополнительная информация о ней.
         */
        result;        
 
      // Делаем что-то до перехватываемого вызова
      self.preInvoke.call(self, callback, args); 
      result = callback.apply(self, args);
      // Делаем что-то после
      self.postInvoke.call(self, callback, args, result);
 
      return result;
    };
  }
}

Всю реализацию желательно завернуть в анонимную функцию, которую тут же можно вызвать и вернуть в качестве результата объект Interceptor. Таким образом, можно спрятать переменные, необходимые только для локального использования (в нашем случае это только emptyFunction), а так же обеспечить возможность легкого переименования Interceptor‘a.

З.Ы.

Напоследок хочу предостеречь от активного использования этого подхода. Во-первых, из-за того, что он придаёт коду не совсем очевидное поведение. Во-вторых, из-за его чрезмерной ресурсоёмкости, которая является следствием использования медленных методов call и apply. Существуют целые JS АОП-фреймворки, в которых, впрочем, скорее всего, так же будет похожая реализация с использованием тех же медленных call и apply.

На этом всё. Желаю вам успехов!

Скачать исходники (1.6 Кб)

  • Пока вижу применения этого подхода только для логирования результатов. Какие еще реальные применения есть?

    • 1. В статье приведён пример валидации аргументов функции, в котором бросается исключения в случае, если параметры функции, вызов которой перехватывается, не удовлетворяют какому-то условию.
      2. Так же можно использовать этот подход для конвертации аргументов функции перед её вызовом. Это пример тоже есть в статье.
      3. Удобно при помощи этого подхода делать профилирование кода, то есть производить оценку быстродействия его участков. Простыми словами говоря, можно навешать на функции перехватчики, которые замеряют скорость выполнения их вызовов.
      4. Проверку прав доступа также удобно было бы делать при помощи этой штуки. (Случай немного не для бек-ендного браузерного джиэса, но есть же node.js). Можно повесить перехватчик на функции, совершающие действия, доступные или нет для определённых ролей пользователей, и в зависимости от роли давать совершить действие или нет.

      И не забывайте, что эту штуку нужно использовать с осторожностью.

  • Ivan Sverdoff