12k
All articles

Цепочки методов в JavaScript: плюсы и минусы

Цепочка методов в JavaScript: как она работает, когда улучшает читаемость и почему длинные цепочки мешают отладке, async и производительности.

OpenReplay Team
OpenReplay Team
Цепочки методов в JavaScript: плюсы и минусы

Цепочка методов (method chaining) — это вызов нескольких методов одного и того же объекта в одной последовательности, и работает это потому, что каждый метод возвращает объект, у которого всё ещё есть методы для вызова.

Если вам когда-нибудь приходилось смотреть на цепочку из шести шагов, которая молча возвращает undefined, и не понимать, куда поставить точку останова, вы уже знакомы с компромиссом. Точки дёшево писать и дорого распутывать. Для встроенных методов массивов и строк возвращаемое значение «несёт» следующий метод; для ваших собственных объектов каждый метод заканчивается на return this. Цепочки — это инструмент читаемости, а не поведение по умолчанию. Они оправдывают себя на коротких конвейерах и начинают дорого обходиться на длинных. В этой статье разбираются механизм работы, реальные преимущества, настоящие недостатки (трудности отладки, лишняя работа, запутанная асинхронность), как правильно построить собственный объект с поддержкой цепочек и конкретное правило, когда пора остановиться.

Ключевые выводы

  • Цепочки методов работают потому, что каждый метод возвращает объект с новыми методами; встроенные методы возвращают новое значение, которое несёт следующий метод, а пользовательские объекты поддерживают цепочки за счёт return this в конце каждого метода.
  • Сама цепочка практически не влияет на производительность; реальная цена — это лишние проходы и выделения памяти, как в случае, когда .filter().map()[0] делает два полных проход по массиву, а .find() — один и останавливается на первом совпадении.
  • Метод, написанный как стрелочная функция, ломает цепочку, потому что у него нет собственного this, поэтому он никогда не указывает на экземпляр, и call, bind и apply этого не изменят.
  • Практическое правило: один шаг — всегда нормально, два — обычно нормально, три или четыре должны заставить задуматься, а пять и больше стоит разбить на именованные шаги.
  • Цепочки оптимизируют скорость написания кода; именование промежуточных значений оптимизирует чтение и последующую отладку — и это разные цели.

Что такое цепочка методов и как она работает?

Цепочка методов работает потому, что каждый метод возвращает объект, у которого всё ещё есть методы для вызова. Встроенные методы массивов и строк возвращают новое значение (массив, строку), которое несёт собственные методы, так что можно продолжать:

const topNames = users
  .filter(user => user.active)
  .map(user => user.name)
  .sort();

filter возвращает массив, поэтому доступен map; map возвращает массив, поэтому доступен sort. Для собственных объектов вы воспроизводите это, возвращая экземпляр из каждого метода:

class QueryBuilder {
  constructor() { this.parts = []; }
  where(clause) { this.parts.push(`WHERE ${clause}`); return this; }
  limit(n) { this.parts.push(`LIMIT ${n}`); return this; }
  build() { return this.parts.join(" "); }
}

new QueryBuilder().where("active = 1").limit(5).build();
// "WHERE active = 1 LIMIT 5"

Поскольку where и limit возвращают this, следующий метод разрешается на том же экземпляре. Это тот же принцип, на котором построены fluent-API билдеров.

Плюсы: читаемые конвейеры и fluent-API

Цепочки проявляют себя лучше всего на коротких конвейерах, где шаги образуют одно понятное преобразование. Код читается слева направо как последовательность (отфильтровать, затем преобразовать, затем отсортировать) и позволяет не придумывать имена одноразовым промежуточным переменным, на которые вы больше никогда не сошлётесь. Для преобразования в два шага цепочка часто является самым прямым выражением намерения:

const activeNames = users.filter(u => u.active).map(u => u.name);

Fluent-API и билдеры опираются на тот же механизм, чтобы читаться как предложения: query.where(...).limit(...).build() или expect(value).to.be.an('array'). Когда вся цепочка описывает одну целостную операцию, синтаксис действительно работает на читателя.

Минусы: отладка, лишняя работа и запутанная асинхронность

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

Трудности отладки. Тяжелее всего отлаживать цепочки, которые выдают неверное итоговое значение, потому что нет естественного места, куда поставить точку останова или вывести промежуточное значение, не разобрав цепочку. В итоге вы вставляете console.log внутрь колбэка, смешивая отладочный код с логикой, или всё равно разбиваете цепочку на шаги. В продакшн-коде фронтенда это частый сценарий сбоя: вы видите неверный вывод, но не видите, какой шаг его породил. Здесь помогает session replay: воспроизведение взаимодействия, которое привело к некорректному состоянию, возвращает вам входные данные, скрытые «свёрнутой» цепочкой, — ту же информацию, которую показало бы разбиение цепочки на именованные шаги.

Лишняя работа. Цепочки подталкивают к тому, чтобы «обработать всё», даже когда вы имели в виду другое. .filter().map()[0] делает два полных проход по массиву и выделяет промежуточный массив, а затем выбрасывает всё, кроме одного элемента. Если вам нужно только первое совпадение, правильный инструмент — Array.prototype.find(). Он проходит по массиву только до того момента, когда колбэк принимает элемент, возвращает этот элемент и дальше не идёт:

const name = users.find(u => u.active)?.name;

Непрозрачность возвращаемых типов. В длинной цепочке вида data.transform().normalize().validate().save() вам нужно отслеживать, что возвращает каждый шаг, без каких-либо аннотаций типов во время выполнения. Когда шаг в середине возвращает нечто неожиданное, вся цепочка незаметно меняет форму.

