Разрабатываем более качественные API на JavaScript

Рано или поздно вы обнаружите, что пишете на JavaScript код, который гораздо сложнее пары строчек очередного плагина под jQuery. Ваш код будет делать огромное количество вещей и с ним будут работать (в идеале) множество людей, с разными подходами к его использованию. У них будут различные уровни потребностей, знаний и ожиданий.

Время, потраченное на создание и на использование

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

Питер Друкер однажды выразился так: «Компьютер — идиот». Так вот, не пишите код для идиотов, пишите в первую очередь для людей! Давайте же окунёмся с головой в разработку таких API, которые были бы понятны и удобны вашим коллегам.

Текучий интерфейс

Текучим интерфейсом (от англ. fluent interface, прим. ред.) часто называют цепочку методов, хотя это всего лишь половина правды. В глазах новичков он выглядит как стиль jQuery. Хоть я и уверен, что именно стиль API стал ключевым фактором успеха jQuery, в нём нет ничего инновационного. Лавры создателя текучего интерфейса принадлежат Мартину Фаулеру, который и придумал этот термин ещё в 2005, примерно за год до релиза jQuery. Но, с другой стороны, Фаулер лишь дал этому понятию название, сами по себе текучие интерфейсы уже использовались гораздо раньше.

Кроме значительных упрощений, jQuery позволил сгладить серьёзные различия между браузерами. Но именно текучий интерфейс в этой крайне успешной библиотеке мне понравился больше всего. Я настолько наслаждался его использованием, что мне захотелось внедрить такой же стиль в API URI.js, разработкой которой я занимался в то время. Во время тонкой настройки API URI.js я постоянно просматривал исходники jQuery в поисках маленьких трюков, которые позволили бы сделать мою реализацию настолько простой, насколько это возможно. Оказалось, что я не один работал в этом направлении. Леа Веру создала chainvas, небольшую утилиту для оборачивания обычных API с гетерами и сеттерами в приятные текучие интерфейсы. Для Underscore что-то похожее реализовано через _.chain(). В сущности, большинство библиотек нового поколения поддерживают цепочки методов.

Цепочки методов

Главная идея внедрения цепочки методов в том, чтобы код как можно более легко читался и воспринимался на лету. Используя цепочки методов, можно сделать код похожим на обычные предложения, упростить его восприятие и, параллельно, избавиться от лишнего информационного шума:

// обычные вызовы в API 
// для смены парочки цветов и добавления обработчика события
var elem = document.getElementById("foobar");
elem.style.background = "red";
elem.style.color = "green";
elem.addEventListener('click', function(event) {
  alert("hello world!");
}, true);

// пример реализации API с цепочкой методов
DOMHelper.getElementById('foobar')
  .setStyle("background", "red")
  .setStyle("color", "green")
  .addEvent("click", function(event) {
    alert("hello world");
  });

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

Разделение команд и запросов

Разделение команд и запросов (CQRS, command-query responsibility segregation, прим. ред.) — это концепция, пришедшая из императивного программирования. Функции, изменяющие состояние или внутренние значения объекта, называются командами, а функции, которые в свою очередь, получают эти значения, называются запросами. Из этого следует, что метод должен быть либо командой, выполняющей какое-либо действие, либо запросом, возвращающим данные, но не то и другое одновременно. Эта концепция лежит в основе повседневных геттеров и сеттеров, которые мы можем увидеть в большинстве современных библиотек. Коль скоро текучие интерфейсы возвращают ссылку на себя для цепного вызова методов, мы уже нарушаем правило для команд, которые по-хорошему не должны ничего возвращать. В довесок к этой легко игнорируемой нами особенности, мы преднамеренно отходим от описанного выше принципа для того, чтобы придерживаться максимальной простоты в API. Отличная иллюстрация такого подхода — метод css() в jQuery:

var $elem = jQuery("#foobar");

// CQRS - команда
$elem.setCss("background", "green");
// CQRS - запрос
$elem.getCss("color") === "red";

// не-CQRS - команда
$elem.css("background", "green");
// не-CQRS - запрос
$elem.css("color") === "red";

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

Чтобы создать текучий интерфейс, сжимать геттеры и сеттеры в один метод вовсе не обязательно, это скорее дело вкуса. Ваша документация должна явно указывать, какой именно подход вы используете. Более подробно про тонкости документирования API я расскажу ниже, сейчас же я могу лишь отметить, что с подобным подходом функции с множественными сигнатурами, возможно, будет сложнее документировать.

Переходим к текучести

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

// создаём новый временной интервал
var interval = new DateInterval(startDate, endDate);
// получаем посчитанное количество дней в интервале
var days = interval.days();

И хотя на первый взгляд всё кажется правильным и логичным, следующий пример показывает, что именно не так:

var startDate = new Date(2012, 0, 1);
var endDate = new Date(2012, 11, 31)
var interval = new DateInterval(startDate, endDate);
var days = interval.days(); // 365

Мы написали целую кучу переменных и хлама, который, скорее всего, нам не нужен. Хорошим выходом из сложившейся ситуации стало бы добавление объекту Date функции, которая возвращает интервал:

// создаём DateInterval для текучего вызова
Date.prototype.until = function(end) {

  // если дата не была передана, создаём её
  if (!(end instanceof Date)) {
    // создаём дату, 
    // передавая полученные аргументы в конструктор без изменения,
    // таким образом функция принимает точно такие же параметры,
    // как и конструктор Date.
    end = Date.apply(null, 
      Array.prototype.slice.call(arguments, 0)
    );
  }

  return new DateInterval(this, end);
};

Таким образом мы создали DateInterval в текучей, лёгкой для написания и чтения, манере:

var startDate = new Date(2012, 0, 1);
var interval = startDate.until(2012, 11, 31);
var days = interval.days(); // 365

// сжатый вызов текучего интерфейса:
var days = (new Date(2012, 0, 1))
  .until(2012, 11, 31) // возвращает экземпляр DateInterval
  .days(); // 365

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

Упомянутый выше пример должен был продемонстрировать текучесть через расширение нативного объекта произвольной функцией. Эта практика столь же холиварная, как и вопрос о необходимости использования точки с запятой. В статье «Расширение встроенных нативных объектов. Зло или нет?» kangax рассказывает о плюсах и минусах подобного подхода. И хотя у каждой стороны есть своё мнение по этому вопросу, все сходятся в одном: в таких вещах необходимо единообразие. Впрочем, даже приверженцы позиции «Нельзя загрязнять нативные объекты своими функциями» допустили бы такой, по-своему текучий, приём:

String.prototype.foo = function() {
  return new Foo(this);
}

"Я нативный объект".foo()
  .iAmACustomFunction(); /* кастомная функция */

С таким подходом ваши функции по-прежнему находятся внутри собственного пространства имён, но при этом доступны через другой объект. Убедитесь, что ваш эквивалент .foo() не является общим термином, и что маловероятно его пересечение с другими API. Также убедитесь, что вы должным образом предоставили методы .valueOf() и .toString(), чтобы объекты можно было преобразовать обратно в изначальные примитивные типы.

Единообразие

У Джейка Арчибальда был слайд с определением слова единообразие. Это определение звучит так: не PHP. Не. Дай. Бог. Не вздумайте назвать свою функцию str_repeat(), strpos() или substr(). И еще, никогда в жизни не меняйте позиции аргументов. Если вы объявили в каком-то одном месте find_in_array(haystack, needle), то добавление findInString(needle, haystack) призовёт толпу разъярённых демонов прямиком из Ада. Они вас выследят, и заставят писать код на Delphi до скончания вашей жизни!

Именование вещей

В вычислительной науке всего две сложные задачи: инвалидация кэша и именование переменных. Фил Карлтон

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

В примере выше с DateInterval присутствовал метод until(). Я мог бы смело назвать эту функцию interval(). И хотя последнее было бы даже ближе по смыслу к возвращаемому значению, текущий вариант более понятен человеку. Подберите и придерживайтесь таких формулировок, которые в первую очередь были бы понятны вам. Не забывайте, единообразие — это 90% того, что имеет значение. Выберите один стиль и будьте ему верным до конца, даже если в будущем он вам разонравится.

Обработка аргументов

Благие намерения

Благое намерение, искажение, заблуждение

То, каким образом ваши методы принимают значения, гораздо важнее, чем стремление преобразовать их в цепочку. Цепочка методов, сама по себе, достаточно обыденная вещь, которую несложно воплотить в коде, а вот обработка аргументов — нет. Вам нужно подумать о том, как вероятнее всего будут использоваться предоставленные вами методы. Будет ли код, использующий ваш API, повторять определённые вызовы функций? Почему эти вызовы будут повторяться? Как может ваш API помочь разработчику уменьшить шум от повторяющихся вызовов тех или иных методов?

Например, метод css() в jQuery может устанавливать стили элементов DOM:

jQuery("#some-selector")
  .css("background", "red")
  .css("color", "white")
  .css("font-weight", "bold")
  .css("padding", 10);

Да тут же есть закономерность! Каждый вызов метода указывает на имя стиля и определяет для него значение. Так и напрашивается организация передачи этих данных в виде объекта-словаря:

jQuery("#some-selector").css({
  "background" : "red",
  "color" : "white",
  "font-weight" : "bold",
  "padding" : 10
});

Метод on() jQuery позволяет регистрировать обработчики событий. Так же, как и css(), он может принимать данные в виде словаря событий. Более того, этот метод допускает регистрацию одного обработчика сразу на несколько событий:

// привязываемся к событиям, передавая словарь:
jQuery("#some-selector").on({
  "click" : myClickHandler,
  "keyup" : myKeyupHandler,
  "change" : myChangeHandler
});

// привязываем обработчик к нескольким событиям:
jQuery("#some-selector").on("click keyup change", myEventHandler);

Вы можете представить сигнатуры функции, используя нижеследующий паттерн Метод:

DateInterval.prototype.values = function(name, value) {
  var map;

  if (jQuery.isPlainObject(name)) {
    // устанавливаем словарь
    map = name;
  } else if (value !== undefined) {
    // устанавливаем значение (возможно, на нескольких именах),
    // преобразуем в словарь
    keys = name.split(" ");
    map = {};
    for (var i = 0, length = keys.length; i < length; i++) {
      map[keys[i]] = value;
    }
  } else if (name === undefined) {
    // получаем все значения
    return this.values;
  } else {
    // получаем конкретное значение
    return this.values[name];
  }

  for (var key in map) {
    this.values[name] = map[key];
  }

  return this;
};

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

<input type="text" value="" data-default="foo">
<input type="text" value="" data-default="bar">
<input type="text" value="" data-default="baz">

Скорее всего, мы пройдёмся по ним в цикле:

jQuery("input").each(function() {
  var $this = jQuery(this);
  $this.val($this.data("default"));
});

А что, если бы мы могли обойтись без этого метода, используя простой коллбек, который можно применить к каждому <input> в коллекции? Разработчики jQuery позаботились об этом, что позволило нам «писать меньше™»:

jQuery("input").val(function() {
  return jQuery(this).data("default");
});

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

Обработка типов

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

DateInterval.prototype.days = function(start, end) {
  return Math.floor((end - start) / 86400000);
};

Как видите, функция принимает числа в качестве параметров — метки времени в миллисекундах, если быть точнее. Хотя функция и справляется с тем, на что рассчитана, она не очень-то гибкая. А что, если мы работаем с объектами Date, или с представлением даты в виде строки? Пользователю придётся всякий раз приводить данные к нужному виду? Нет! В нашем API должны присутствовать простые операции по проверки входных данных и их приведения к нужному типу, и они должны находиться в единственном месте, а не быть раскиданными по всему коду:

DateInterval.prototype.days = function(start, end) {
  if (!(start instanceof Date)) {
    start = new Date(start);
  }
  if (!(end instanceof Date)) {
    end = new Date(end);
  }

  return Math.floor((end.getTime() - start.getTime()) / 86400000);
};

Мы добавили шесть строчек и дали функции возможность принимать на вход объект Date, метку времени в виде числа, или даже строковое представление вроде Sat Sep 08 2012 15:34:35 GMT+0200 (CEST). Мы не знаем, как и для чего люди будут использовать наш код. Проявив лишь немного предусмотрительности, мы можем быть уверены, что интеграция нашего кода пройдёт безболезненно.

Опытный разработчик может заметить ещё одну проблему в этом коде. Мы предполагаем, что start всегда будет перед end. Если пользователь API случайно поменяет местами даты, ему вернётся отрицательное количество дней между start и end. Остановитесь, и хорошенько обдумайте подобные этой ситуации. Если вы решите, что отрицательное значение не имеет смысла, исправьте это:

DateInterval.prototype.days = function(start, end) {
  if (!(start instanceof Date)) {
    start = new Date(start);
  }
  if (!(end instanceof Date)) {
    end = new Date(end);
  }

  return Math.abs(Math.floor((end.getTime() - start.getTime()) / 86400000));
};

В JavaScript есть возможность приводить данные к нужному типу множеством способов. Если вы работаете с примитивами (string, number, boolean), подобную операцию выполнить довольно просто:

function castaway(some_string, some_integer, some_boolean) {
  some_string += "";
  some_integer += 0; // parseInt(some_integer, 10) безопаснее
  some_boolean = !!some_boolean;
}

Я не настаиваю на том, чтобы вы делали так всегда. Но эти невинно выглядящие строчки помогут вашим коллегам сохранить кучу времени и нервов на этапе интеграции вашего API.

Рассматриваем undefined как ожидаемое значение

Рано или поздно окажется, что ваш API будет ожидать undefined в качестве значения, которое нужно установить атрибуту. Может быть, для сброса значения атрибута или для обработки некорректных параметров с целью ускорить работу API. Чтобы определить, что значение undefined было явным образом передано в ваш метод, вы можете проверить объект arguments:

function testUndefined(expecting, someArgument) {
  if (someArgument === undefined) {
    console.log("someArgument является undefined");
  }
  if (arguments.length > 1) {
    console.log("но он был передан явно");
  }
}

testUndefined("foo");
// выведется: someArgument является undefined
testUndefined("foo", undefined);
// выведется: someArgument является undefined, но он был передан явно

Именованные аргументы

event.initMouseEvent(
  "click", true, true, window,
  123, 101, 202, 101, 202,
  true, false, false, false,
  1, null);

Сигнатура функции Event.initMouseEvent — это кошмар наяву. Нет никаких шансов, что какой-нибудь разработчик вспомнит, что означает 1 (предпоследний параметр), не заглянув предварительно в документацию. И неважно, насколько хороша ваша документация, вы должны приложить все усилия к тому, чтобы людям не пришлось лишний раз искать подобные вещи!

Как у других

Заглянув за пределы нашего с вами любимого языка, мы обнаружим, что в Python существует концепция именованных аргументов. С её помощью можно определить значения по умолчанию для аргументов функции, что позволяет при вызове функции передавать данные в аргументы в зависимости от их именования:

function namesAreAwesome(foo=1, bar=2) {
  console.log(foo, bar);
}

namesAreAwesome();
// выведет: 1, 2

namesAreAwesome(3, 4);
// выведет: 3, 4

namesAreAwesome(foo=5, bar=6);
// выведет: 5, 6

namesAreAwesome(bar=6);
// выведет: 1, 6

При использовании такой схемы вызов функции initMouseEvent() мог бы выглядеть более понятным:

event.initMouseEvent(
  type="click",
  canBubble=true,
  cancelable=true,
  view=window,
  detail=123,
  screenX=101,
  screenY=202,
  clientX=101,
  clientY=202,
  ctrlKey=true,
  altKey=false,
  shiftKey=false,
  metaKey=false,
  button=1,
  relatedTarget=null);

К сожалению, в JavaScript это пока невозможно. И хотя в «следующую версию JavaScript» (её часто называют ES.next, ES6 или Harmony) войдут значения параметров по умолчанию и дополнительные позиционные параметры, про реализацию именованных параметров там до сих пор ни слова.

Словари аргументов

Ну что ж, JavaScript не Python, а до ES.next как до Луны пешком. Так что у нас остаётся не так уж и много способов решить проблему «зарослей аргументов». В jQuery, как и в почти любом другом современном API, было решено использовать концепцию «объектов с опциями». Сигнатура функции jQuery.ajax() — яркий тому пример. Вместо многочисленных аргументов, мы просто принимаем объект:

// Кошмарная функция
function nightmare(accepts, async, beforeSend, cache, complete, /* и ещё 28 других аргументов*/) {
  if (accepts === "text") {
    // готовимся получить текст
  }
}
// функция мечты
function dream(options) {
  options = options || {};
  if (options.accepts === "text") {
    // готовимся получить текст
  }
}

Это приём не только избавляет нас от безумно длинных сигнатур функций, но ещё и делает их вызов более наглядным:

// Кошмарная функция
nightmare("text", true, undefined, false, undefined, /* и ещё 28 других аргументов */);

// функция мечты
dream({
  accepts: "text",
  async: true,
  cache: false
});

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

Значения по умолчанию в аргументах

Я постараюсь объяснить этот принцип на примере jQuery.extend(), _.extend() и Object.extend из библиотеки Prototype. Это функции, необходимые для объединения объектов, также позволяют примешивать к уже переданным ваши собственные, заранее указанные, опции:

var default_options = {
  accepts: "text",
  async: true,
  beforeSend: null,
  cache: false,
  complete: null,
  // и так далее
};

// функция мечты
function dream(options) {
  var o = jQuery.extend({}, default_options, options || {});
  console.log(o.accepts);
}

// делаем значение по умолчанию публичным
dream.default_options = default_options;

dream({ async: false });
// выведется: "text"

Вы получите бонусные очки, если сделаете значения по умолчанию публичными. В таком случае любой, кто использует вашу библиотеку, сможет поменять accepts на «json» в единственном месте, без постоянного указания на эту опцию. Обратите внимание, что в примере добавляется || {} при первом чтении объекта с опциями. Это позволяет вызывать функцию вообще без параметров.

Благие намерения, также известные как «подводные камни»

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

С большой силой приходит и большая ответственность. Вольтер

Как и большинство слабо типизированных языков, JavaScript делает автоматическое приведение к нужному типу, когда это необходимо. Простой пример, проверка на истинность значения:

var foo = 1;
var bar = true;

if (foo) {
  // ага, выполнится
}

if (bar) {
  // ага, выполнится
}

Мы полностью привыкли к этому автоматическому приведению. Мы пользовались этим столько раз, что уже и забыли, что если что-то истинно, то это не обязательно булев тип истины. Некоторые API ради своей гибкости становятся даже чересчур умными. Взглянем на пример с сигнатурой jQuery.toggle():

.toggle( /* число */ [duration], /* функция */ [callback] )
.toggle( /* число */ [duration], /* строка */ [easing], 
  /* function */ [callback] )
.toggle( /* булево */ showOrHide) // Показать или скрыть 

Придётся потратить какое-то время, чтобы выяснить, почему всё это работает совершенно по-разному:

var foo = 1;
var bar = true;
var $hello = jQuery(".hello");
var $world = jQuery(".world");

$hello.toggle(foo);
$world.toggle(bar);

Мы ожидали использовать сигнатуру showOrHide (показать или скрыть) в обоих случаях. Но, вот что произошло на самом деле: $hello переключает свою видимость с длительностью (duration) в 1 миллисекунду. И это не баг jQuery, это простой случай, когда наши ожидания не оправдались. Даже если вы разработчик на jQuery со стажем, вы будете рано или поздно на этом спотыкаться.

Вы вольны добавить столько удобства и синтаксического сахара, сколько вам захочется, но не жертвуйте чистотой и скоростью API в погоне за этим. Если вы заметите, что написали что-то подобное, подумайте над тем, чтобы вместо этого добавить отдельный метод, скажем, .toggleIf(bool). И какой бы выбор вы ни сделали, соблюдайте единообразие!

Расширяемость

Разработка возможностей

