Он вам не JavaScript!

Дисклеймер!

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

В рамках лекции мы узнаем:

  1. Как появился один из самых языков программирования
  2. За что его не любит большинство разработчиков
  3. Плохой ли 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

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

Фундаментальные грехи

Конечно, в таких спартанских условиях, из языка не могло выйти что-то удобное, масштабируемое и надёжное. Костыли стали верным спутником 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 происходят интересные процессы преобразования объектов по строго определённому алгоритму:

В конце него он гарантированно получит примитив (число, булево или строку). С ними уже можно оперировать.

Возвращаясь к примерам выше, уже не кажется магией некоторые преобразования:

[] + []   // [] -> "", [] -> "", "" + "" = ""

[] + {}   // [] -> "", {} -> "[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 больше 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).