Как цепочка методов работает под капотом

Сегодня я наткнулся на статью о человеке, который не ответил на вопрос о цепочке методов на собеседовании. В статье не было слишком глубокого понимания того, что на самом деле представляет собой цепочка методов и почему она работает именно так, как работает, поэтому я решил, что будет уместно более глубокое рассмотрение предмета.

Что такое цепочка методов?

Когда jQuery был запущен, он поразил всех, кто щелкает клавиатурой, обновляет браузер, своим элегантным использованием цепочки методов. Технически такой дизайн известен как беглый интерфейс, и при правильном подходе он может привести к довольно аккуратному коду. Это выглядит примерно так:

$('.tabs .tab')
  .removeClass('.active')
  .eq(selectedTabIndex)
  .addClass('.active')
  .closest('.tabGroup')
  .find('.header')
  .addClass('.active')

Хотя в JavaScript всегда был такой элегантный синтаксис, до jQuery цепочка методов не была особенно популярна, если я правильно помню. ES5 принес нам методы массива, такие как .map(), .filter() или .slice(), и цепочка методов стала еще более популярной. Если вы начали заниматься интерфейсной разработкой в ​​эпоху после jQuery, это может показаться вам более знакомым:

tableBody.innerHTML = someArray
  .filter(([a]) => a < 100)
  .map(([a, b, c]) => `
    <tr>
      <td>${a}</td>
      <td>${STATUSES[b]}</td>
      <td>${c * 1000}</td>
    </tr>
  `)
  .join('')

Но прежде чем мы перейдем к этому, давайте поговорим о…

Что такое выражения?

Говоря простым языком, выражения — это разные способы выражения некоторого значения. Например, 2 + 2 означает 4. И Math.random() * 10 также является некоторым выражением/значением, хотя мы не знаем, что это за значение, пока не запустим этот код. Фактически, Math.random() само по себе является выражением. И так 10. Выражения могут быть простыми, представляющими только необработанное значение, или они могут быть настолько сложными, насколько это возможно. Дело в том, что они всегда представляют некоторое значение.

Вы можете заменять выражения на другие выражения. Работает ли это на самом деле, зависит от типа значения, представленного каждым выражением — ваш код будет работать до тех пор, пока значения, которые представляют выражения, совместимы (не обязательно одно и то же значение, но совместимы). Это включает замену более сложных выражений простыми значениями. Например, если вы знаете, что выражение TABS[currentTab] всегда будет оцениваться как 1, вы можете просто сказать вместо него 1, и ваш код все равно будет работать. Дело в том, что мы не всегда знаем, что такое currentTab, поэтому мы не можем просто привести выражение к конкретному значению. Вот почему мы используем выражения в программировании.

Как мы видели в примере Math.random(), вызов функции или метода также является выражением. (Между прочим, метод — это просто функция, которая присваивается свойству объекта и вызывается определенным образом — вместе с объектом, на котором она живет.) Значение, выраженное вызовом функции, является возвращаемым значением функции.

Итак, теперь мы можем вернуться к цепочке методов?

Конечно. Давайте подробнее рассмотрим наш пример с массивом. Вот еще раз для справки:

tableBody.innerHTML = someArray
  .filter(([a]) => a < 100)
  .map(([a, b, c]) => `
    <tr>
      <td>${a}</td>
      <td>${STATUSES[b]}</td>
      <td>${c * 1000}</td>
    </tr>
  `)
  .join('')

Первая часть выражения — someArray, которая предположительно содержит массив с неверным именем. Второе выражение someArray.filter(...). Как вы знаете, это выражение оценивается как массив, некоторые элементы которого удалены в зависимости от того, что возвращает функция обратного вызова. После оценки этого выражения и подстановки значения на его место мы получаем:

tableBody.innerHTML = filteredSomeArray
  .map(([a, b, c]) => `
    <tr>
      <td>${a}</td>
      <td>${STATUSES[b]}</td>
      <td>${c * 1000}</td>
    </tr>
  `)
  .join('')

