Pull to refresh

Расширение нативных объектов JavaScript — зло ли это? Манифест SugarJS

Reading time13 min
Views19K
Original author: Andrew Plummer
SugarJS logoВ комментариях к посту про Underscore/Lo-Dash я упомянул, что среди библиотек, расширяющих стандартную библиотеку JavaScript, я предпочитаю SugarJS, который, в отличие от большинства аналогов, работает через расширение нативных объектов.

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

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

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

Передаю слово Andrew Plummer.



Итак, Sugar — библиотека, которая модифицирует нативные объекты JavaScript. Подождите, разве это не во зло? — спросите вы, — вы что, не извлекли урок из горького опыта Prototype?

По этому поводу существует много заблуждений. Sugar избегает подводные камни, о которые спотыкался Prototype, и фундаментально отличается по своей сути. Однако этот выбор — не без последствий. Ниже разобраны потенциальные проблемы, вызываемые изменением нативных объектов, и изложена позиция Sugar насчет каждой из них:
  1. Модификация объектов среды
  2. Функции как перечисляемые свойства
  3. Переопределение свойств
  4. Конфликты в глобальном пространстве имен
  5. Допущения насчет отсутствия свойств
  6. Соответствие спецификации

1. Модификация объектов среды


Проблема:


Термин «объекты среды» (host objects) означает объекты JavaScript, предоставляемые окружением, в котором исполняется код. Примеры host-объектов: Event, HTMLElement, XMLHttpRequest. В отличие от нативных объектов JavaScript, которые строго соответствуют спецификации, объекты среды могут меняться по усмотрению разработчиков браузеров, и их реализации в разных браузерах могут отличаться.

Не вдаваясь в подробности, если вы модифицируете объекты среды, ваш код может быть подвержен ошибкам, может тормозить и быть уязвимым к будущим изменениям окружения.

Позиция Sugar:


Sugar работает только с нативными объектами JavaScript. Объекты среды ему неинтересны (или, точнее говоря, неизвестны). Этот путь выбран не только, чтобы избежать проблем с host-объектами, но и чтобы сделать библиотеку доступной большому множеству окружений JavaScript, в том числе работающих вне браузера.

От переводчика: вот модуль Sugar в репозитории Node.

2. Функции как перечисляемые свойства


Проблема:


В браузерах, не следующих современным спецификациям, определение нового свойства делает его перичесляемым (enumerable). При обходе циклом свойств объекта, новое свойство будет затронуто наравне со свойствами, содержащими данные.

Подробности
По умолчанию, при определении у объекта нового свойства, оно становится перечисляемым. Таким образом мы храним в объектах данные и проходимся по ним в цикле:

var o = {};
o.name = "Harry";
for(var key in o) {
  console.log(key);
}
// => name

Если в качестве нового свойства мы присвоим функцию (или, выражаясь языком ООП, добавим объекту метод), эта функция тоже станет перечисляемой:

Object.prototype.getName = function() {
  return this.name;
};
for(var key in {}) {
  console.log(key);
}
// => getName

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

Object.defineProperty(Object.prototype, 'getName', {
  value: function() {
    return this.name;
  },
  enumerable: false
});
for(var key in {}) {
  console.log(key);
}
// => (пусто)

Однако, как всегда, есть подвох. Возможность определять неперечисляемые свойства отсутствует в Internet Explorer 8 и ниже.

Итак, с перечисляемостью свойств обычных объектов разобрались, но что насчет массивов? Обычно для обхода значений массивов используют обычный цикл for со счетичком.

Array.prototype.name = 'Harry';
var arr = ['a','b','c'];
for(var i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}
// => 'a'
// => 'b'
// => 'c'

Как видите, проблем с перечисляемостью свойств можно избежать, просто накручивая счетчик. Если же обходить объект с помощью for..in, перечисляемые свойства попадут в цикл:

Array.prototype.name = 'Harry';
var arr = ['a','b','c'];
for(var key in arr) {
  console.log(arr[key]);
}
// => 'a'
// => 'b'
// => 'c'
// => 'Harry'

По этой причине, при обращении к свойствам объектов по именам свойств (и к значениям массивов по номерам индекса) в циклах вида for..in следует использовать метод hasOwnProperty. Это исключит свойства, не принадлежащие объекту непосредственно, а доставшиеся ему через цепочку прототипов:

Array.prototype.name = 'Harry';
var arr = ['a','b','c'];
for(var key in arr) {
  if(arr.hasOwnProperty(key)) {
    console.log(arr[key]);
  }
}
// => 'a'
// => 'b'
// => 'c'

Это один из самых общих примеров хороших практик JavaScript. Всегда используйте его при обращении к свойствам объектов по именам свойств.

