[Перевод] JavaScript-классы — это не просто «синтаксический сахар»
Стандартная защита, сравнимая со строгим режимом
Использование директивы
«use strict»
в ES5-коде не помешает вызвать конструктор без ключевого слова new
: // ES5
function Test() { "use strict"; }
Test.call({}); // всё нормально
// ES6+
class Test {}
Test.call({}); // будет выдано исключение
Причина этого заключается в том, что в более современном механизме, в классах, есть концепция
new.target
. Её, без использования транспиляторов, имитирующих подобное поведение, средствами ES5 реализовать невозможно. А транспилятор при этом, кроме того, должен выполнять проверку instanceof
, что приводит к появлению более медленного кода, перегруженного служебными конструкциями.Расширение встроенных типов
Несмотря на то, что я сам, с 2004 года, пытался создавать подклассы
Array
и других подобных встроенных типов, в ES5, на самом деле, нет адекватного способа этого добиться.// Эпический фейл ES5
function List() { "use strict"; }
List.prototype = Object.create(Array.prototype);
var list = new List;
list.push(1, 2, 3);
JSON.stringify(list);
// {"0":1,"1":2,"2":3,"length":3}
list.slice(0) instanceof List; // false
Предлагаю не обращать внимания на тот факт, что я даже не использую в конструкторе
Array.apply(this, arguments)
, так как это тоже не приведёт к желаемому эффекту. Попытка расширения возможностей стандартного ES5-типа Array
выйдет неуклюжей вне зависимости от того, как её воспринимать. То же самое справедливо и в отношении любых других встроенных в JS сущностей, вроде String
и прочего подобного.// Вторая версия эпического фейла ES5
function Text(value) {
"use strict";
String.call(this, value);
}
new Text("does this work?");
// нет, не работает, и никак работать не будет
Я знаю, о чём вы думаете: «Да кому вообще может понадобиться расширять
String
, дружище?». И это — правильный вопрос. Вам, вполне возможно, это и не понадобится. Но смысл тут не в том, надо это кому-то или нет, а в том, что сделать это средствами ES5 просто невозможно. Механизмам прототипного наследования это недоступно, а вот JS-классы способны на такие вещи.Известный символ Symbol.species
Если вас когда-нибудь интересовал вопрос о том, как так получается, что
list.slice(0)
не является экземпляром List
, то знайте, что ответом на него является известный символ Symbol.species.// ES6+
class List extends Array {}
(new List).slice(0) instanceof List; // true
[].slice.call(new List) instanceof List; // true
И, соответственно, если только не следить внимательно за тем, чтобы каждый метод возвращал бы экземпляр исходного объекта, чего все могут ожидать от всех методов
Array
, окажется, что ES5 просто не создан для работы с тем, для чего используется Symbol.species
. В ES5 в этом плане всё устроено очень неудобно и ненадёжно.Вывод: JS-классы гораздо лучше показывают себя в деле оправдания ожиданий, чем механизмы ES5.
Ключевое слово super
Если вы когда-нибудь задумывались о том, почему в ES5
Array.apply(this, arguments)
не работает в конструкторе, то знайте, что это так по двум причинам: - Встроенный тип
Array
отвечает за создание новых массивов, но при этом он совершенно не заботится, как и другие встроенные классы, о контексте. - JS-классы позволяют расширять экземпляры этих классов, а в ES5 это, без транспиляторов, невозможно. Да и с применением транспилятора, когда речь идёт о расширении встроенных типов, код оказывается весьма неприглядным.
// ES5
function Button() {
return document.createElement('button');
}
function MyButton(value) {
Button.call(this);
this.textContent = value;
}
Object.setPrototypeOf(MyButton, Button);
Object.setPrototypeOf(MyButton.prototype, Button.prototype);
Как думаете, что случится после вызова
new MyButton(«content»)
? Вот пара вариантов ответа: - Будет возвращена кнопка с текстом
value
. - Будет возвращён экземпляр
MyButton
со свойствомtextContent
.
Правильным будет второй ответ. И, если только мы не станем описывать все подклассы так, как показано ниже, наши ожидания не оправдаются:
function MySubClass() {
var self = Class.apply(this, arguments) || this;
// сделать что угодно с self
return self;
}
Но что, всё же, не так с этим подходом?
- Если суперкласс возвращает что-то другое — мы лишаемся наследования.
- Если суперкласс представлен встроенным классом — то у нас, вместо этого, может иметься переменная
self
, указывающая на примитивное значение.
Вот другой вариант, в котором исправлена первая проблема, но не вторая:
function MySubClass() {
var self = Class.apply(this, arguments);
if (self == null)
self = this;
else if (!(self instanceof MySubClass))
Object.setPrototypeOf(self, MySubClass.prototype);
// сделать что-нибудь с self
return self;
}
А теперь давайте взглянем на то, как это реализовано в JS-классах:
// ES6+
class Button {
constructor() {
return document.createElement('button');
}
}
class MyButton extends Button {
constructor(value) {
super();
this.textContent = value;
}
}
document.body.appendChild(new MyButton("hello"));
Если снова задаться вопросом о том, стоит ли писать подобный код, ответ на него будет зависеть от обстоятельств.
Даёт ли это нам право говорить о том, что JS-классы гораздо лучше и мощнее того, что есть в ES5? Да, даёт!
Методы
То, о чём я тут хочу поговорить, не относится исключительно к JS-классам, но это — то, о чём довольно много разработчиков может и не знать: методы нельзя использовать в роли конструкторов. Это относится и к методам, описываемым при создании объектов с использованием литеральной нотации.
// ES6+
class Test {
method() {}
}
new Test.prototype.method;
// TypeError: Test.prototype.method is not a constructor
В ES5 в качестве конструктора может быть использована любая функция. Не допустить использования функции в качестве конструктора можно только если каждый раз проверять
this
: // ES5
function Test() {}
Test.prototype.method = function () {
if (!(this instanceof Test))
throw new TypeError("not a constructor");
};
Может ли тот, кому придётся писать подобный код, считать JS-классы всего лишь «синтаксическим сахаром»?
Перечислимость
В JS-классах и статические и нестатические методы не являются перечислимыми. Да, этого можно добиться и в ES5, но для этого придётся воспользоваться большим объёмом медленного и неудобного вспомогательного кода.
Стрелочные функции
При объявлении JS-классов можно пользоваться стрелочными функциями. То же самое справедливо и для ES5-конструкторов, но тут, чтобы это воспроизвести, как и в прочих подобных случаях, понадобится некоторый объём вспомогательного кода:
// ES5
function WithArrows() {
Object.defineProperties(this, {
method1: {
configurable: true,
writable: true,
value: () => "arrow 1"
}
});
}
// ES6+
class WithArrows {
method1 = () => "arrow 1";
}
// (new WithArrows).method1();
Приватные поля класса
JS-классы поддерживают приватные свойства, а, с недавних пор, и приватные методы.
// ES6+
class WithPrivates {
#value;
#method(value) {
this.#value = value;
}
constructor(value) {
this.#method(value);
}
}
Можно ли сделать то же самое в ES5? На самом деле — нет, если только не прибегнуть к транспилятору и к
WeakMap
для хранения экземпляров класса с приватными полями, которые ни при каких условиях не должны выйти за пределы экземпляра. А если, всё же, случится их «утечка», мы никак не сможем от неё защититься.Итоги
В ES5, используя старые механизмы прототипного наследования, безусловно, можно воспроизвести многое из того, что доступно в более современных версиях языка. Но не существуют стандартных реализаций подобного, которые были бы так же быстры или безопасны, как использование соответствующих синтаксических конструкций, имеющих отношение к классам. И, кроме того, некоторые вещи просто невозможно воспроизвести средствами прототипного наследования.
Предлагаю прекратить говорить о том, что JS-классы — это всего лишь «синтаксический сахар». Тот, кто так говорит, упускает массу деталей, на которые просто нельзя закрывать глаза. Нет, на них можно и не обращать внимания, но только если будет решено отказаться от использования современных механизмов JS, основанных на классах. А ведь эти механизмы способны вывести ООП в JS на такой уровень, до которого никто не добирался за последние 20 «прототипных» лет.
Но если предположить, что некто сознательно отказывается от новых возможностей JS, тогда правильнее будет говорить о классах примерно так: «Мне не нравятся JS-классы, так как я думаю, что меня вполне устраивает прототипное наследование».
Сказать так будет честнее и правильнее, чем называть классы простым «синтаксическим сахаром».
Спасибо всем, кто помогает донести идеи, изложенные в этом материале, до всех тех разработчиков, которые не обращают внимания на различия между наследованием, которое основано на классах, и прототипным наследованием.
Как по-вашему, классы в JavaScript — это, всё же, «сахар», или нет?