CODEGURU

Винтажный JS — bind, call и apply своими руками

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

Итак, для начала давайте вспомним, что вообще делают методы bind, call и apply. Все три метода делают, собственно, одну и ту же вещь — позволяют вызвать функцию, указав ей объект, который она должна использовать в качестве контекста. Контекст, если забыли, это то, к чему мы обращаемся с помощью ключевого слова this. Например, у нас есть объект:

const user = {
  fullName: 'Иван Человеков'
}

И у нас есть абстрактная функция, возвращающая имя:

function getName() {
  return this.fullName;
}

Если мы просто вызовем эту функцию, то в ответ получим undefined. Потому что при вызове контекстом для неё будет глобальный объект window, у которого нет свойства fullName. А вот если бы мы могли сказать ей, что контекстом должен быть объект user, то мы бы получили в ответ Иван Человеков. Именно это мы и можем сделать с помощью методов bind, call и apply.

const user = {
  fullName: 'Иван Человеков'
}

function getName() {
  return this.fullName;
}

getName.bind(person)() // Иван Человеков

У метода bind есть особенность. Он возвращает функцию с новым контекстом. Т.е нам нужно её вызывать самостоятельно. Именно поэтому в примере после bind стоят скобки, чтобы сразу же вызвать возвращаемую функцию. Методы call и apply самостоятельно вызывают функцию, к которой применяются. Между собой они отличаются тем, как они работают с аргументами, которые мы передаём в вызываемую функцию.

const user = {
  firstName: '',
  lastName: '',
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

function getFullName(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
  return this.fullName();
}

getFullName.call(user, 'Иван', 'Человеков');

getFullName.apply(user, ['Иван', 'Человеков']);

Как видно из примера, метод call принимает аргументы просто в виде списка, е метод apply — в виде массива. Метод bind, в силу своих особенностей (помните же, что он возвращает функцию, которую нужно ещё вызвать), может принимать аргументы двумя способами.

getFullName.bind(user, 'Иван', 'Человеков')();
getFullName.bind(user, 'Иван')('Человеков');
getFullName.bind(user)('Иван', 'Человеков');

Т.е мы можем передавать аргументы как в сам метод bind, так и непосредственно в возвращаемую функцию.

Теперь, когда мы вспомнили как это всё работает, можем приступить к написанию собственной функции bind. Для начала определимся с логикой:

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

Тут всё понятно. А как сделать так, чтобы нужный нам объект стал контекстом функции? Самый простой способ — сделать эту функцию методом объекта.

function bind(fn, context) {
  return function() {
    const uuid = Date.now().toString();
    context[uuid] = fn;
    const res = context[uuid]();
    delete context[uuid];
    return res;
  }
}

Ну вот, собственно, логика готова. Разберём, что тут у нас происходит. Чтобы сделать функцию методом объекта контекста нам нужно создать у него новое свойство. И свойство это должно быть уникальным, чтобы не получилось так, что мы случайно изменим существующее поле. Самый простой способ получить уникальное значение — использовать время. Оно вполне уникально и никогда не повторяется. Формируем уникальную строку const uuid = Date.now().toString();, создаём новое поле у объекта и кладём туда нашу функцию context[uuid] = fn;. Затем мы помещаем вызов функции в новую переменную const res = context[uuid](); и возвращаем объекту контекста изначальное состояние delete context[uuid];. И в самом конце возвращаем переменную с функцией return res.

Теперь нужно разобраться с аргументами. Как помните, аргументы могут быть переданы как в саму функцию bind, так и в возвращаемую функцию. И нам нужно обработать оба варианта. Это достаточно просто, нам поможет синтаксис rest parameters.

function bind(fn, context, ...rest) {
  return function(...args) {
    const uuid = Date.now().toString();
    context[uuid] = fn;
    const res = context[uuid](...rest, ...args);
    delete context[uuid];
    return res;
  }
}

Готово. Давайте проверим работоспособность нашей крафтовой функции bind.

const user = {
  firstName: '',
  lastName: '',
  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

function getFullName(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
  return this.fullName();
}

bind(getFullName, user, 'Иван', 'Человеков')() // => Иван Человеков
bind(getFullName, user, 'Иван')('Человеков') // => Иван Человеков
bind(getFullName, user)('Иван', 'Человеков') // => Иван Человеков

Да, мы сделали это, написали свой собственный bind 🥇. Написать call и apply теперь проще простого.

call(fn, context, ...args) {
  const uuid = Date.now().toString();
  context[uuid] = fn;
  const res = context[uuid](...args);
  delete context[uuid];
  return res;
}

apply(fn, context, args) {
  const uuid = Date.now().toString();
  context[uuid] = fn;
  const res = context[uuid](...args);
  delete context[uuid];
  return res;
}

Тут нам не нужно возвращать функцию, поэтому кода немного меньше, а логика та же самая. Немного лишь отличается работа с аргументами, Как помните функция call принимает список аргументов. Поэтому мы используем синтаксис rest arguments. В случае с apply это не нужно, так как тут мы точно знаем, что принимаем массив. Вот и всё. Это оказалось не так уж и сложно 🎉.