От переводчика:

Автор не упоминает, что есть еще один метод обхода массивов: Array.prototype.forEach. В результате беглого поиска я нашел полифилл от Mozilla Developer Network, который, по их словам, алгоритмически воспроизводит спецификацию (и, как вы можете убедиться по следующей ссылке, имеет идентичную с нативным forEach производительность). В коде полифилла используется первый (безопасный) способ обхода массива. В то же время, известно, что forEach заметно медленнее простейшего цикла for со счетчиком, судя по всему, из-за дополнительных проверок.

forEach доступен во всех современных мобильных и десктопных браузерах. Отсутствует в IE8 и ниже.

Позиция Sugar:


Sugar делает свои методы неперечисляемыми всегда, когда это возможно, то есть во всех современных браузерах. Однако, пока IE8 не сгинет окончательно, нужно всегда иметь эту проблему в виду. Ее корень лежит в обходе свойств циклом, и мы должны рассмотреть по отдельности два основных вида объектов, которые можно обходить циклом: обычные объекты и массивы.

Из-за этой проблемы (а также из-за проблемы переопределения свойств) Sugar не модифицирует Object.prototype, как это делается в примерах выше. Это означает, что использование циклов for..in на обычных объектах JavaScript никогда не приведет к попаданию в цикл неизвестных свойств, потому что их нет.

С массивами ситуация несколько сложнее. Стандартным способом обхода массивов является простой цикл for который при каждой итерации увеличивает счетчик на единицу и использует его в качестве имени свойства. Этот способ безопасен, и проблема также не возникает. Обходить массив при помощи цикла for..in тоже возможно, но это не считается хорошей практикой. Если вы решили использовать этот подход, всегда применяйте метод hasOwnProperty, чтобы проверять, принадлежат ли свойства непосредственно объекту (см. последний пример в раскрывашке выше).

Получается, обход массива циклом for..in и отсутствие проверки hasOwnProperty — это плохая практика внутри плохой практики. Если такой код будет выполнен в устаревшем браузере (IE8 и ниже), наружу вылезут все свойства объектов, включая методы Sugar, поэтому важно отметить, что проблема существует. Если ваш проект ломается при включении в него Sugar, первое, что вы должны провеить, это правильно ли вы обходите свойства объектов в циклах. Стоит также отметить, что эта проблема не является проблемой одного только Sugar, а имеет место для всех библиотек, которые предоставляют полифиллы для методов массивов.

Вывод. Если вы не можете переписать проблемный код обхода массивов, а поддержка IE8 и ниже вам важна, значит, вы не можете пользоваться пакетом Array библиотеки Sugar. Соберите свою сборку Sugar, исключив этот пакет.

3. Переопределение свойств


Проблема:


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

Проблема становится очевидной. К примеру, если для всех объектов определить метод count, а потом какому-нибудь объекту записать данные в свойство с тем же именем, то метод окажется недоступен.

Object.prototype.count = function() {};
var o = { count: 18 };
o.count
// => 18

Свойство count, непосредственно определенное для объекта, как бы заслоняет одноименный метод, лежащий далее по прототипной цепочке (в оригинале «бросает тень» — is «shadowing»). В результате вызвать метод для этого объекта становится невозможно.

Позиция Sugar:


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

Вместо этого, Sugar предпочитает представлять все методы для простых объектов в качестве статических методов класса Object. Пока JavaScript не делает разницы между свойствами и методами, этот подход не изменится.

От переводчика:

При желании, вы можете перенести методы Sugar для работы с обычными объектами в свойства конкретного объекта. Это делается при помощи Object.extended():

var
  foo = {foo: 'foo'},
  bar = {bar: 'bar'};

foo = Object.extended(foo);
foo.merge(bar);
console.log(foo);
// => {foo: 'foo', bar: 'bar'}

4. Конфликты в глобальном пространстве имен


Проблема:


Если ты существуешь в глобальном пространстве имен, твой главный источник стресса — это беспокойство о том, что тебя переопределят. При написании кода всегда есть риск как поломать чужие методы, так и то, что кто-то сломает ваши.

Позиция Sugar:


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

Скажем, если разработчики Вася и Петя определяют в одном и том же прототипе два метода, которые делают одно и тоже, но имеют разные имена, то они всего лишь работают несогласованно, но ничего криминального. Если же они определяют два метода, выполняющие разные задачи, но имеющие одинаковое имя, то они поломают проект.

Ценность Sugar заключается в числе прочего в том, что он предоставляет единый, канонический API, единственной задачей которого является добавление маленьких вспомогательных методов в прототипы. В идеале, эту задачу следует доверять только одной библиотеке (будь то Sugar или какая-то другая). Выводить на поле глобального пространства имен новых игроков, с которыми вы мало знакомы и чьи задачи менее очевидны, — значит увеличивать риск. Это, конечно, не означает, что вы сразу нарветесь на проблему. Степень риска нужно соотносить со степенью вашей осведомленности.