Далее у нас есть filteredSomeArray.map(...). Мы можем вызвать .map() в цепочке, потому что предыдущее выражение оценивается как значение, совместимое с этим методом, а именно массив. Фактически, любой объект, поддерживающий .map(), сработал бы. Как и предыдущее выражение, filteredSomeArray.map(...) также оценивается как массив — массив, в котором каждый элемент преобразуется (сопоставляется) с тем, что возвращает обратный вызов. В этом случае мы преобразуем массивы из трех значений в строку, представляющую часть HTML. В итоге получаем:

tableBody.innerHTML = filteredAndMappedSomeArray
  .join('')

Наконец, метод .join() преобразует массив в строку. (Поскольку мы сейчас работаем со строкой, мы могли бы продолжить связывать строковые методы с этой цепочкой методов, но нам это не нужно, поэтому мы просто остановимся. 😁) Когда вызывается последний метод , возвращаемое значение этого метода — в данном случае строка — присваивается tableBody.innerHTML.

Наша очередь

Итак, давайте реализуем что-нибудь, используя свободный интерфейс. Давайте реализуем небольшую функцию запроса HTTP, которая позволяет нам выполнять запрос GET или POST с использованием предоставленных параметров и обрабатывать результаты.

Как мы уже говорили, для того, чтобы работать с плавными интерфейсами, нам нужно вернуть объект.

let request = (url) => {
  return {
    url: new URL(url, location.origin),
  }
}

Мы будем использовать объект для хранения сведений о нашем запросе (например, URL). Итак, мы вернули объект, но у него нет методов. Мы не можем связать вызовы методов, если у нас нет методов. Давайте добавим пару методов, которые позволят нам указать глагол HTTP.

let request = (url) => {
  return {
    url: new URL(url, location.origin),
    method: 'GET',
    post() {
      this.method = 'POST'
    },
    get() {
      this.method = 'GET'
    },
  }
}

Попробуем их использовать:

request('/api/books')
  .get()
  .post()

Получаем ошибку:

Uncaught TypeError: Cannot read properties of undefined (reading 'post')

Метод .post() не существует для возвращаемого значения метода .get(). Это связано с тем, что метод .get() ничего не возвращает, поэтому выражение request('/api/books').get() оценивается как undefined. По сути, мы пытаемся сделать undefined.post(), что не работает по понятным причинам.

Чтобы сделать интерфейс плавным, нам нужно убедиться, что наши методы .get() и .post() возвращают объект.

let request = (url) => {
  return {
    url: new URL(url, location.origin),
    method: 'GET',
    post() {
      this.method = 'POST'
      return this
    },
    get() {
      this.method = 'GET'
      return this
    },
  }
}

Ключевое слово this представляет объект, для которого вызывается метод. В этом случае методы this внутри методов post() и get() представляют один и тот же объект, который их включает, — тот, который возвращается функцией request().

Теперь, если мы попробуем еще раз, мы увидим, что цепочка .post() после .get() завершается без ошибок. Воодушевленные этим успехом, давайте добавим метод для указания параметров запроса. Мы хотим отслеживать все параметры и иметь возможность вызывать метод несколько раз, чтобы дополнить параметры новыми.

let request = (url) => {
  return {
    url: new URL(url, location.origin),
    params: {},
    method: 'GET',
    post() {
      this.method = 'POST'
      return this
    },
    get() {
      this.method = 'GET'
      return this
    },
    params(data) {
      Object.assign(this.params, data)
      return this
    },
  }
}

Теперь вы будете видеть узор более четко. Мы изменяем базовые данные (такие свойства, как .method и .params), а затем возвращаем файл this. Возврат this поддерживает цепочку.

Для полноты давайте также добавим метод для выполнения запроса и обработки результатов.

