Этот текст является переводом статьи 'Stop Being Cute and Clever' небезызвестного (по крайней мере, в Python-комьюнити) Армина Ронахера.
Последние дни в свободное время я занимался созданием планировщика. Идея была простой: создать некий клон worldtime buddy c использованием AngularJS и некоторых других JavaScript-библиотек.
И знаете что? Это было отнюдь не весело. Я уже давно так сильно не злился, работая над чем-либо, а это что-то значит, потому что обычно я быстро высказываю своё недовольство (прошу прощения у моих фолловеров в Twitter).
Я регулярно использую JavaScript, но мне редко приходилось сталкиваться с кодом других людей. Обычно я привязан только к jQuery, underscore и иногда AngularJS. Однако в этот раз я пошел ва-банк и решил использовать различные сторонние библиотеки.
Для данного проекта я использовал jQuery, без которого уже нельзя обойтись (да и зачем?), и AngularJS с некоторыми UI-компонентами (angular-ui и биндинги к jQuery UI). Для работы с часовыми поясами использовался moment.js.
Хочу сразу отметить, что я не собираюсь критиковать чей-то конкретный код. Более того, если кто-то заглянет в мои JavaScript-исходники, их код будет немногим лучше, а иногда и хуже, ведь я не тратил на него много времени, да и вообще у меня не слишком много опыта работы с этим языком.
Однако я заметил тревожную тенденцию появления кода ужасного качества в JavaScript-библиотеках (по крайней мере в тех, которые я использую), и задумался о том, почему так происходит.
У меня было много проблем с js-библиотеками, и все они являлись результатом того, что всем, похоже, наплевать на особенности работы языка.
Причина, по которой я стал активно изучать сторонний JavaScript-код, была в том, что моя наивная попытка отправки 3mb названий городов в библиотеку автодополнения typeahead.js привела к невероятно тормозному UI. Очевидно, что сейчас ни один умный человек будет не отправлять так много данных в поле с автодополнением, а фильтровать их сначала на стороне сервера. Но данная проблема кроется не в медленной загрузке данных, а как раз в медленной фильтрации. Чего я никак не мог понять, ведь даже если происходит линейный поиск по 26 000 элементов, он не должен быть настолько медленным.
Предыстория
Итак, интерфейс тормозил — очевидно, ошибка была в моей попытке передать слишком большое количество данных. Но интересно, что производительность падала именно при использовании typeahead-виджета. Причем иногда весьма своеобразным образом. Чтобы показать, какое сумасшествие происходило, я приведу несколько начальных тестов:
- Ищем San Francisco, печатая «san». ~200ms.
- Ищем San Francisco, печатая «fran». ~200ms.
- Ищем San Francisco, печатая «san fran». Секунда.
- Ищем San Francisco, снова печатая «san». Секунда.
Что вообще происходит? Как ломается поиск, если мы ищем что-то более одного раза?
Первое, что я сделал – это использовал новый профайлер Firefox’a, чтобы увидеть, на что тратится так много времени. И очень быстро нашел в typeahead кучу вещей, которые были слишком странными.
Самое узкое место было найдено довольно быстро. Проблема была в эпичном промахе при выборе структуры данных и странном алгоритме. Способ поиска совпадений причудлив и включает такие замечательные вещи, как проход по списку строк и дальнейшую проверку каждой из них на вхождение в другие списки, включая исходный. Когда в первом списке находится 6000 элементов, и для каждого запускается линейный поиск для проверки, действительно ли этот элемент находится в списке, всё это занимает очень много времени.
Да, ошибки случаются, и если вы проводите тесты с небольшими объёмами данных, вы даже не заметите их. Отправленных мною тысяч городов и часовых поясов было слишком много. Также не все пишут функции поиска каждый день, так что я никого не виню.
Но из-за того, что мне пришлось отлаживать этот кусок, я наткнулся на самый странный код из всего, что видел ранее. После дальнейшего исследования оказалось, что такие же чудаковатости встречаются не только в typeahead.
Основываясь на этом, я теперь убеждён, что JS представляет из себя своеобразный Дикий Запад разработки софта. В первую очередь потому что он конкурирует с кодом PHP года 2003-го в плане качества, но судя по всему это волнует меньшее количество людей, так как он работает на клиентской стороне, а не на сервере. Вы не должны платить за медленно работающий JavaScript.
«Умный» код
Первая болевая точка – люди, которым JS кажется милым и 'умным' языком. И это делает меня до смешного параноидальным при проведении ревью кода и поиске багов. Даже если вы знаете примененные идиомы, вы не можете быть уверены, будут ли побочные эффекты намеренными, или кто-то просто сделал ошибку.
Для примера я приведу кусок typeahead.js:
_transformDatum: function(datum) {
var value = utils.isString(datum) ? datum : datum[this.valueKey],
tokens = datum.tokens || utils.tokenizeText(value),
item = {
value: value,
tokens: tokens
};
if (utils.isString(datum)) {
item.datum = {};
item.datum[this.valueKey] = datum;
} else {
item.datum = datum;
}
item.tokens = utils.filter(item.tokens, function(token) {
return !utils.isBlankString(token);
});
item.tokens = utils.map(item.tokens, function(token) {
return token.toLowerCase();
});
return item;
}
Это всего лишь одна функция, которая тем не менее зацепила меня по многим причинам. Всё, что делает функция – конвертирует объект c данными в элемент списка. Что представляет из себя объект с данными? Где-то здесь и начинается интересное. Похоже, что автор библиотеки в какой-то момент пересмотрел свой подход. Всё должно было начинаться с приёма функцией строки и дальнейшего оборачивания её объектом с value-атрибутом (тоже строкой) и массивом токенов. Однако теперь возвращаемый объект – обёртка над объектом данных (или строкой) с совершенно иным интерфейсом. Копируется куча данных, и затем просто переименовываются некоторые атрибуты.
Предположим, что что на вход поступает объект следующего вида:
{
"value": "San Francisco",
"tokens": ["san", "francisco"],
"extra": {}
}
Тогда он трансформируется в такой:
{
"value": "San Francisco",
"tokens": ["san", "francisco"],
"datum": {
"value": "San Francisco",
"tokens": ["san", "francisco"],
"extra": {}
}
}
Я могу понять, почему код заканчивает работу именно так, но глядя на совершенно иной участок кода, совершенно неочевидно, почему мой datum-объект стал другим объектом, тем не менее содержащим те же данные. Даже хуже: удваивается используемая объектом память, потому что при операциях с массивами копируются токены. Получается, что я мог просто отправить объекты данных в правильном формате, сократив при этом потребление памяти на 10MB.
А ведь такой код достаточно типичен для JavaScript, и это расстраивает. Он неясен, он странный, ему не хватает информации о типах. И он слишком 'умный'.
Он просто оперирует объектами. Ты не можешь спросить у объекта: datum, в нужном ли ты формате? Это просто объект. При копании в деталях реализации оказалось, что можно отправить целую кучу различных типов данных на вход – и всё бы продолжало работать, просто делая что-то другое по началу и ломаясь намного позже. Впечатляет количество неверной информации, которую JS может обработать, выдав каким-то образом результат.
Мало того, что не хватает типизации, так этот код еще легкомысленно злоупотребляет операторами и функциональным программированием. Не передать словами, насколько недоверчиво я отношусь к такому стилю написания JS-кода, учитывая то, как странно работает функция map
. Не многим языкам удается реализовать map
таким образом, что ["1", "2", "3"].map(parseInt)
выльется в [1, NaN, NaN]
.
Злоупотребление операторами широко распространено. Немного ниже можно увидеть замечательный кусок кода:
_processData: function(data) {
var that = this, itemHash = {}, adjacencyList = {};
utils.each(data, function(i, datum) {
var item = that._transformDatum(datum), id = utils.getUniqueId(item.value);
itemHash[id] = item;
utils.each(item.tokens, function(i, token) {
var character = token.charAt(0), adjacency =
adjacencyList[character] || (adjacencyList[character] = [ id ]);
!~utils.indexOf(adjacency, id) && adjacency.push(id);
});
});
return {
itemHash: itemHash,
adjacencyList: adjacencyList
};
}
Для информации: utils.indexOf
– простой линейный поиск в массиве, а utils.getUniqueId
возвращает постоянно увеличивающееся целое число в качестве идентификатора.
Очевидно, автор этого кода знал о хэш-таблицах со сложностью O(1)
, иначе он не положил бы эту строку в hashmap. Но всё же несколькими строками ниже происходит линейный поиск перед позиционированием элемента в списке. Если закинуть в этот код 100 000 токенов, он будет работать очень медленно, поверьте.
Также хочется обратить внимание на это цикл:
utils.each(item.tokens, function(i, token) {
var character = token.charAt(0), adjacency =
adjacencyList[character] || (adjacencyList[character] = [ id ]);
!~utils.indexOf(adjacency, id) && adjacency.push(id);
});
Я просто уверен, что автор был очень горд. Для начала, почему именно так? Разве !~utils.indexOf(...) &&
действительно достойная замена if (utils.indexOf(...) >= 0)
? Не говоря уже о том, что hashmap со списками смежности называется adjacencyList
… Или то, что список инициализируется ID строки, и потом сразу же проходит линейный поиск по всему списку для поиска этого же элемента еще раз. Или что значение в хэш-таблицу вносится по булевой проверке списка и с использованием оператора ‘или’ для выполнения присваивания.
Еще один распространенный хак – использование унарного оператора +
(который в других языках бесполезен, так как это noop
) для перевода строки в число. +value
– то же самое, что parseInt(value, 10)
.
У меня есть теория, что всё это операторное безумие пошло из Ruby. Но в Ruby это имеет смысл, так как там только два объекта со значением 'ложь'
: false
и nil
. Всё остальное – 'истина'
. Весь язык базируется на этой концепции. В JS же многие объекты ложны. А затем иногда – нет.
Например, экземпляр пустой строки ""
приравнивается к false
. За исключением того случая, когда это объект. А строки иногда становятся объектами по случайности. Функция jQuery each
передает текущее значение итератора как this
. Но, так как this
не может ссылаться на примитивные типы, объект передается как строка, обернутая объектом.
Так что в некоторых ситуациях поведение может сильно отличаться:
> !'';
true
> !new String('');
false
> '' == new String('');
true
Симпатизировать операторам можно в Ruby, но никак не в JavaScript. Это банально опасно. Не то что бы я не доверяю человеку, который протестировал свой код и знает, что он делает. Просто если кто-то другой посмотрит потом на этот код, ему будет неясно, запланировано ли такое поведение разработчиком.
Использование ~
для проверки возвращаемого функцией indexOf
значения, которое может быть равным -1
при отсутствии элемента, просто неразумно. И пожалуйста, не говорите мне, что «так же быстрее».
Работаем «наживую»
Сомнительное использование операторов это одно, но действительно убивает то, что динамическую природу JS возводят в абсолют. Как по мне, даже Python – избыточно динамический язык, но питонисты хотя бы довольно разумно сводят модификации классов и пространств имён в runtime к минимуму. Но в мире JavaScript всё по другому. И особенно в мире AngularJS.
Классы не существуют, в JS используются объекты, которые иногда могут иметь прототипы. Хотя обычно все просто помещают функции в объекты. А иногда и функции в функции. Странное клонирование объектов тоже нормальное явление, ну разве что не в случае, когда состояние объекта часто меняется.
Может показаться, что директивы в Angular неплохи, до тех пор, пока вам не встретится директива, делающая почти то, что вам нужно. В большинстве случаев директива монолитна, и единственный способ изменить её – это добавить еще одну директиву с большим приоритетом, которая пропатчит предыдущую. Я бы не расстроился, если бы наследование классов ушло в прошлое, уступив место совмещению, но такой 'обезьяний патчинг' – не мой стиль.
Динамическая натура позволяет коду очень быстро развиться в неуправляемую массу, где никто точно не знает, что как работает. Не просто из-за отсутствия классов и типов. Всё окружение смахивает на штуку, склеенную изолентой, с толстым слоем смазки в механизме.
Например, Angular использует систему слежения за изменениями моделей и DOM для автоматической их синхронизации. Мало того, что это чертовски медленно, так народ еще и придумывает всякие обходные пути для предотвращения firing’а обработчиков. Такая логика быстро становится до смешного запутанной.
Неизменяемость
Чем выше уровень языка программирование, тем более неизменяемыми становятся вещи. Но только не в JavaScript. API становятся всё больше засоренными stateful-концепциями. Может быть, неуместно жаловаться на это в плане производительности, но это начинает очень быстро раздражать. Одни из самых неприятных багов в моём планировщике были в изменяемой природе moment-объектов. Вместо того, чтобы вернуть новый объект, foo.add('minutes', 1)
изменял исходный. Нет, я знал про это, в документации к API всё описано. Но, к сожалению, случайно передал туда ссылку, и она была изменена.
Правда, в теории JS должен быть отличным инструментом для построения API, использующего неизменяемые объекты при условии возможности их ‘заморозки’ по желанию. Это как раз то, чего не хватает Python. Однако в тоже время Python предоставляет больше инструментов, делающих immutable-объекты более интересными. Например, поддержку перегрузки операторов, и first-классы, которые позволяют использовать такие объекты в качестве ключей хэш-таблиц.
«Полезная магия»
Я люблю Angular, очень. Это одна из разумнейших систем для проектирования UI в JavaScript, но присутствие в ней магии пугает. Начинается всё с простых вещей. Например, библиотека переименовывает директивы. Если вы создадите директиву fooBar
, в DOM она попадёт как foo-bar
. Почему? Предположим, для однообразия с style
DOM API, в котором делалось нечто похожее ранее. Но это делает код запутанным, поскольку вы можете не знать в точности, как точно называется директива. Также всё это полностью игнорирует идею пространств имён. Если у вас есть две директивы с одинаковыми именами в разных Angular-приложениях, они будут конфликтовать.
Внедрение зависимости в Angular происходит по умолчанию через конвертацию JS-функции в строку и последующее использование регулярного выражения для разбора аргументов. Если вы новичок в AnguarJS, для вас это вообще не будет иметь никакого смысла, а мне даже сейчас эта идея кажется плохой. Это конфликтует с тем, что люди делали в течении долгого времени в JS: локальные переменные рассматриваются как анонимные. Имя ни на что не влияет. Именно этим минимизаторы пользовались целую вечность. Однако всё же это не в полной мере относится к Angular, так как есть альтернативная возможность явного объявления зависимостей.
Что за слои?
Одним из неудобств после перехода с Python на клиентский JavaScript было отсутствие абстракций. В качестве примера, Angular позволяет получить доступ к параметрам текущего URL в виде словаря. Что он не позволяет, так это разобрать произвольную строку запроса. Почему? Потому что внутренняя функция парсинга спрятана под многими слоями замыканий, и кто-то просто не подумал, что она может быть полезной.
И такое происходит не только в Angular. JS сам по себе не имеет функции для экранирования HTML. Но DOM, очевидно, нуждается в такой функциональности в некоторых случаях. Из-за чего некоторые на полном серьёзе экранируют HTML так:
function escapeHTML(string) {
var el = document.createElement('span');
el.appendChild(document.createTextNode(string));
return el.innerHTML;
}
А так можно распарсить URL:
function getQueryString(url) {
var el = document.createElement('a');
el.href = url;
return el.search;
}
Это безумие, но оно везде.
В какой-то степени я могу понять, что разработчики не хотят давать доступ к низкоуровневым функциям, но это приводит к тому, что люди придумывают различные хаки просто для того, чтобы продублировать уже имеющийся функционал. Вполне обычно видеть полдюжины реализаций одного и того же действия в больших JS-приложениях.
«Но оно же работает»
PHP настолько популярен, потому что он просто работает, и не требует много времени на обучение. Целое поколение разработчиков начало работать с ним. И этой группе людей пришлось открывать болезненными способами кучу вещей, основанных на опыте предыдущих лет. Сформировался некий групповой менталитет: когда один человек копировал код другого, он особо не задумывался, как он работает. Я помню время, когда система плагинов была бредом сумасшедшего, и основным путём расширения PHP-приложений были mod-файлы. Какой-то заблуждавшийся дурак начал всё это, и все стали так делать. Я почти уверен, что именно так появились register_globals, странное ручное экранирование SQL, да и вся концепция обработки входных данных вместо нормального экранирования.
JS по большей части такой же. Сменилось поколение разработчиков, сменились проблемы, а менталитет остался. Всё так же концепции, увиденные в одной библиотеке, копируются в другую.
Даже хуже: так как всё работает в песочнице на компьютерах пользователей, никто даже не задумывается о безопасности. И в отличие от PHP производительность не имеет значения, поскольку клиентский JS «масштабируется линейно» с ростом числа пользователей, запустивших приложение.
Будущее?
Нет, я не совсем пессимистично настроен к JavaScript. Он определенно улучшается, но я считаю, что ему придётся пройти через ту же фазу, что и PHP: люди, пришедшие из других областей и с других языков программирования были вынуждены работать с ним, и, пусть медленно, начали приносить здравые мысли в сообщество. Настанет время, когда обезьяний патчинг прототипов исчезнет, когда будут представлены более строгие системы типов, люди начнут думать о параллелизме, появится отрицательная реакция на безумное метапрограммирование.
Последние несколько лет подобные события происходят в Python-сообществе. Несколько лет назад метаклассы были горячей новинкой, а сейчас, когда приложения стали становится всё больше и больше, многие одумались. Когда Django только появился, разработчикам пришлось отстаивать использование функций вместо классов, сейчас об этом уже почти никто не говорит.
Я просто надеюсь, что JavaScript-комьюнити понадобится меньше времени на подстройку, чем предшественникам.
Armin Ronacher,
09/12/2013
Автор: komissarex