Привязка контекста (this) к функции в javascript и частичное применение функций

в 17:49, , рубрики: javascript, jquery, Программирование, метки:

В предыдущем посте я описал, что this в javascript не привязывается к объекту, а зависит от контекста вызова. На практике же часто возникает необходимость в том, чтобы this внутри функции всегда ссылался на конкретный объект.
В данной статье мы рассмотрим два подхода для решения данной задачи.
1. jQuery.proxy — подход с использованием популярной библиотеки jQuery
2. Function.prototype.bind — подход, добавленный в JavaScript 1.8.5. Рассмотрим также его применение для карринга (частичного применения функции) и некоторые тонкости работы, о которых знают единицы.

Введение

Рассмотрим простой объект, содержащий свойство x и метод f, который выводит в консоль значение this.x

var object = {
    x: 3,
    f: function() {
        console.log(this.x);
    }
}

Как я указывал в предыдущем посте, при вызове object.f() в консоли будет выведено число 3. Предположим теперь, что нам нужно вывести данное число через 1 секунду.

setTimeout(object.f, 1000); // выведет undefined

//простой способ это обойти — сделать вызов через обёртку:
setTimeout(function() { object.f(); }, 1000); // выведет 3

Каждый раз использовать функцию обертку — неудобно. Нужен способ привязать контекст функции, так, чтобы this внутри функции object.f всегда ссылался на object

1. jQuery.proxy

jQuery.proxy(function, context);
jQuery.proxy(context, name);

Ни для кого не секрет, что jQuery — очень популярная библиотека javascript, поэтому вначале мы рассмотрим применение jQuery.proxy для привязки контекста к функции.
jQuery.proxy возвращает новую функцию, которая при вызове вызывает оригинальную функцию function в контексте context. С использованием jQuery.proxy вышеописанную задачу можно решить так:

setTimeout($.proxy(object.f, object), 1000); // выведет 3

Если нам нужно указать несколько раз одинаковый callback, то вместо дублирования

setTimeout($.proxy(object.f, object), 1000);
setTimeout($.proxy(object.f, object), 2000);
setTimeout($.proxy(object.f, object), 3000);

лучше вынести результат работы $.proxy в отдельную переменную

var fn = $.proxy(object.f, object);
setTimeout(fn, 1000);
setTimeout(fn, 2000);
setTimeout(fn, 3000);

Обратим теперь внимание на то, что мы дважды указали object внутри $.proxy (первый раз метод объекта — object.f, второй — передаваемй контекст — object). Может быть есть возможность избежать дублирования? Ответ — да. Для таких случаев в $.proxy добавлена альтернативная возможность передачи параметров — первым параметром должен быть объект, а вторым — название его метода. Пример:

var fn = $.proxy(object, "f");
setTimeout(fn, 1000);

Обратите внимание на то, что название метода передается в виде строки.

2. Function.prototype.bind

func.bind(context[, arg1[, arg2[, ...]]])

Перейдем к рассмотрению Function.prototype.bind. Данный метод был добавлен в JavaScript 1.8.5.

Совместимость с браузерами

Firefox (Gecko): 4.0 (2)
Chrome: 7
Internet Explorer: 9
Opera: 11.60
Safari: 5.1.4

Эмуляция Function.prototype.bind из Mozilla Developer Network

Function.prototype.bind = function (oThis) {
    if (typeof this !== "function") {
      // closest thing possible to the ECMAScript 5 internal IsCallable function
      throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
    }
 
    var aArgs = Array.prototype.slice.call(arguments, 1), 
        fToBind = this, 
        fNOP = function () {},
        fBound = function () {
          return fToBind.apply(this instanceof fNOP && oThis
                                 ? this
                                 : oThis,
                               aArgs.concat(Array.prototype.slice.call(arguments)));
        };
 
    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
 
    return fBound;
  };

Function.prototype.bind имеет 2 назначения — статическая привязка контекста к функции и частичное применение функции.
По сути bind создаёт новую функцию, которая вызывает func в контексте context. Если указаны аргументы arg1, arg2… — они будут прибавлены к каждому вызову новой функции, причем встанут перед теми, которые указаны при вызове новой функции.

2.1. Привязка контекста

Использовать bind для привязки контекста очень просто, достаточно просто рассмотреть пример:

function f() {
    console.log(this.x);
}
var bounded = f.bind({x: 3}); // bounded - новая функция - "обертка", у которой this ссылается на объект {x:3}
bounded();// Выведет 3

Таким образом пример из введения можно записать в следующем виде:

var object = {
    x: 3,
    f: function() {
        console.log(this.x);
    }
}
setTimeout(object.f.bind(object), 1000); // выведет 3
2.2. Частичное применение функций