Библиотеки, плагины и прочие middlemware не должны использовать Sugar по той же причине. Модификация глобальных объектов должна быть сознательным решением конечного пользователя. Если автор библиотеки все-таки решает использовать Sugar, он должен сообщить об этом своим пользователям на самом видном месте.

От переводчика: Я считаю, что любая библиотека должна стремиться к тому, чтобы иметь как можно меньше зависимостей, в особенности таких необязательных, как Sugar, Underscore и подобные библиотеки. Они не делают ничего такого, что нельзя было бы переписать на чистом JavaScript. Злоупотребление этим правилом со стороны авторов библиотек может приводить к тому, что ваш проект будет иметь кашу из зависимостей с дублирующимся и совершенно избыточным функционалом: Lazy.js, Underscore, Lo-Dash, wu.js, Sugar, Linq.js, JSLINQ, From.js, IxJS, Boiler.js, sloth.js, MooTools… Так что рекомендация «не используйте Sugar в middleware» справедлива и в адрес остальных библиотек.

5. Допущения насчет отсутствия свойств


Проблема:


Насколько опасны конфликты в глобальном пространстве имен, настолько же вредны и допущения о том, что содержится (или чего не содержится) в глобальном пространстве имен.

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

function getName(o) {
  if(o.first) {
    return firstName;
  } else {
    return lastName;
  }
}

Этот обманчиво простой код делает неявное допущение — что свойство first никогда не будет определено где-нибудь в прототипной цепочке объекта (даже если это строка). Конечно, гарантий этого вам никто не даст, ведь Object.prototype и String.prototype — это глобальные объекты, и изменить их может каждый.

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

Позиция Sugar:


Исправить код из последнего примера совсем несложно. Вы уже знакомы с решением:

function getName(o) {
  if(o.hasOwnProperty('first')) {
    return firstName;
  } else {
    return lastName;
  }
}

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

На своем веку Sugar спровоцировал эту проблему в коде крупных библиотек два раза: jquery#1140 и mongoose#482. Виновниками в обоих случаях были неудачно названные методы Sugar. Мы охотно их переименовали, чем и разрешили проблему. Кроме того, одна из библиотек (jQuery) проработала проблему совместно с нами, чтобы устранить изъян на своей стороне.

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

6. Соответствие спецификации


Проблема:


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

Позиция Sugar:


С самого начала мы разрабатывали Sugar, стремясь не только соответствовать спецификации, но и эволюционировать вместе с ней.

В пакете ES5 Sugar предлагает полифиллы методов, описанных в современной спецификации. Конечно, при наличии в среде исполнения нативной реализации методов, используются нативные. Sugar имеет обширный набор тестов, благодаря которому можно быть уверенным, что все полифиллы точно соответствуют спецификации. Кроме того, при желании вы можете отказаться от пакета ES5 и использовать любой другой ES5-полифилл.

Соответствовать спецификации — значит и подстраиваться под изменения в ней. На Sugar лежит ответственность всегда быть на передовой стандартов. Чем раньше начнешь соответствовать новой драфту спецификации, тем безболезненней будет переход на нее в дальнейшем. Начиная с версии 1.4, Sugar равняется на стандарт ECMAScript 6 (и поглядывает на 7, находящий на самых ранних этапах развития). По мере изменения спецификации, Sugar продолжит подстраиваться, избегая конфликтов и стремясь выдерживать баланс между практичностью и соответствием нативной реализации.

Конечно, адаптация хороша для тех пользователей, кто готов регулярно обновлять зависимости. Но как поведут себя проекты, сидящие на старой версии Sugar, когда среда перейдет на очередную версию спецификации? Представьте себе ситуацию: браузеры посетителей вашего сайта обновляются, и сайт в них ломается. Sugar недавно принял нелегкое решение переопределять методы, которые не описаны в спецификации явным образом. Теперь никаких if (!Object.prototype.foo) Object.prototype.foo = function(){};, все отсутствующие в ECMAScript методы переопределяются безусловно.

Хоть и может показаться наоборот, но это решение направлено на улучшение поддержки сайтов. Даже если в результате обновления спецификации нативные методы изменятся и вступят в конфликт с Sugar, Sugar переопределит их. Следовательно, методы продолжат работать как и раньше — пока у вас не дойдут руки обновить сайт. Но как уже было сказано, мы стремимся идти далеко впереди спецификации, минимизируя эту потребность.

TL/DR