let request = (url) => {
  return {
    url: new URL(url, location.origin),
    params: {},
    method: 'GET',
    post() {
      this.method = 'POST'
      return this
    },
    get() {
      this.method = 'GET'
      return this
    },
    params(data) {
      Object.assign(this.params, data)
      return this
    },
    exec(callback) {
      let fetchOptions = { method: this.method }
      if (this.params) {
        if (this.method === 'GET')
          for (let k of this.params)
            this.url.searchParams.set(k, this.params[k])
        else
          Object.assign(fetchOptions, {
            body: JSON.stringify(this.params),
            headers: { 'Content-Type': 'application/json' },
          })
      } 
      return fetch(this.url, fetchOptions)
        .then(res => {
          if (res.ok) return res.json()
        })
        .then(json => {
          if (json) callback(null, json)
          else callback(Error('not good')
        })
        .catch(callback)
      }
  }
}

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

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

Итак, чтобы сделать почтовый запрос с некоторыми данными, мы делаем это:

request('/api/books')
  .post()
  .params({ title: 'JavaScript the Good Parts' })
  .params({ author: 'Douglass Crockford' })
  .exec(error => {
    if (error) alert('OMG, no! It failed.')
    else location.assign('/success.html')
  })

Не только это

Если вы посмотрите только на этот один пример, который я привел выше, у вас может сложиться впечатление, что цепочка методов упрощается за счет возврата this. Это неправда (каламбур). Помните, мы говорили что-то о «совместимых» объектах. Пока возвращаемый объект совместим, мы можем вернуть любой объект — любой объект, который поддерживает следующий метод в цепочке.

Продемонстрируем это на другом примере.

Я воспользуюсь примером из оригинальной статьи из-за лени. Мы будем делать почти то же самое (сцепляющие математические операции), но дадим ему возможность обрабатывать деление на ноль.

let operation = (x = 0) => {
  return {
    add(y) {
      return operation(x + y)
    },
    sub(y) {
      return operation(x - y)
    },
    mul(y) {
      return operation(x * y)
    },
    div(y) {
      if (y === 0) return error
      return operation(x / y)
    },
    valueOf() {
      return x
    },
  }
}
let error = {
  add() { return error },
  sub() { return error },
  mul() { return error },
  div() { return error },
  valueOf() { return },
}

Первое, что нужно отметить, это то, что мы нигде не используем this. Поскольку объекты, созданные operation(), совместимы друг с другом, мы можем вернуть operation() из любого из методов, и это по-прежнему позволит создавать цепочки. Рассмотрим этот пример:

let result = operation().add(2).mul(4) // => operation(8)

Первое выражение, operation().add(2), будет оцениваться как operation(0 + 2) или operation(2). Это позволяет нам привязать к нему operation(2).mul(4), что, в свою очередь, оценивается как operation(2 * 4), что равно operation(8).

Во-вторых, следует отметить, что метод .div() вернет либо новый объект operations(), либо объект error. Объект error имеет все те же методы, что и объект operations(), что делает его совместимым с любым последующим методом операции, который мы можем захотеть вызвать. Однако ни один из них ничего не делает, кроме как возвращает один и тот же объект error. Если вы когда-нибудь попытаетесь разделить объект operations() на ноль, с этого момента вы будете работать с объектом error, и все последующие операции будут игнорироваться. Это приводит к подавлению ошибок деления на ноль без нежелательных результатов, таких как Infinity.

let result = operations().add(2).div(0).add(4) // => error

В этом примере operations().add(2) оценивается как operations(2). operations(2).div(0) оценивается как error. Следующий вызов метода, .add(4), вызывается для объекта error, а не для объекта operations(), поэтому он ничего не делает. Он просто возвращает error.

В качестве примечания: метод valueOf() — это волшебный метод, который заставит движок JavaScript приводить объекты operations() к числам в некоторых выражениях. Поэтому вы можете преобразовать объекты operations() в число, добавив к нему 0 (например, operations().add(2) + 0) или используя функцию Number() (например, Number(operations().add(2))).

Заключение

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

Удачи в получении (или сохранении) этой работы! 🙌