Для упрощения рассмотрим сразу пример использования bind для частичного применения функций

function f(x, y, z) {
    console.log(x + y + z);
}
var bounded = f.bind(null, 3, 5); // напомню что первый параметр - это контекст для функции, поскольку мы не используем this в функции f, то контекст не имеет значения - поэтому в данном случае передан null
bounded(7); // распечатает 15 (3 + 5 +7)
bounded(17); // распечатает 25 (3 + 5 +27)

Как видно из примера — суть частичного применения функций проста — создание новой функции с уменьшенным количеством аргументов, за счет «фиксации» первых аргументов с помощью функции bind.
На этом можно было бы закончить статью, но… Функции, полученные с использованием метода bind имеют некоторые особенности в поведении

2.3. Особенности bind

В комментариях к предыдущей статье было приведено 2 примера, касающихся bind (раз, два).
Я решил сделать микс из этих примеров, попутно изменив строковые значения, чтобы было проще с ними разбираться.
Пример (попробуйте угадать ответы)

function ClassA() {
  console.log(this.x, arguments)
}

ClassA.prototype.x = "fromProtoA";

var ClassB = ClassA.bind({x : "fromBind"}, "bindArg");

ClassB.prototype = {x : "fromProtoB" };

new ClassA("callArg");
new ClassB("callArg");
ClassB("callArg");
ClassB.call({x: "fromCall"}, 'callArg');

Ответы

fromProtoA { '0': 'callArg' }
fromProtoA { '0': 'bindArg', '1': 'callArg' }
fromBind { '0': 'bindArg', '1': 'callArg' }
fromBind { '0': 'bindArg', '1': 'callArg' }

Прежде чем разобрать — я перечислю основные особенности bind в соответствии со стандартом.

2.3.1. Внутренние свойств

У объектов Function, созданных посредством Function.prototype.bind, отсутствует свойство prototype или внутренние свойства [[Code]], [[FormalParameters]] и [[Scope]].

Это ограничение отличает built-in реализацию bind от вручную определенных методов (например, вариант из MDN)

2.3.2. call и apply

Поведение методов call и apply отличается от стандартного поведения для функций, а именно:

boundFn.[[Call]] = function (thisValue, extraArgs):
 
  var
      boundArgs = boundFn.[[BoundArgs]],
      boundThis = boundFn.[[BoundThis]],
      targetFn = boundFn.[[TargetFunction]],
      args = boundArgs.concat(extraArgs);
 
      return targetFn.[[Call]](boundThis, args);

В коде видно, что thisValue не используется нигде. Таким образом подменить контекст вызова для функций полученных с помощью Function.prototype.bind с использованием call и applyнельзя!

2.3.3. В конструкторе

В конструкторе this ссылается на новый (создаваемый) объект. Иначе говоря, контекст заданный при помощи bind просто игнорируется. Конструктор вызывает обычный [[Call]] исходной функции.
Важно! Если в конструкторе отсутствует return this, то возвращаемое значение в общем случае неопределено!

Разбор примера

function ClassA() {
  console.log(this.x, arguments)
}

ClassA.prototype.x = "fromProtoA";

var ClassB = ClassA.bind({x : "fromBind"}, "bindArg");

 // исходя 2.3.1, эта строчка не делает ровным счетом ничего в built-in реализациях Function.prototype.bind
// В ручной из Mozilla Developer Network эта строчка сыграет роль, пос
ClassB.prototype = {x : "fromProtoB" };

// Тут все просто - никакой bind мы еще не использовали
// Результат: fromProtoA ["callArg"]
new ClassA("callArg"); 

// Исходя из 2.3.3 - this ссылается на новый объект. поскольку в bind был задан параметр bindArg, то в выводе аргументов он займет первое место
// Результат:  fromProtoA ["bindArg", "callArg"]
// При ручной реализации Function.prototype.bind результат будет другой: fromBind ["bindArg", "callArg"]. 
new ClassB("callArg");

// Обычный вызов bind функции, поэтому в качестве контекста будет {x : "fromBind"}, первым параметром bindArg (заданный через bind), вторым - "callArg"
// Результат: fromBind ["bindArg", "callArg"]
ClassB("callArg");

// Из пункта 2.3.2. следует, что при вызове метода call на функции, полученной с использованием bind передаваемый контекст игнорируется.
// Результат: fromBind ["bindArg", "callArg"]
ClassB.call({x: "fromCall"}, 'callArg');

Заключение

В данном посте я постарался описать основные методы привязывания контекста к функциям, а также описал некоторые особенности в работе Function.prototype.bind, при этом я старался оставить только важные детали (с моей точки зрения).
Если вы заметили ошибки/неточности или хотите что-то уточнить/добавить — напишите в ЛС, поправлю

Автор: Sirian

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js