Давайте пройдемся по всем проблемам и связанным с ними рискам:
  1. Проблема: Модификация объектов среды
    Риск: отсутствует.
  2. Проблема: Функции как перечисляемые свойства
    Риск: минимальный. При обходе обычных объектов риска нет, как и при обходе массивов безопасным способом. При обходе массивов небезопасным способом будут проблемы в IE8 и ниже.
  3. Проблема: Переопределение свойств
    Риск: отсутствует.
  4. Проблема: Конфликты в глобальном пространстве имен
    Риск: минимальный, но растет обратно пропорционально вашей осведомленности о том, что происходит в глобальном пространстве имен вашего проекта. В идеале, проект не должен содержать больше одной библиотеки вроде Sugar, и ее использование должно быть задокументировано. Не используйте Sugar, если сами пишете библиотеку; в крайнем случае, сообщайте о применении Sugar пользователям вашей как можно громче.
  5. Проблема: Допущения насчет отсутствия свойств
    Риск: минимальный. Проблема возникала дважды в истории Sugar, оба случая были быстро разрешены.
  6. Проблема: Соответствие спецификации
    Риск: совсем незначительный. Sugar старается быть настолько осторожным с модификацией нативных объектов, насколько это вообще возможно. Но достаточно ли этого? Ответ на этот вопрос зависит от убеждений пользователя и структуры проекта, а также меняется со временем (в лучшую сторону).


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

От переводчика


Производительность SugarJS


Будучи более удобным в использовании, Sugar заметно проигрывает Lo-Dash в производительности. Однако вопрос производительности, на мой взгляд, имеет значение только на обработке сколько-нибудь больших объемов данных. Если вы работаете с фронтендом, то никакой разницы в быстродействии этих библиотек вы не обнаружите.

Для тех же, кому производительность критична, рекомендую LazyJS. В случаях, когда после обхода свойств объекта/массива не требуется вернуть новый объект со всеми свойствами, LazyJS выигрывает у Lo-Dash в производительности. На составных операциях разрыв становится значительным. Например, на операции map -> filter LazyJS в пять раз быстрее, чем Lo-Dash, и в пятнадцать, чем SugarJS. Если же вам нужно не просто пройтись по значениям свойств, а собрать новый объект/массив, то LazyJS теряет преимущество. StreetStrider подсказывает, что ленивые вычисления на цепочках планируются в LoDash версии 3.

Тут (англ.) можно прямо в своем браузере сравнить производительность десяти аналогичных библиотек на множестве типовых операций.

Удобство, которое мы потеряли


Вам может быть интересно, как элегантно проблема расширения нативных объектов решена в Ruby. Там предложен механизм уточнений (refinements), который в версии Ruby 2.1 вышел из экспериментального статуса.

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

refine String do
  def petrovich
    "Petrovich says: " + self
  end
end

Разработчик Петя ваяет одну из частей крупного проекта, над которым трудится много программистов. Когда Петя подключает библиотеку Vasya, васины манки-патчи не распространяются на весь проект и не мешают жить остальным кодерам. При подключении библиотеки переопределение нативных объектов вообще не происходит.

Чтобы воспользоваться новыми методами, Петя в своем коде указывает, манки-патчи из каких библиотек ему нужны:

using Vasya
using HollowbodySixString1957

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

UPD1: Зачем все это нужно?


Из комментариев стало понятно, что это очевидно мне одному. Выношу мой ответ из комментариев.

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

Когда видишь такой код (абстрактный пример):

var result = Math.floor( MyArray.last( arr ) );
if (debug) console.log( "result:", result );
return result;

… вместо такого:

return arr.last().floor().debug();

… то становится как-то, знаете, тоскливо.
Only registered users can participate in poll. Log in, please.
Допустимо ли расширение нативных объектов JS?
37.91% Недопустимо, за исключением полифиллов.127
16.12% Допустимо, но только при тщательном документировании, покрытии всего кода тестами и осведомительной работе с коллективом.54
29.55% Допустимо, достаточно здравого смысла. Проблемы надо решать по мере их поступления, а не прятать голову в песок.99
4.18% Ничего не думаю по этому поводу.14
12.24% Я НЛО, похитьте меня кто-нибудь!41
335 users voted. 89 users abstained.
Only registered users can participate in poll. Log in, please.
Как изменилось ваше отношение к вопросу после прочтения статьи?
36.71% Как был против, так и остался.116
27.53% Как не был против, так и остался.87
6.01% Оказывается, не так страшен черт.19
7.28% Не думал, что расширение нативных объектов так опасно.23
8.23% Ничего не думаю по этому поводу.26
14.24% pilot.jpg45
316 users voted. 104 users abstained.
Tags:
Hubs:
+24
Comments44

Articles