Разрабатывая возможности

Рассмотрев объекты с опциями, мы затронули тему расширяемой конфигурации. Давайте теперь подробнее остановимся на том, как предоставить пользователю возможность расширить ядро библиотеки и само API. Это важная тема, потому как подобное решение позволит вам сконцентрироваться на действительно критических задачах, в то время как другие пользователи будут самостоятельно заниматься пограничными случаями. Хорошие API — это краткие API. Конечно, иметь пригоршню опций для более тонкой настройки неплохо, но, если их будет пара дюжин, то ваше API будет восприниматься как раздутое и трудное к освоению. Обращайте внимание только на те случаи, где API используется по прямому назначению, делайте только те вещи, которые понадобятся большинству пользователей вашего API. Всё остальное предоставьте им для самостоятельного взаимодействия. Есть несколько проверенных способов дать пользователям API возможность расширять ваш код в соответствии с их потребностями.

Коллбеки

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

var default_options = {
  // код
  position: function($elem, $parent) {
    $elem.css($parent.position());
  }
};

function Widget(options) {
  this.options = jQuery.extend({}, default_options, options || {});
  this.create();
};

Widget.prototype.create = function() {
  this.$container = $("<div></div>").appendTo(document.body);
  this.$thingie = $("<div></div>").appendTo(this.$container);
  return this;
};

Widget.prototype.show = function() {
  this.options.position(this.$thingie, this.$container);
  this.$thingie.show();
  return this;
};

var widget = new Widget({
  position: function($elem, $parent) {
    var position = $parent.position();
    // располагаем $elem в нижнем правом углу $parent
    position.left += $parent.width();
    position.top += $parent.height();
    $elem.css(position);
  }
});
widget.show();

Также через коллбеки пользователям API часто предоставляется возможность настроить элементы, созданные при помощи вашего кода:

// по умолчанию коллбек на создание элемента ничего не делает
default_options.create = function($thingie){};

Widget.prototype.create = function() {
  this.$container = $("<div></div>").appendTo(document.body);
  this.$thingie = $("<div></div>").appendTo(this.$container);
  // запускаем коллбек на создание, чтобы позволить изменить объект
  this.options.create(this.$thingie);
  return this;
};

var widget = new Widget({
  create: function($elem) {
    $elem.addClass('my-style-stuff');
  }
});
widget.show();

Всякий раз, когда вы принимаете коллбеки, убедитесь, что их сигнатуры описаны в документации с примерам работы, так вы поможете пользователям вашего API настраивать ваш код. Убедитесь, что вы соблюдаете единообразие в отношении контекста (куда указывает this) коллбеков и их аргументов.

События

События — привычное дело при работе с DOM. В более масштабных приложениях мы используем события в различных проявлениях (например, PubSub), чтобы наладить связь между модулями. События особенно полезны и естественны в работе с виджетами UI. Библиотеки, вроде jQuery, предоставляют довольно простые интерфейсы, с помощью которых вы можете легко справится с покорением этой области.

Интерфейс событий, как и подсказывает само название, лучшего всего применим там, где что-то постоянно происходит. Например, отображение того или иного виджета, зависящее от внешне недосягаемых факторов. Другая частая задача — обновление виджета после того, как он показан. Обе эти задачи можно очень легко решить с помощью интерфейса событий jQuery, который, к тому же, позволяет эти события делегировать:

Widget.prototype.show = function() {
  var event = jQuery.Event("widget:show");
  this.$container.trigger(event);
  if (event.isDefaultPrevented()) {
    // обработчик события не позволяет нам показывать виджет
    return this;
  }

  this.options.position(this.$thingie, this.$container);
  this.$thingie.show();
  return this;
};

// листнер для всех событий widget:show
$(document.body).on('widget:show', function(event) {
  if (Math.random() > 0.5) {
    // не позволять показывать виджет
    event.preventDefault();
  }

  // обновляет данные виджетов
  $(this).data("last-show", new Date());
});

var widget = new Widget();
widget.show();

Вы можете выбирать имена событий как вам вздумается. Только не используйте нативные события для своих целей и, будьте добры, выносите ваши события в отдельное пространство имён. В jQuery UI, например, имена событий составляются из имени виджета и имени события dialogshow. Мне кажется, что это нечитаемо, и я часто использую просто dialog:show, главным образом из-за того, что так сразу очевидно, что это нестандартное событие, а не какая-то скрытая особенность браузера.

Хуки

Традиционные геттеры и сеттеры могут в особенности выиграть от использования хуков. Хуки обычно отличаются от коллбеков их количеством и тем, как они устанавливаются. В то время как коллбеки обычно используются на уровне экземпляров для конкретных задач, хуки обычно используются на глобальном уровне для изменения значений или выполнения произвольных действий. В качестве примера применения хуков рассмотрим cssHooks jQuery:

// определяем хук для произвольного css
jQuery.cssHooks.custombox = {
  get: function(elem, computed, extra) {
    return $.css(elem, 'borderRadius') == "50%"
      ? "circle"
      : "box";
  },
  set: function(elem, value) {
    elem.style.borderRadius = value == "circle"
      ? "50%"
      : "0";
  }
};