Запутанная асинхронность. Смешивание асинхронного управления потоком с преобразованиями данных в одной цепочке .then() размывает намерение. Разделение загрузки и разбора данных от преобразования читается яснее:

const res = await fetchUsers();
const users = await res.json();
const activeNames = users.filter(u => u.active).map(u => u.name);

Это суждение о читаемости, а не о производительности. await и .then() выполняют одну и ту же работу.

Производительность: точки бесплатны, проходы — нет

Сама цепочка практически не имеет собственных издержек производительности: вызов метода плюс обращение к свойству на каждом шаге — мелочь по сравнению с работой по обходу коллекции. Реально дорого вам обходится выполнение большего объёма работы, чем нужно. .filter().map()[0] — это два полных прохода O(n) плюс промежуточный массив; find() — один проход с досрочной остановкой. Вывод: считайте итерации и выделения памяти, а не точки. Цепочка из пяти методов, обходящая данные один раз, может быть быстрее цепочки из двух методов, обходящей их дважды. Используйте методы с досрочным выходом, такие как find и some, всякий раз, когда вам нужен только первый подходящий результат.

Как построить собственный API с поддержкой цепочек?

Чтобы сделать объект пригодным для цепочек, возвращайте this из каждого метода, который должен продолжать цепочку. Единственная ловушка, которая гарантированно всё ломает, — написать метод как стрелочную функцию. Стрелочная функция никогда не получает собственный this; она заимствует тот this, который был в окружающем коде на момент её написания, поэтому return this вернёт неверный объект, и передача функции через call, bind или apply этого не изменит.

const counter = {
  count: 0,
  // Broken: arrow `this` is the enclosing scope, not `counter`
  incArrow: () => { this.count++; return this; },
  // Correct: method shorthand binds `this` to the instance
  inc() { this.count++; return this; }
};

counter.inc().inc(); // works, count === 2

И классы, и прототипы поддерживают один и тот же паттерн. Если вы предпочитаете объявлять состояние как поле класса (parts = []) вместо присваивания внутри конструктора, этот синтаксис стандартизирован с ES2022. Вариант на прототипах ведёт себя идентично:

// Prototype form — identical behavior
function Query() { this.parts = []; }
Query.prototype.where = function (c) { this.parts.push(c); return this; };

Для нового кода используйте класс; прототипную форму полезно знать для работы со старыми кодовыми базами и для понимания того, во что «разворачивается» класс.

Эмпирическое правило для длины цепочки

Цепочки оптимизируют скорость написания кода; именование промежуточных значений оптимизирует чтение и последующую отладку — и это разные цели. Практическое правило по длине:

Длина цепочкиЧто делатьПочему
1 шагСмело используйте цепочкуРаспутывать нечего
2 шагаОбычно нормальноВсё ещё одно понятное преобразование
3–4 шагаЗадумайтесь; рассмотрите вариант с именованием промежуточного значенияЧитаемость и доступ к точкам останова начинают страдать
5+ шаговРазбейте на именованные шагиВозвращаемые типы и смешанные задачи трудно отслеживать

Разбивайте цепочку, когда вы активно занимаетесь отладкой, когда возвращаемый тип какого-то шага неясен или когда цепочка смешивает асинхронное управление потоком с преобразованием данных. И предпочитайте find или some с досрочным выходом вместо filter-с-последующим-индексом, когда вам нужен только один результат.

Соединяйте шаги в цепочку, когда они читаются как одно преобразование и остаются короткими; давайте имена промежуточным значениям в тот момент, когда цепочка вырастает больше трёх-четырёх шагов или начинает делать больше работы, чем вы просили. В следующий раз, когда цепочка перейдёт эту границу, разбейте её. Ваше будущее «я», читающее этот код, потратит меньше времени на расшифровку и больше — на исправление.

Часто задаваемые вопросы

Медленнее ли цепочка методов, чем вызов методов по отдельности?

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

Почему цепочка ломается, когда метод написан как стрелочная функция?

Стрелочная функция никогда не получает собственный 'this'. Она заимствует 'this' из окружающего кода, поэтому 'return this' возвращает неверный объект, и следующему методу не на чем корректно разрешиться. Передача функции через call, bind или apply тоже не поможет, потому что эти методы не могут задать стрелочной функции новый 'this'. Используйте краткую запись метода или обычную функцию, чтобы 'this' привязывался к экземпляру.

Когда использовать find() вместо filter().map()[0]?

Используйте find(), когда вам нужен только первый подходящий элемент. Array.prototype.find() проходит по массиву лишь до того момента, когда колбэк принимает элемент, затем возвращает его и дальше не идёт, то есть делает один проход. В отличие от этого, filter().map()[0] делает два полных прохода по массиву и выделяет промежуточный массив, прежде чем отбросить всё, кроме первого элемента. Метод 'some' работает по той же логике досрочного выхода, когда вам нужно только логическое значение.

Хуже ли производительность у цепочек промисов с .then() по сравнению с await?

Нет. Цепочка '.then()' и 'await' выполняют одну и ту же работу под капотом, так что разница в читаемости, а не в производительности. Цепочки вызовов '.then()' склонны смешивать асинхронное управление потоком с преобразованием данных в одной последовательности, что размывает намерение. Разделение загрузки и разбора данных от преобразования с помощью 'await' обычно читается яснее, но ни один из подходов не быстрее в измеримой степени.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.