Как разработчик JS, это то, что не дает мне спать по ночам
Опубликовано: 2022-03-11JavaScript — странный язык. Хотя он вдохновлен Smalltalk, он использует синтаксис, подобный C. Он сочетает в себе аспекты парадигм процедурного, функционального и объектно-ориентированного программирования (ООП). Он имеет многочисленные, часто избыточные подходы к решению почти любой мыслимой проблемы программирования и не имеет строгого мнения о том, какой из них предпочтительнее. Он слабо и динамически типизирован, с лабиринтным подходом к приведению типов, который сбивает с толку даже опытных разработчиков.
JavaScript также имеет свои недостатки, ловушки и сомнительные возможности. Новые программисты борются с некоторыми из его более сложных концепций — подумайте об асинхронности, замыканиях и подъеме. Программисты, имеющие опыт работы с другими языками, разумно предполагают, что вещи с похожими именами и внешним видом будут работать в JavaScript так же, и часто ошибаются. Массивы на самом деле не массивы; что с this
делать, что такое прототип и что на самом деле делает new
?
Проблема с классами ES6
Худший нарушитель, безусловно, является новым для последней версии JavaScript, ECMAScript 6 (ES6): классы . Некоторые разговоры о классах откровенно настораживают и раскрывают глубоко укоренившееся непонимание того, как на самом деле работает язык:
«JavaScript наконец-то стал настоящим объектно-ориентированным языком, теперь у него есть классы!»
Или:
«Классы освобождают нас от размышлений о сломанной модели наследования JavaScript».
Или даже:
«Классы — это более безопасный и простой подход к созданию типов в JavaScript».
Эти утверждения меня не беспокоят, потому что они подразумевают, что с прототипическим наследованием что-то не так; давайте отложим эти аргументы. Эти утверждения беспокоят меня, потому что ни одно из них не является правдой, и они демонстрируют последствия подхода JavaScript «все для всех» к проектированию языка: он чаще наносит вред программисту в понимании языка, чем помогает. Прежде чем идти дальше, давайте проиллюстрируем.
JavaScript Pop Quiz #1: В чем существенная разница между этими блоками кода?
function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())
Ответ здесь - нет ни одного . По сути, они делают одно и то же, вопрос только в том, использовался ли синтаксис класса ES6.
Правда, второй пример более выразителен. Только по этой причине вы можете возразить, что class
— хорошее дополнение к языку. К сожалению, проблема немного более тонкая.
JavaScript Pop Quiz #2: Что делает следующий код?
function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())
Правильный ответ заключается в том, что он выводит на консоль:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
Если вы ответили неправильно, вы не понимаете, что такое class
на самом деле. Это не твоя вина. Как и Array
, class
— это не языковая функция, это синтаксическое мракобесие . Он пытается скрыть прототипическую модель наследования и сопутствующие ей неуклюжие идиомы и подразумевает, что JavaScript делает то, чего на самом деле не делает.
Возможно, вам сказали, что class
был введен в JavaScript, чтобы классические ООП-разработчики, пришедшие из таких языков, как Java, чувствовали себя более комфортно с моделью наследования классов ES6. Если вы один из таких разработчиков, этот пример, вероятно, привел вас в ужас. Должно. Это показывает, что ключевое слово class
в JavaScript не дает никаких гарантий, которые должен предоставлять класс. Он также демонстрирует одно из ключевых отличий модели наследования прототипов: прототипы — это экземпляры объектов , а не типы .
Прототипы против классов
Наиболее важное различие между наследованием на основе классов и прототипов заключается в том, что класс определяет тип , экземпляр которого может быть создан во время выполнения, тогда как прототип сам по себе является экземпляром объекта.
Дочерний класс ES6 — это еще одно определение типа , которое расширяет родителя новыми свойствами и методами, которые, в свою очередь, могут быть созданы во время выполнения. Дочерний элемент прототипа — это другой экземпляр объекта, который делегирует родительскому элементу любые свойства, не реализованные в дочернем элементе.
Примечание: вам может быть интересно, почему я упомянул методы класса, а не методы прототипа. Это потому, что в JavaScript нет концепции методов. Функции — это первоклассные функции в JavaScript, и они могут иметь свойства или быть свойствами других объектов.
Конструктор класса создает экземпляр класса. Конструктор в JavaScript — это обычная старая функция, которая возвращает объект. Единственная особенность конструктора JavaScript заключается в том, что при вызове с ключевым словом new
он назначает свой прототип в качестве прототипа возвращаемого объекта. Если это звучит для вас немного запутанно, вы не одиноки — это так, и это большая часть того, почему прототипы плохо изучены.
Чтобы подчеркнуть это, дочерний элемент прототипа не является копией своего прототипа и не является объектом той же формы , что и его прототип. У дочернего элемента есть живая ссылка на прототип, и любое свойство прототипа, не существующее в дочернем элементе, является односторонней ссылкой на свойство с тем же именем в прототипе.
Рассмотрим следующее:
let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
В предыдущем примере, в то время как child.foo
был undefined
, он ссылался на parent.foo
. Как только мы определили foo
для child
элемента, child.foo
имел значение 'bar'
, но parent.foo
сохранил свое исходное значение. Как только мы delete child.foo
, он снова ссылается на parent.foo
, что означает, что когда мы меняем значение родителя, child.foo
ссылается на новое значение.
Давайте посмотрим, что только что произошло (для более ясной иллюстрации мы собираемся представить, что это Strings
, а не строковые литералы, разница здесь не имеет значения):
То, как это работает внутри, и особенно особенности new
и this
— это тема для отдельного разговора, но у Mozilla есть подробная статья о цепочке наследования прототипов JavaScript, если вы хотите узнать больше.
Ключевым выводом является то, что прототипы не определяют type
; они сами являются instances
и могут изменяться во время выполнения со всеми вытекающими последствиями.
Все еще со мной? Вернемся к разбору классов JavaScript.
JavaScript Pop Quiz #3: Как вы реализуете конфиденциальность в классах?
Наш прототип и свойства класса выше не столько «инкапсулированы», сколько «ненадежно свисают из окна». Мы должны исправить это, но как?
Здесь нет примеров кода. Ответ в том, что вы не можете.
В JavaScript нет концепции конфиденциальности, но есть замыкания:
function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"
Вы понимаете, что только что произошло? Если нет, то вы не понимаете замыканий. На самом деле все в порядке — они не такие пугающие, как их изображают, они очень полезные, и вам нужно время, чтобы узнать о них.
JavaScript Pop Quiz #4: Что эквивалентно приведенному выше ключевому слову class
?
Извините, это еще один вопрос с подвохом. Вы можете сделать в основном то же самое, но это выглядит так:
class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"
Дайте мне знать, если это выглядит проще или понятнее, чем в SecretiveProto
. По моему личному мнению, это несколько хуже — оно нарушает идиоматическое использование объявлений class
в JavaScript и работает не так, как можно было бы ожидать, скажем, от Java. Это будет ясно из следующего:

JavaScript Pop Quiz #5: Что делает SecretiveClass::looseLips()
?
Давайте узнаем:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Ну… это было неловко.
Тест на JavaScript № 6: Что предпочитают опытные разработчики JavaScript — прототипы или классы?
Как вы уже догадались, это еще один вопрос с подвохом — опытные разработчики JavaScript стараются избегать и того, и другого, когда могут. Вот хороший способ сделать это с помощью идиоматического JavaScript:
function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()
Речь идет не только о том, чтобы избежать уродства, присущего наследованию, или о принудительной инкапсуляции. Подумайте, что еще вы могли бы сделать с помощью secretFactory
и leaker
, чего не могли бы легко сделать с помощью прототипа или класса.
Во-первых, вы можете деструктурировать его, потому что вам не нужно беспокоиться о контексте this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
Это очень мило. Помимо того, что мы избегаем new
и this
дурачества, это позволяет нам взаимозаменяемо использовать наши объекты с модулями CommonJS и ES6. Это также делает композицию немного проще:
function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)
Клиентам blackHat
не нужно беспокоиться о том, откуда взялся exfiltrate
, а spyFactory
не нужно возиться с жонглированием контекстом Function::bind
или глубоко вложенными свойствами. Имейте в виду, нам не нужно сильно беспокоиться об this
в простом синхронном процедурном коде, но это вызывает всевозможные проблемы в асинхронном коде, которых лучше избегать.
Если немного подумать, spyFactory
можно превратить в сложнейший шпионский инструмент, способный справиться со всеми видами целей проникновения, или, другими словами, в фасад.
Конечно, вы могли бы сделать это и с классом, или, скорее, с набором классов, каждый из которых наследуется от abstract class
или interface
… за исключением того, что в JavaScript нет понятия абстракций или интерфейсов.
Вернемся к примеру с приветствием, чтобы посмотреть, как мы реализуем его с помощью фабрики:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
Вы могли заметить, что эти фабрики становятся более краткими по ходу дела, но не волнуйтесь — они делают то же самое. Учебные колеса отрываются, ребята!
Это уже меньше шаблонов, чем прототип или классовая версия того же кода. Во-вторых, он более эффективно обеспечивает инкапсуляцию своих свойств. Кроме того, в некоторых случаях он требует меньше памяти и производительности (на первый взгляд может показаться, что это не так, но JIT-компилятор незаметно работает за кулисами, чтобы сократить дублирование и вывести типы).
Так безопаснее, часто быстрее и проще писать такой код. Зачем нам снова нужны занятия? О, конечно же, повторное использование. Что произойдет, если нам нужны недовольные и восторженные варианты приветствующих? Что ж, если мы используем класс ClassicalGreeting
, мы, вероятно, сразу переходим к выдумыванию иерархии классов. Мы знаем, что нам нужно будет параметризовать пунктуацию, поэтому проведем небольшой рефакторинг и добавим несколько дочерних элементов:
// Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!
Это прекрасный подход, пока кто-нибудь не придет и не попросит функцию, которая не вписывается четко в иерархию, и все это перестанет иметь какой-либо смысл. Закрепите эту мысль, пока мы пытаемся написать ту же функциональность с фабриками:
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
Не очевидно, что этот код лучше, хотя он немного короче. На самом деле, вы могли бы возразить, что это сложнее читать, и, возможно, это тупой подход. Разве у нас не может быть просто unhappyGreeterFactory
и enthusiasticGreeterFactory
GreeterFactory?
Затем приходит ваш клиент и говорит: «Мне нужен новый встречающий, который недоволен и хочет, чтобы об этом узнал весь зал!»
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Если бы нам нужно было использовать это восторженно-недовольное приветствие более одного раза, мы могли бы облегчить себе задачу:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
Существуют подходы к этому стилю композиции, которые работают с прототипами или классами. Например, вы можете переосмыслить UnhappyGreeting
и EnthusiasticGreeting
как декораторы. Это все равно потребует больше шаблонного кода, чем подход функционального стиля, использованный выше, но это цена, которую вы платите за безопасность и инкапсуляцию реальных классов.
Дело в том, что в JavaScript вы не получаете такой автоматической безопасности. Фреймворки JavaScript, которые подчеркивают использование class
, делают много «волшебства», чтобы скрыть подобные проблемы и заставить классы вести себя должным образом. Взгляните как-нибудь на исходный код Polymer ElementMixin
, смею вас. Это волшебные уровни арканы JavaScript, и я имею в виду без иронии или сарказма.
Конечно, мы можем исправить некоторые из проблем, обсуждавшихся выше, с помощью Object.freeze
или Object.defineProperties
для большего или меньшего эффекта. Но зачем имитировать форму без функции, игнорируя при этом инструменты, которые предоставляет нам изначально JavaScript, которых мы можем не найти в таких языках, как Java? Вы бы использовали молоток с надписью «отвертка», чтобы закрутить винт, когда в вашем ящике с инструментами была настоящая отвертка, сидящая рядом с ним?
Поиск хороших частей
Разработчики JavaScript часто подчеркивают достоинства языка как в разговорной речи, так и со ссылкой на одноименную книгу. Мы пытаемся избежать ловушек, расставленных его более сомнительным выбором языка, и придерживаемся тех частей, которые позволяют нам писать чистый, читаемый, многократно используемый код с минимальным количеством ошибок.
Существуют разумные аргументы в пользу того, какие части JavaScript подходят, но я надеюсь, что убедил вас, что class
не является одним из них. В противном случае, надеюсь, вы понимаете, что наследование в JavaScript может быть запутанным беспорядком, и этот class
не исправляет это и не избавляет вас от необходимости разбираться в прототипах. Дополнительный кредит, если вы уловили намеки на то, что объектно-ориентированные шаблоны проектирования прекрасно работают без классов или наследования ES6.
Я не говорю вам полностью избегать class
. Иногда вам нужно наследование, и class
предоставляет более чистый синтаксис для этого. В частности, class X extends Y
намного лучше, чем подход старого прототипа. Кроме того, многие популярные интерфейсные фреймворки поощряют его использование, и вам, вероятно, следует избегать написания странного нестандартного кода только из принципа. Мне просто не нравится, куда это идет.
В моих кошмарах целое поколение библиотек JavaScript написано с использованием class
, в расчете на то, что он будет вести себя аналогично другим популярным языкам. Обнаружены целые новые классы ошибок (каламбур). Возрождаются старые, которые легко могли бы остаться на кладбище искаженного JavaScript, если бы мы не попали по неосторожности в ловушку class
. Опытные разработчики JavaScript страдают от этих монстров, потому что то, что популярно, не всегда хорошо.
В конце концов мы все сдаемся и начинаем заново изобретать велосипеды на Rust, Go, Haskell или кто знает что еще, а затем компилировать в Wasm для Интернета, а новые веб-фреймворки и библиотеки множатся до бесконечности.
Это действительно не дает мне спать по ночам.