// применям .css(), который использует этот хук
$("#some-selector").css("custombox", "circle");

Зарегистрировав хук custombox, мы наделили метод .css() jQuery способностью обрабатывать ранее недоступное свойство CSS. В своей статье «Хуки в jQuery» я рассказываю о других хуках, предоставляемых jQuery и о том, как их можно применять. Вы можете предоставлять хуки примерно так же, как и коллбеки:

DateInterval.nameHooks = {
  "yesterday" : function() {
    var d = new Date();
    d.setTime(d.getTime() - 86400000);
    d.setHours(0);
    d.setMinutes(0);
    d.setSeconds(0);
    return d;
  }
};

DateInterval.prototype.start = function(date) {
  if (date === undefined) {
    return new Date(this.startDate.getTime());
  }

  if (typeof date === "string" && DateInterval.nameHooks[date]) {
    date = DateInterval.nameHooks[date]();
  }

  if (!(date instanceof Date)) {
    date = new Date(date);
  }

  this.startDate.setTime(date.getTime());
  return this;
};

var di = new DateInterval();
di.start("yesterday");

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

Генерирование аксессоров

Дублирование

Дублирование

Скорее всего, в любом API содержится множество методов-аксессоров (геттеров, сеттеров, экзекуторов), которые делают похожие вещи. Вернёмся к примеру с DateInterval. Там мы наверняка предоставили бы методы start() и end() для управления интервалами. Простое решение может выглядеть так:

DateInterval.prototype.start = function(date) {
  if (date === undefined) {
    return new Date(this.startDate.getTime());
  }

  this.startDate.setTime(date.getTime());
  return this;
};

DateInterval.prototype.end = function(date) {
  if (date === undefined) {
    return new Date(this.endDate.getTime());
  }

  this.endDate.setTime(date.getTime());
  return this;
};

Как видите, куча повторяющегося кода. Принцип «не повторяйся» (DRY или Don’t Repeat Yourself, прим. ред.) подсказывает нам возможность воспользоваться шаблоном-генератором:

var accessors = ["start", "end"];
for (var i = 0, length = accessors.length; i < length; i++) {
  var key = accessors[i];
  DateInterval.prototype[key] = generateAccessor(key);
}

function generateAccessor(key) {
  var value = key + "Date";
  return function(date) {
    if (date === undefined) {
      return new Date(this[value].getTime());
    }

    this[value].setTime(date.getTime());
    return this;
  };
}

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

var accessors = {"start" : {color: "green"}, "end" : {color: "red"}};
for (var key in accessors) {
  DateInterval.prototype[key] = generateAccessor(key, accessors[key]);
}

function generateAccessor(key, accessor) {
  var value = key + "Date";
  return function(date) {
    // тут делаем что-нибудь полезное,
    // используя `key` и `accessor.color`
  };
}

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

function wrapFlexibleAccessor(get, set) {
  return function(name, value) {
    var map;

    if (jQuery.isPlainObject(name)) {
      // устанавливаем словарь
      map = name;
    } else if (value !== undefined) {
      // устанавливаем значение (возможно, на нескольких именах),
      // преобразуем в словарь
      keys = name.split(" ");
      map = {};
      for (var i = 0, length = keys.length; i < length; i++) {
        map[keys[i]] = value;
      }
    } else {
      return get.call(this, name);
    }

    for (var key in map) {
      set.call(this, name, map[key]);
    }

    return this;
  };
}

DateInterval.prototype.values = wrapFlexibleAccessor(
  function(name) { 
    return name !== undefined 
      ? this.values[name]
      : this.values;
  },
  function(name, value) {
    this.values[name] = value;
  }
);

Углубленное описание искусства написания кода без повторов выходит далеко за пределы этой статьи. Книга «Patterns for DRY-er JavaScript» за авторством Ребекки Мёрфи, а также слайды Матиаса Биненса про то, как принцип «не повторяйся» влияет на производительность JavaScript — это хорошее начало для изучения этой темы.

Ужасы передачи данных по ссылке

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

В JavaScript нельзя явно указать на то, как именно должны передаваться аргументы, по ссылке или по значению. Примитивы (строки, числа, булевы значения) передаются по значению, а объекты (любые, включая Array, Date) передаются способом, похожим на передачу по ссылке. Если вы сейчас впервые об этом прочитали, вот вам пример, в качестве краткого ликбеза:

// по значению
function addOne(num) {
  num = num + 1; // да, num++; делает то же самое
  return num;
}

var x = 0;
var y = addOne(x);
// x === 0 <--
// y === 1

// по ссылке
function addOne(obj) {
  obj.num = obj.num + 1;
  return obj;
}

var ox = {num : 0};
var oy = addOne(ox);
// ox.num === 1 <--
// oy.num === 1

Обработка объектов по ссылке может вас пребольно укусить, если вы не будете осторожны. Вернёмся к примеру с DateInterval и рассмотрим такой баг:

var startDate = new Date(2012, 0, 1);
var endDate = new Date(2012, 11, 31)
var interval = new DateInterval(startDate, endDate);
endDate.setMonth(0); // устанавливаем январь
var days = interval.days(); // получили 31, а ожидалось 365 - ой!

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

Обратите внимание, что это поведение правдиво и для значений, которые ваш API вернул. Если вы просто вернёте внутренний объект, любые изменения, совершённые снаружи, отразятся на внутренних данных. И это наверняка не то, чего бы вам хотелось. Однако есть jQuery.extend(), _.extend() и Object.extend в Prototype — легкие способы избавления от ужасов передачи по ссылке.

Если этого краткое изложение вам кажется недостаточно, прочтите отличную статью «По ссылке или по значению» из книги с носорогом.

Проблема продолжения

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

jQuery('.wont-find-anything')
  // методы выполнятся, хотя им и нечего делать
  .somePlugin().someOtherPlugin();

В нетекучем коде мы можем предотвратить вызов этих функций:

var $elem = jQuery('.wont-find-anything');
if ($elem.length) {
  $elem.somePlugin().someOtherPlugin();
}

Если мы используем цепочку методов, мы теряем возможность сделать так, чтобы какие-то вещи не происходили — мы не можем никуда деться из цепи. Пока разработчик API знает, что у объекта бывают иные состояния, при которых нужно сделать лишь return this;, всё хорошо. В зависимости от того, что ваши методы делают внутри себя, может оказаться полезным добавить тривиальную проверку на пустоту объекта:

jQuery.fn.somePlugin = function() {
  if (!this.length) {
    // Отставить! Нам тут делать нечего
    return this;
  }

  // делаем какие-то тяжёлые вычисления для настройки
  for (var i = 10000; i > 0; i--) {
    // Я всего лишь трачу попусту ваше драгоценное процессорное время
    // Если вы будете запускать меня достаточно часто,
    // я превращу ваш лэптоп в сталеплавильную печь
  }

  return this.each(function() {
    // делаем полезную работу
  });
};

Обработка ошибок

Выходим из строя раньше

Выходим из строя раньше

Я немного лукавил, когда говорил, что мы не можем никуда деться из цепи, для этого правила есть свои исключения, они же Exception, простите за каламбур ☺.

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

// jQuery получает это
$(document.body).on('click', {});

// при щелчке в консоль выведется:
// TypeError: ((p.event.special[l.origType] || {}).handle || l.handler).apply 
// is not a function 
// in jQuery.min.js on Line 3

Ошибки вроде этой — одна сплошная головная боль при отладке. Не тратьте попусту время других людей. Информируйте пользователя API, если он сделал что-то глупое:

if (Object.prototype.toString.call(callback) !== '[object Function]') { 
  // см. прим.
  throw new TypeError("callback is not a function!");
}

Примечание: typeof callback === "function" не следует использовать, потому что старые браузеры могут говорить, что объект — функция, хотя он ей и не является. В Chrome (до версии 12) это происходило с RegExp. Для удобства, используйте jQuery.isFunction() или _.isFunction().

Большая часть библиотек, которые я встречал, в независимости от использованного языка (среди слаботипизированных) не заботятся о доскональной проверке входных данных. Честно говоря, мой собственный код так же проводит валидацию только в тех местах, где я вижу возможность допустить ошибку. Никто из нас этого не делает, но всем нам это необходимо. Программисты — кучка лентяев, мы пишем код не просто ради процесса его написания, и уж тем более не ради чего-то, во что сами не до конца верим. Разработчики Perl6 увидели в этом проблему и решили добавить кое-что под названием ограничение параметров. В JavaScript этот подход выглядел бы следующим образом:

function validateAllTheThings(a, b {where typeof b === "numeric" and b < 10}) {
  // Интерпретатор должен кинуть ошибку,
  // если b не число или больше 9
}

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

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

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

Переходим к асинхронности

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

Api.prototype.async = function(callback) {
  console.log("async()");
  // делаем что-нибудь асинхронно
  window.setTimeout(callback, 500);
  return this;
};
Api.prototype.method = function() {
  console.log("method()");
  return this;
};

// запускаем
api.async(function() {
  console.log('callback()');
}).method();

// выведется: async(), method(), callback()

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

Deferred-объекты и Промисы

В какой-то степени мы можем справиться с этой кашей из вызовов синхронного и асинхронного API при помощи Промисов. В jQuery они известны как Deferred-объекты. Объект Deferred возвращается вместо обычного this, что выбрасывает нас из цепочки вызовов. Это поначалу кажется необычным, но, фактически, это оберегает нас от продолжения синхронных вызовов после вызова асинхронного метода:

Api.prototype.async = function() {
  var deferred = $.Deferred();
  console.log("async()");

  window.setTimeout(function() {
    // делаем что то асинхронное
    deferred.resolve("some-data");
  }, 500);

  return deferred.promise();
};

api.async().done(function(data) {
  console.log("callback()");
  api.method();
});

// выведет: async(), callback(), method()

