Он вам не JavaScript!
Дисклеймер!
Перед началом лекции я бы хотел отметить, что весь её текст является либо общепризнанными фактами, либо субъективным мнением автора. Если вы не согласны с ним — это нормально, запишите свою лекцию.
В рамках лекции мы узнаем:
- Как появился один из самых языков программирования
- За что его не любит большинство разработчиков
- Плохой ли JS язык программирования?
Люблю и ненавижу
JavaScript — это уникальный язык. Его не любят, но тем не менее повсеместно используют.
Но почему? За что вообще не любят JS? Почему JS получился таким, какой он есть? К чему может привести неопределённое поведение? Какие языки могут заменить JS? Это и многое другое мы обсудим сегодня!
Немного статистики
JS держит крепкие позиции в большинстве рейтинг-листов.
И это неудивительно: дальше мы узнаем, что JS настоящий монополист в мире фронтенда.
Итак, перейдём к краткой исторической справке.
Краткая историческая справка
Предыстория
В середине 1990-х годов, в период бурного развития интернет-технологий, компания Netscape Communications, создатель популярного веб-браузера Netscape Navigator, столкнулась с необходимостью расширения функциональности веб-страниц. Существовавшие на тот момент технологии не позволяли реализовать интерактивность на стороне клиента, что ограничивало возможности веб-разработки. Анимации и всё прочее также были недоступны.
Предпосылки создания нового языка
Марк Андриссен, сооснователь Netscape, выдвинул концепцию “динамического HTML”, требовавшую создания скриптового языка, который:
- Был бы прост в освоении для непрофессиональных разработчиков
- Не требовал компиляции
- Мог выполняться непосредственно в браузере
- Имел бы синтаксис, понятный широкому кругу пользователей
Первоначально рассматривались существующие языки (Python, Tcl, Scheme), но они не соответствовали всем требованиям. В частности, Scheme, несмотря на свою элегантность, имел синтаксис, слишком отличающийся от распространённых языков программирования. Всё-таки, не стоит забывать, что это диалект Lisp :)
С другой стороны, в то время Sun Microsystems активно разрабатывали Oak (ныне и далее в тексте — Java). И Netscape Communications активно сотрудничала с Sun, что в итоге привело к большому воздействию Java на финальную версию JavaScript.
Возможно, возникает закономерный вопрос: почему бы в таком случае не взять Java как язык для браузеров? Ответ очевиден — вернитесь к 4 пункту.
Первый прототип
Брендан Айк, на тот момент сотрудник Netscape, получил задание создать прототип языка за крайне сжатые сроки. Конечно, с такими дедлайнами и таким смутным временем (а мы помним, что во время гонки браузеров была проблема с единообразием), получилась максимальная несуразица. Это была первая версия языка JavaScript. Работа над ней велась с мая по декабрь 1995 года.
Название ему выбрали максимально подходящее — Mocha. И уже с самого первого релиза он показал себя во всей красе.
По итогу внешне вышло что-то похожее на Java из-за сделки с Sun, а внутри — Scheme с прототипным наследованием (далее будет рассказано подробнее, что это значит).
Что будем делать с названием Mocha
Над брендингом очень хорошо подумали, и по итогу язык пережил несколько переименований:
- Mocha (рабочее название) → LiveScript (при первом включении в Netscape Navigator)
- LiveScript → JavaScript (из-за сделки с Sun)
Фундаментальные грехи
Конечно, в таких спартанских условиях, из языка не могло выйти что-то удобное, масштабируемое и надёжное. Костыли стали верным спутником JS до конца его дней из-за фундаментальных проблем, вложенных уже в первую версию языка. В этой главе мы рассмотрим основные проблемы, с которыми вы можете столкнуться при работе с JS.
Приведение типов
JavaScript славится своей неочевидной системой приведения типов. Говорить тут особо не о чем, так язык собрали. Мы лишь рассмотрим занимательные примеры удивительного поведения языка, а также попробуем их объяснить.
Арифметические операции
'4' + 2; // "42" (2 -> "2")
'4' - 2; // 2 ("4" -> 4)
'4' * '2'; // 8 ("4" -> 4, "2" -> 2)
'4' / '2'; // 2 ("4" -> 4, "2" -> 2)
'4' ** '2'; // 16 ("4" -> 4, "2" -> 2)
Как мы можем заметить, арифметические операции приводят строки к числам, если не определена соответствующая операция над строками. В данном примере у нас есть перегруженный на конкатенацию +, поэтому, если хотя бы один из операндов строка, мы будем их складывать как строки. Здесь всё довольно просто.
Арифметические операции 2: возвращение
А сейчас я достаю из чертогов JS самое жуткое и до конца не изученное явление — сумма нематематических объектов.
Сначала примеры — а потом объяснения:
[] + [] // ""
[] + {} // "[object Object]"
{} + [] // 0, а в console.log "[object Object]"
{} + {} // NaN,
// а в console.log "[object Object][object Object]"
Почему так происходит и мы получаем такие странные ответы? Дело в том, что под капотом JS происходят интересные процессы преобразования объектов по строго определённому алгоритму:
Symbol.toPrimitive()если естьvalueOf()toString()
В конце него он гарантированно получит примитив (число, булево или строку). С ними уже можно оперировать.
Возвращаясь к примерам выше, уже не кажется магией некоторые преобразования:
[] + [] // [] -> "", [] -> "", "" + "" = ""
[] + {} // [] -> "", {} -> "[object Object]",
// "" + "[object Object]" = "[object Object]"
{} + [] // Смотри объяснение ниже.
{} + {} // {} -> "[object Object]", {} -> "[object Object]",
// "[object Object]" + "[object Object]" =
// "[object Object][object Object]"
Ну а как это объясняет {} + []?
Да никак, это JavaScript. Это ошибка парсера: иногда, если действовать не через console.log, то он иногда может интерпретировать {} + [] как {} -> undefined и +[] -> 0 как унарный плюс. И тут важно понимать: +[] это именно унарная операция, а не undefined + 0.
Вау, а если попробовать пооперировать над массивами и числами?
[] - 1; // -1 ([] -> 0)
{
}
-(1)[5] * // NaN ("[object Object]" - 1 -> NaN)
[2][(1, 2)] - // 10 ([5] -> 5)
[3, 4]; // NaN ([1, 2] -> "1, 2")
После того как мы узнали алгоритм, уже не так непонятно и страшно? Мы только начали, время для унарных операций над объектами!
+[] + // 0 ([] -> "" -> 0)
{} - // NaN ({} -> "[object Object]" -> NaN)
[] - // -0 (-0 === 0)
{}; // NaN
Здесь всё просто, срабатывает обычное преобразование в Number.
Зная алгоритм выше, нетрудно догадаться, каким результатом обернутся операции над null, NaN, undefined. Не будем для краткости на них заостряться.
Прототипное наследование
Прототипное наследование — это фундаментальный механизм JavaScript, кардинально отличающийся от классического ООП, из-за чего ООП в привычном нам понимании в JS невозможен. Разберём его наглядно.
Объектная модель
Да, в JS есть объекты (их ещё называют прототипами — отсюда и название парадигмы). Для того, чтобы как-то объединять их в группы наподобие классов, у каждого объекта есть скрытое свойство [[Prototype]], доступ к которому мы можем получить через __proto__:
const Animal = { eats: true };
const Rabbit = { jumps: true };
Rabbit.__proto__ = Animal; // теперь Animal — "родитель" Rabbit
console.log(Rabbit.eats); // true
При обращении к какому-то полю объекта, JS проверит его наличие в объекте, затем в его [[Prototype]]. Процесс повторяется до последнего прототипа, иначе такого поля нет.
const Animal = { eats: true };
const Rabbit = { jumps: true, __proto__: Animal };
const Richard = { owner: 'Robert', __proto__: Rabbit };
console.log(Richard.eats); // Richard.__proto__.__proto__.eats
Современные прототипы
Способы работы с прототипами выше считаются устаревшими и рекомендуется использовать их по-другому:
const Animal = { eats: true };
const Rabbit = Object.create(Animal);
console.log(Rabbit.eats); // true
console.log(Object.getPrototypeOf(Rabbit) === Animal); // true
Object.setPrototypeOf(Rabbit, { jumps: true });
console.log(Rabbit.jumps); // true
В чём проблема прототипов?
Конечно, можно сказать, что это не похоже на ООП, не интуитивно и т.п., но лично мне так не кажется. Для меня основной проблемой является отсутствие какой-либо приватности у полей объектов:
const Secret = {
_token: 696969,
};
console.log(Secret._token); // Иллюзия безопасности
А как же классы?
Особо заинтересованный читатель мог подметить, что, вообще говоря, в JS есть классы:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes noise`);
}
}
Однако это лишь синтаксический сахар над прототипным наследованием. Кстати, запомните это словосочетание: синтаксический сахар. При работе с JS вы будете слышать его максимально часто.
Работа с for
На самом деле, “подводных камней” достаточно много, но они все лежат примерно на поверхности. Я бы хотел осветить один пример относительно неочевидного поведения:
const animal = { eats: true };
const rabbit = { jumps: true, __proto__: animal };
for (let prop in rabbit) {
console.log(prop); // jumps, eats
}
Object.keys(rabbit); // ['jumps']
Как мы видим, цикл проходится по всем свойствам, даже унаследованным, в то время как Object.keys возвращает лишь собственные свойства.
Будьте бдительны с этим!
Синтаксический сахар. Целый завод
В силу неудобств и неурядиц, описанных в прошлой главе, а также в стремлении стать самым дружелюбным языком для всех, неизбежно JS начал обрастать (и обрастает до сих пор) большим количеством синтаксического сахара. Из-за этого язык местами перестаёт быть однозначным, а код на нём становится менее читаемым.
Мы не будем сильно заострять внимание на синтаксическом сахаре в JS, а лишь бегло пробежимся по примерам.
Топ-8 способов задать функцию
В JS вы можете задать функцию целой кипой способов:
function aboba(x) { return x }
aboba = function(x) { return x }
aboba = new Function('x', 'return x')
aboba = Function('x', 'return x')
aboba = (x) => { return x }
aboba = (x) => (x)
aboba = x => x
aboba = Object.assign(new Function, { ... })
Первые два способа самые обыкновенные — function declaration и function expression соответственно.
Далее мы создаём функцию через конструктор. Это интересный способ, однако практического смысла он не несёт. Мы также можем создать её и без new, что ещё более абсурдно, ведь какой тогда смысл в new? На самом деле, различие лишь в том, что в первом случае мы явно вызываем конструктор Function, а во втором — неявно.
Следующие три способа — это стрелочные функции. В ряде случаев мы можем даже не использовать скобки, а также вместо тела функции писать уже готовое возвращаемое значение. Это тоже своего рода синтаксический сахар — игнорирование скобок.
Последний способ для извращенцев. Мы создаём функцию, конвертируем её в объект и добавляем свойства, которые нам нужны. Как результат — модифицированная функция с добавленными свойствами.
И да, функция может являться объектом и может обладать собственным контекстом this, что также добавляет большую неоднозначность. Есть ли у нас контексты функций, скажем, в C++? В Rust? В Go? В Python… а, в Python есть кстати. Не сказал бы, что это его красит.
Экосистема JS и ленивые программисты
Пока вы читаете эту главу, в мире было написано по меньшей мере 13 библиотек на JS.
Гайд как сломать весь Интернет в 11 строчек кода
В марте 2016 года мир JavaScript пережил один из самых громких кризисов в своей истории — падение экосистемы из-за удаления крошечной библиотеки left-pad.
Что это вообще такое? Это маленькая библиотека, написанная Азером Кочулу, которая занимает 11 (всего!) строчек кода и просто дополняет строку пробелами слева:
module.exports = leftpad;
function leftpad(str, len, ch) {
str = String(str);
var i = -1;
if (!ch && ch !== 0) ch = ' ';
len = len - str.length;
while (++i < len) {
str = ch + str;
}
return str;
}
Не кажется ли вам, что этот код можно написать самому за пару минут? Мне тоже так кажется, однако разработчики Babel и React со мной не согласятся. Тысячи проектов использовали эту библиотеку, устанавливая её через npm.
Однако Азер Кочулу в один момент решил просто удалить все свои 273 модуля из npm. Он же имеет на это право? Ну да, имеет. Кто-то от этого застрахован? Нет, никто.
В 11:00 UTC left-pad пропал из реестра npm, что повлекло за собой поломку сборок React, Babel, ESLint и других инструментов. Тут же были остановлены деплои крупнейших компаний, таких как Netflix и Bloomberg.
И по такому случаю, через 2.5 часа, впервые в истории npm откатывает удаление библиотеки и вводит новое правило на удаление пакетов (вернее, запрет на их удаление).
Но как так получилось? Проблема в глубокой вложенности зависимостей:
react -> babel-preset-react -> babel-preset-es2015 -> babel-plugin-transform-es2015-modules-commonjs -> left-pad
После такой ситуации Азер Кочулу ушёл из JS-сообщества, а его аккаунт на Github и npm забанили на несколько месяцев. В Twitter запустили флешмоб #SaveThePad, начали появляться пародийные пакеты, как, например, fck-left-pad — реализация в 1 строчку.
И всё это из-за 11 строчек…
Не изобретай велосипед
Вы скажете: “ну и как эта история связана с проблемой экосистемы? Не нужно было ставить непопулярные пакеты”.
Да, вы правы! Не нужно ставить непопулярные пакеты. Ровно также, как и не нужно ставить слишком простые пакеты! Вы в силах реализовать подобный функционал и сами.
Страшнее другое — на полном серьёзе в npm есть библиотеки:
is-even: проверка числа на чётностьis-odd: проверка числа на нечётностьempt: проверка массива на пустотуis-positive-integer: проверка числа на положительностьarray-index: возвращает индекс элемента в массивеis-ice-cream: проверка на то, что строка содержит вкус мороженого
И если вам кажется, что их никто не использует в проде, то вспомните историю выше. Я почти уверен, что люди скачивают их не только забавы ради. Да, кстати, у библиотеки is-even больше 200 000 недельных скачиваний. Стоит задуматься, а умеем ли мы вообще программировать…
Как не стать жертвой абсурда
npm ls --depth=5
решит все ваши проблемы. Будьте осторожнее с пакетами в JS.
Альтернативные решения
Были попытки спасти JS. Это могут быть как его расширения, так и совсем другие языки. Об этом и поговорим в данной главе.
TypeScript
Надмножество JS со статической типизацией. Из плюсов можно отметить полную совместимость с JS (можно постепенно мигрировать). Из минусов — типы пропадают в рантайме, так как TS нужно компилировать в JS.
WebAssembly (WASM)
Эта технология позволяет писать на любом поддерживающим её языке. Можно сказать, что это специальный бинарный формат для выполнения кода в браузере.
Например, в языке Rust есть фреймворк Leptos — прекрасное решение для высоконагруженных и максимально отзывчивых элементов приложения, таких как 3D-рендер или сложная анимация.
Не заменяет JS полностью (обычно используется вместе).
Dart
Изначально писался как замена JS, однако не прижился и теперь дружит с Flutter. Вместе они позволяют писать кроссплатформенные приложения.
Elm
Хорошая попытка создать убийцу JS. Достаточно быстрый язык, со строгой типизацией. Однако у него слишком высокий порог входа из-за сложного синтаксиса (мне напомнил Haskell).
Всё это — ошибка
Мне кажется, мы не сможем заменить JS ни одним из существующих языков. Проблема вся в том, что уже написано бесчисленное число сайтов, в том числе и очень старых. И нам важно сохранить обратную совместимость с ними.
С другой стороны, почему бы не создать хороший язык, который бы решал все проблемы JS, и плавно переходить на него? Новые сайты писать на новом языке, а старые сайты переписывать по мере возможностей? Этот вопрос открыт для дискуссии.
Заключение расследования
JS — это не самый хороший язык. У него полно минусов, поэтому его повсеместное использование остаётся под вопросом. Но всё же он чертовски хорош для простых скриптов в браузере или даже для написания фронтенда в принципе.
Возможно, он где-то повернул не туда, а может где-то сказалось влияние грехов при первой разработке, но тем не менее я не могу придумать хорошую альтернативу JS, которая бы была на порядок лучше него (кроме TS).