Объект Deferred позволяет регистрировать обработчики при помощи .done(), .fail(), .always(). Эти обработчики будут вызваны в случае завершения, неудачи, или, соответственно, независимо от состояния. Вы можете прочесть более детальное описание принципов работы с Deferred-объектами в статье «Конвееры промисов в JavaScript».

Отладка текучих интерфейсов

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

Как и с любым кодом, разработка через тестирование (TDD или test-driven development, прим. ред.) — это наиболее простой способ уменьшить потребность в последующей отладке. Когда я писал URI.js с использованием методологии TDD, я не столкнулся со сколь-нибудь серьёзными затруднениями по отладке библиотеки. Но, с другой стороны, разработка через тестирование лишь уменьшает необходимость отладки, а не исключает её полностью.

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

foobar.bar()
  .baz()
  .bam()
  .someError();

У такой техники есть свои определенные преимущества, хотя облегчение отладки и не основное из них. Код, написанный как в примере выше, ещё проще читать. Также вам будет гораздо удобней пользоваться построчным сравнением в системах контроля версий вроде SVN и GIT. А что касается отладки, только в Chrome (на момент написания статьи) someError() покажется на 4 строке, остальные же браузеры будут по-прежнему считать, что ошибка происходит на 1 строке.

Даже добавление простого метода для записи происходящего с вашими объектами в лог может серьёзно помочь. Хотя это и считается «отладкой вручную» и может вызывать ухмылку у людей, привыкших к «настоящим» дебаггерам:

DateInterval.prototype.explain = function() {
  // выводим текущее состояние в консоль
  console.dir(this);
};

var days = (new Date(2012, 0, 1))
  .until(2012, 11, 31) // возвращает экземпляр DateInterval
  .explain() // пишем отладочную информацию в консоль
  .days(); // 365

Имена функций

На всём протяжении этой статьи вы увидели много примеров кода в стилистике Foo.prototype.something = function(){}. Такой стиль был выбран для краткости примеров. Но когда вы будете писать код, вам может понадобиться один из следующих подходов для того, чтобы ваша консоль могла правильно показывать имена функций:

Foo.prototype.something = function something() {
  // что-то происходит
};

Foo.prototype.something = function() {
  // что-то происходит
};
Foo.prototype.something.displayName = "Foo.something";

Второй способ, displayName был представлен в WebKit, а затем появился и в Firebug / Firefox. С displayName придётся написать немного больше кода, но это позволит использовать произвольные имена, включая пространства имён или связанный объект. Любой из этих подходов может немного помочь при работе с анонимными функциями.

Вы сможете узнать больше об этом из статьи «Разъяснения насчет именованных функций-выражений» в блоге kangax .

Документирование API

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

Все эти утилиты, так или иначе, вызывают разочарование. JavaScript — очень динамичный язык, и тем самым особенно разнообразен в выражениях. Из-за этого, со многими вещами подобным утилитам просто не справиться. Ниже я привел список причин, почему я решил подготавливать документацию в простом HTML, markdown или DocBoock (если проект достаточно большой). У jQuery, например, точно такие же проблемы, и его разработчики вообще не документируют свой API в коде.

  1. Сигнатуры функций — это ещё не всё, что требуется от документации, однако большая часть утилит сосредоточена именно на этом.
  2. Примеры кода имеют большое значение в объяснении, как именно что-то работает. В обычной документации по API обычно не получается реализовать это адекватно.
  3. Документирование кода обычно не справляется с объяснением процессов, происходящих за кулисами (поток, события и т.п.).
  4. Документирование методов с несколькими сигнатурами обычно очень болезненно.
  5. Документирование методов с объектами, содержащими опции, так же часто бывает нетривиальной задачей.
  6. Сгенерированные методы обычно нелегко документировать, это же касается и коллбеков по умолчанию.

Если вы не можете, или же попросту не хотите подстраивать ваш код под одну из приведённых выше утилит для документирования, и предпочитаете делать документацию «на коленке», то проекты вроде Document-Bootstrap вполне могут помочь сэкономить ваше время.

Убедитесь, что ваша документация — это нечто большее, чем просто автоматически сгенерированный текст. Ваши пользователи будут благодарны за любезно предоставленные вами примеры. Расскажите им, как ваша программа работает, когда и какие именно события происходят. Нарисуйте схему, если это поможет пользователям разобраться, как ваш API делает те или иные вещи. И прежде всего, поддерживайте синхронизацию между кодом и документацией!

Самоочевидный код

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

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

Заключение

Вы можете продолжить изучение этой темы с помощью видео «Повторное использование кода — хорошо или великолепно» (слайды), с выступления Джейка Арчибальда, посвященного разработке API. Помимо этого, ещё в 2007 Джошуа Блох выступал с презентацией «Как разработать хороший API и почему это важно» на Google Tech Talks. И хотя он говорил не о JavaScript, изложенные им основные принципы вполне применимы и для вас.

Если вас интересует скорость разработки API, обратите внимание на «Важные паттерны разработки в JS» Эдди Османи, чтобы узнать больше о том, как продуктивно организовать код изнутри.

Спасибо за подготовку статьи @bassistance, @addyosmani и @hellokahlil, за то, что нашли время вычитку на корректуру этой статьи.