Недавно, TC39 определили финальную семантику классов в ECMAScript 6 2. Это статья поясняет как работает их реализация. Наиболее значимые из недавних изменений связаны с тем, как реализована система наследования классов.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
toString() {
return super.toString() + ' in ' + this.color;
}
}
let cp = new ColorPoint(25, 8, 'green');
cp.toString(); // '(25, 8) in green'
console.log(cp instanceof ColorPoint); // true
console.log(cp instanceof Point); // true
Классы определяются в ECMAScript 6 (ES6) следующим образом:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
Использовать этот класс можно просто вызвав конструктор функции, как в ES5:
> var p = new Point(25, 8);
> p.toString()
'(25, 8)'
По факту, результатом создания такого класса будет функция:
> typeof Point
'function'
Однако, вы можете вызывать класс только через new
, а не через вызов
функции (Секция 9.2.2 в спецификации):
> Point()
TypeError: Classes can’t be function-called
Объявления функций поднимаются: объявленные внутри общей области видимости, функции сразу же доступны, независимо от того, где они были объявлены. Это означает, что вы можете вызвать функцию, которая будет объявлена позднее.
foo(); // работает, так как `foo` _поднялась_
function foo() {}
В отличие от функций, определения классов не поднимаются. Таким образом, класс существует только после того, как его определение было достигнуто и выполнено. Попытка создания класса до этого момента приведет к «ReferenceError»:
new Foo(); // ReferenceError
class Foo {}
Причина этого ограничения в том, что классы могут быть наследниками. Это поддерживается с помощью выражения «extends», значение которого может быть произвольным. Это выражение должно быть установлено в определенном месте, которое не может быть поднято.
Отсутствие механизма поднятия — это не такое большое ограничение, как вы могли бы подумать. Например, функция, которая определена до определения класса, все еще может ссылаться на этот класс, но вы вынуждены ждать выполнения определения класса до того, как сможете выполнить эту функцию.
function functionThatUsesBar() {
new Bar();
}
functionThatUsesBar(); // ReferenceError
class Bar {}
functionThatUsesBar(); // OK
Так же, как и для функций, есть два способа определить класс: объявление класса и выражение класса.
По аналогии с функциями, идентификатор выражения класса доступен только внутри выражения:
const MyClass = class Me {
getClassName() {
return Me.name;
}
};
let inst = new MyClass();
console.log(inst.getClassName()); // Me
console.log(Me.name); // ReferenceError: Me не определен
Тело класса может содержать только методы, но не свойства. Прототип, имеющий свойства, обычно считается анти-паттерном.
Давайте рассмотрим три вида методов, которые вы часто можете встретить в классах.
class Foo {
constructor(prop) {
this.prop = prop;
}
static staticMethod() {
return 'classy';
}
prototypeMethod() {
return 'prototypical';
}
}
let foo = new Foo(123);
Диаграмма объекта для это определения класса выглядит следующим образом:
(Совет для понимания: [[Prototype]]
— это отношения наследования между
объектами, в то время как prototype
— обычное свойство, значением которого
является объект. Значение свойства prototype
оператор new
использует
как прототип для создаваемых объектов.)
Для начала рассмотрим псевдо-метод «constructor». Этот метод является особенным, так как он определяет функцию, которая представляет собой класс:
> Foo === Foo.prototype.constructor
true
> typeof Foo
'function'
Иногда его называют конструктором класса. Он имеет особенности, которые
обычный конструктор функции не имеет (главным образом, способность
конструктора вызывать конструктор базового класса через super()
, о котором
я расскажу чуть позже).
Далее, статические методы. Статические свойства (или свойства класса)
являются свойствами самого Foo
. Если вы определили метод с помощью static
,
значит вы реализовали метод класса:
> typeof Foo.staticMethod
'function'
> Foo.staticMethod()
'classy'
В третьих, прототипные методы. свойства прототипа Foo
являются и
свойствами Foo.prototype
. Это, как правило, методы, и наследуются
экземплярами Foo
.
> typeof Foo.prototype.prototypeMethod
'function'
> foo.prototypeMethod()
'prototypical'
Синтаксис для геттеров и сеттеров такой же как и в ECMAScript 5 литералы объекта:
class MyClass {
get prop() {
return 'getter';
}
set prop(value) {
console.log('setter: '+value);
}
}
MyClass
используется следующим способом:
> let inst = new MyClass();
> inst.prop = 123;
setter: 123
> inst.prop
'getter'
Вы можете определить имя метода с помощью выражения, если поместите его
в квадратные скобки. Например, следующие определения класса Foo
эквивалентны:
class Foo() {
myMethod() {}
}
class Foo() {
['my'+'Method']() {}
}
const m = 'myMethod';
class Foo() {
[m]() {}
}
Некоторые специальные методы в ECMAScript 6 имеют ключи, которые являются
символами 3.
Механизм вычисляемых имен методов позволяют вам определять такие методы.
Например, если объект имеет метод с ключом Symbol.iterator
, это —
итератор 4. Это означает,
что его содержимое может быть итерировано циклом for-of
или другими механизмами языка.
class IterableClass {
[Symbol.iterator]() {
•••
}
}
Если вы определите метод с «*» в начале, то получите метод генератор 4.
Между прочим, генератор полезен для определения метода, ключом которого
является Symbol.iterator
. Следующий код демонстрирует, как это работает:
class IterableArguments {
constructor(...args) {
this.args = args;
}
* [Symbol.iterator]() {
for (let arg of this.args) {
yield arg;
}
}
}
for (let x of new IterableArguments('hello', 'world')) {
console.log(x);
}
// Вывод:
// hello
// world
Ключевое слово extends
позволяет создать класс-наследник существующего
конструктора (который возможно был определен с помощью класса):
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // (A)
this.color = color;
}
toString() {
return super.toString() + ' in ' + this.color; // (B)
}
}
Этот класс используется как и ожидалось:
> let cp = new ColorPoint(25, 8, 'green');
> cp.toString()
'(25, 8) in green'
> cp instanceof ColorPoint
true
> cp instanceof Point
true
В данном случае мы имеем два вида классов:
Point
— это базовый класс, потому что он не имеет выраженияextends
.ColorPoint
— производный класс.
Есть два способа использовать ключевое слово super
:
- Конструктор класса (псевдо-метод «constructor» в теле класса),
использует его как вызов функции
(_super(•••)_)
, для того, чтобы вызвать базовый конструктор (строка A). - Определения методов (в объектах, заданных через литерал, или классах,
статических или нет), используют это для вызова свойства
(_super.prop_)
, или вызова метода(_super.method(•••)_)
, для ссылки на свойства базового класса (строка B).
Прототип класса наследника является базовым классом в ECMAScript 6:
> Object.getPrototypeOf(ColorPoint) === Point
true
Это означает, что статические свойства наследуются:
class Foo {
static classMethod() {
return 'hello';
}
}
class Bar extends Foo {
}
Bar.classMethod(); // 'hello'
Можно вызывать статические методы базового класса:
class Foo {
static classMethod() {
return 'hello';
}
}
class Bar extends Foo {
static classMethod() {
return super.classMethod() + ', too';
}
}
Bar.classMethod(); // 'hello, too'
В классе-наследнике нужно вызвать super()
до того, как будете обращаться к
свойствам через this
:
class Foo {}
class Bar extends Foo {
constructor(num) {
let tmp = num * 2; // OK
this.num = num; // ReferenceError
super();
this.num = num; // OK
}
}
Пропустив вызов super()
в производном классе, вы получите ошибку:
class Foo {}
class Bar extends Foo {
constructor() {
}
}
let bar = new Bar(); // ReferenceError
Так же, как в ES5, можно переопределить результат конструктора, явно возвращая объект:
class Foo {
constructor() {
return Object.create(null);
}
}
console.log(new Foo() instanceof Foo); // false
Если вы так сделаете, то не имеет значения, инициализирован ли this
или нет.
Другими словами: вы не обязаны вызывать super()
в производном конструкторе,
если переопределите результат таким образом.
Если не указать constructor
для базового класса, тогда используется
следующая конструкция:
constructor() {}
Для дочерних классов, используется конструктор по умолчанию:
constructor(...args) {
super(...args);
}
В ECMAScript 6 наконец-то можно наследоваться от всех встроенных конструкторов (обходные пути в ES5, но здесь накладываются значительные ограничения).
Например, теперь вы можете создавать свои собственные классы исключений (которые будут наследовать такие особенности, как стек вызовов для большинства браузерных движков):
class MyError extends Error {
}
throw new MyError('Something happened!');
Вы также можете наследоваться от Array
, экземпляры которого правильно
работают с length
:
class MyArray extends Array {
constructor(len) {
super(len);
}
}
// Экземпляры класса `MyArray` работают так же как обычный массив:
let myArr = new MyArray(0);
console.log(myArr.length); // 0
myArr[0] = 'foo';
console.log(myArr.length); // 1
Заметьте, что наследование от встроенных конструкторов — это то, что движок должен поддерживать изначально, вы не сможете получить эту функциональность с помощью транспайлеров.
То, что мы до сих пор рассматривали, является основой классов. Если вам интересно узнать подробнее про механизм классов, то вам нужно читать дальше. Давайте начнем с синтаксиса классов. Ниже приводится немного модифицированная верcия синтаксиса, предложенного в Секции A.4 спецификации ECMAScript 6.
ClassDeclaration:
"class" BindingIdentifier ClassTail
ClassExpression:
"class" BindingIdentifier? ClassTail
ClassTail:
ClassHeritage? "{" ClassBody? "}"
ClassHeritage:
"extends" AssignmentExpression
ClassBody:
ClassElement+
ClassElement:
MethodDefinition
"static" MethodDefinition
";"
MethodDefinition:
PropName "(" FormalParams ")" "{" FuncBody "}"
"*" PropName "(" FormalParams ")" "{" GeneratorBody "}"
"get" PropName "(" ")" "{" FuncBody "}"
"set" PropName "(" PropSetParams ")" "{" FuncBody "}"
PropertyName:
LiteralPropertyName
ComputedPropertyName
LiteralPropertyName:
IdentifierName /* foo */
StringLiteral /* "foo" */
NumericLiteral /* 123.45, 0xFF */
ComputedPropertyName:
"[" Expression "]"
Два наблюдения:
-
Значение расширения может быть произвольным выражением. Это значит, что вы можете написать следующий код:
class Foo extends combine(MyMixin, MySuperClass) {}
-
Между методами допускается точка с запятой.
-
Проверки ошибок: имя класса не может быть
eval
илиarguments
; одинаковые имена классов не допускаются; названиеconstructor
может использоваться только для обычных методов, для геттеров, сеттеров и генераторов — не допускается. -
Классы не могут быть вызываемой функцией. Иначе они бросают исключение
TypeException
-
Методы прототипа не могут использоваться как конструкторы:
class C { m() {} } new C.prototype.m(); // TypeError
Определения класса создают (изменяемые) разрешаемые связи. Для данного
класса Foo
:
-
Статические методы
Foo.*
доступны для записи и настройки, но не для перечисления. Доступность для записи позволяет динамически вносить изменения в них. -
Конструктор и объект в свойстве
prototype
имеют неизменяемые ссылки:Foo.prototype
не доступен для записи, перечисления и настройки.Foo.prototype.constructor
не доступен для записи, перечисления и настройки.
-
Прототипные методы
Foo.prototype.*
доступны для записи и настройки, но не для перечисления.
Заметьте, что определения методов в литералах объекта создают перечисляемые свойства.
В ECMAScript 6, наследование классов выглядит следующим образом:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
•••
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
•••
}
let cp = new ColorPoint(25, 8, 'green');
Этот код создает следующие объекты:
Следующий подраздел рассматривает цепочки прототипов (в две колонки),
далее рассматривает как cp
выделяется в памяти и инициализируется.
На диаграмме видно, что есть 2 цепочки прототипов (объекты связаны через
отношения [[Prototype]]
, которые наследуются):
-
Левая колонка: классы (функции). Прототипом производного класса является расширяемый класс. Прототип базового класса является
Function.prototype
, которая также есть прототип функции:> const getProto = Object.getPrototypeOf.bind(Object); > getProto(Point) === Function.prototype true > getProto(function () {}) === Function.prototype true
-
Правая колонка: цепочки прототипов экземпляров. Цель класса — установить эту цепочку прототипов. Цепочка прототипов заканчивается на
Object.prototype
(чей прототип являетсяnull
), который также прототип объектов, созданных через литералы объекта:> const getProto = Object.getPrototypeOf.bind(Object); > getProto(Point.prototype) === Object.prototype true > getProto({}) === Object.prototype true
Из цепочки прототипов в левой колонке следует, что статические свойства наследуются.
Потоки данных между конструкторами классов отличаются от канонического пути наследования в ES5. Под капотом это выглядит примерно так:
// Экземпляр находится тут
function Point(x, y) {
// Выполняется до выполнения кода конструктора:
this = Object.create(new.target.prototype);
this.x = x;
this.y = y;
}
•••
function ColorPoint(x, y, color) {
// Выполняется до выполнения кода конструктора:
this = uninitialized;
this = Reflect.construct(Point, [x, y], new.target); // (A)
// super(x, y);
this.color = color;
}
Object.setPrototypeOf(ColorPoint, Point);
•••
let cp = Reflect.construct( // (B)
ColorPoint, [25, 8, 'green'],
ColorPoint);
// let cp = new ColorPoint(25, 8, 'green');
В ES5 и ES6 экземпляр объекта создается в разных местах:
- В ES6 он создается базовым конструктором, последним в цепочке вызовов конструкторов.
- В ES5 он создается оператором
new
, первым в цепочке вызовов конструкторов.
Предыдущий код использует две новые возможности ES6:
-
new.target
является неявным параметром, который имеют все функции. Это вызов конструктора, гдеthis
является вызовом метода.- Если конструктор напрямую вызывается через
new
, его значение это и есть этот конструктор (строка B). - Если конструктор был вызван через
super()
, его значение этоnew.target
конструктора, который был вызван (строка A). - Вызвав функцию обычным способом, значение будет
undefined
. Это значит, что вы можете использоватьnew.target
чтобы определить, была ли функция функцией вызова или вызовом конструктора (черезnew
). - Внутри стрелочной функции
new.target
ссылается наnew.target
окружающей нестрелочной функции.
- Если конструктор напрямую вызывается через
-
Reflect.construct()
5 позволяет вызвать конструктор при заданииnew.target
в качестве последнего параметра.
Преимуществом этой реализации наследования является то, что это позволяет
писать нормальный код для наследования встроенных конструкторов (такие как
Error
и Array
). Последний раздел объясняет, почему иной подход был
необходим.
this
инициализируется в производных конструкторах, а это значит, что если кthis
обращаются до того как вызвалиsuper()
, то будет брошено исключение.- Вызов
super()
после инициализацииthis
приведет кReferenceError
. Это защита от двойного вызоваsuper()
. - Если конструктор возвращается неявно (без
return
), тогда результат будетthis
. Еслиthis
инициализирован, тогда бросится исключениеReferenceError
. Это защита от невызоваsuper()
. - Если конструктор явно возвращает не объект (включая
undefined
иnull
), результатом будетthis
(это поведение оставляет совместимость с ES5 и ранее). Еслиthis
инициализирован, тогда бросится исключениеTypeError
. - Если конструктор явно возвращает объект, тогда он и будет результатом.
Тогда не имеет значение инициализирован
this
или нет.
Давайте рассмотрим, как выражение extends
влияет на работу класса
(Секция. 14.5.14 спецификации).
Значение extends
должно быть «конструктивно» (вызываться через new
)
хотя null
тоже поддерживается.
class C {
}
- Тип конструктора: базовый
- Прототип
C
:Function.prototype
(как обычная функция) - Прототип
C.prototype
:Object.prototype
(который также прототип объекта, созданный через литералы объекта)
class C extends B {
}
- Тип конструктора: наследник
- Прототип
C
:B
- Прототип
C.prototype
:B.prototype
class C extends Object {
}
- Тип конструктора: наследник
- Прототип
C
:Object
- Прототип
C.prototype
:Object.prototype
Обратите внимание на следующее различие с первым случаем:
Если нет extends
, класс является базовым и выделяет в памяти экземпляры.
Если класс расширяет Object
, это производный класс объекта и выделяет
экземпляры. Полученные экземпляры (в том числе их цепочки прототипов)
одинаковы, только получены разными способами.
class C extends null {
}
- Тип конструктора: наследник
- Прототип
C
:Function.prototype
- Прототип
C.prototype
:null
Такой класс бесполезный: вызов через new
приведет к ошибке, потому
что конструктор по умолчанию сделает вызов базового конструктора и
Function.prototype
(базовый конструктор) не может быть конструктором вызова.
Единственный способ избежать ошибки — это добавить конструктор, который
возвратит объект.
В ECMAScript 5, большинство встроенных конструкторов не могут быть унаследованы (несколько обходных путей).
Чтобы понять почему, давайте используем канонический ES5 шаблон наследования
Array
. Как мы вскоре узнаем, это не работает.
function MyArray(len) {
Array.call(this, len); // (A)
}
MyArray.prototype = Object.create(Array.prototype);
К сожалению, если мы создадим MyArray
, мы поймем, что он не работает
должным образом: экземпляр свойства length
не изменится в ответ на наше
добавление элементов в массив:
> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0
Есть два препятствия, которые мешают myArr
быть правильным массивом.
Первое препятствие: инициализация. this
, который вы передаете в
конструктор Array
(в строке A) полностью игнорируется. Это значит, что вы
не можете использовать Array
чтобы настроить экземпляр, который создал
MyArray
.
> var a = [];
> var b = Array.call(a, 3);
> a !== b // a игнорируется, b — новый объект
true
> b.length // определилось верно
3
> a.length // неизменно
0
Второе препятствие: выделение памяти. Экземпляры объектов, созданные через
Array
являются экзотичными (термин, используемый в спецификации ECMAScript
для объектов, которые имеют особенности, которые нормальные объекты не имеют):
их свойства length
отслеживают и влияют на управление элементами массива.
В общем, экзотические объекты могут быть созданы с нуля, но вы не можете
преобразовать существующий обычный объект в экзотический. К сожалению,
это то, что делает Array
, когда вызывается на строке A:
Он должен был превратить обычный объект, созданный из MyArray
в
экзотический объект массива.
В ECMAScript 6, наследование Array
выглядит следующим образом:
class MyArray extends Array {
constructor(len) {
super(len);
}
}
Это работает (но это не то, что ES6 транспайлеры могут поддерживать, это зависит от того, поддерживает ли движок JavaScript это изначально):
> let myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1
Сейчас рассмотрим, как подход к наследованию в ES6, позволяет обойти препятствия:
-
Выделение памяти происходит в базовом конструкторе. Это значит, что
Array
может выделить в памяти экзотический объект. В то время как большая часть нового подхода связана с тем, как полученные конструкторы ведут себя, этот шаг требует, чтобы базовый конструктор понималnew.target
и делалnew.target.prototype
прототипом выделенного экземпляра. -
Инициализация также происходит в базовом конструкторе, конструктор класса-наследника получает инициализированный объект и работает с ним, вместо того, чтобы создавать собственный объект и отдавать его конструктору базового класса, чтобы тот его создавал.
Следующий ES6 код вызывает базовый метод со строкой «B» в качестве аргумента.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() { // (A)
return '(' + this.x + ', ' + this.y + ')';
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
toString() {
return super.toString() // (B)
+ ' in ' + this.color;
}
}
let cp = new ColorPoint(25, 8, 'green');
console.log(cp.toString()); // (25, 8) in green
Чтобы понять как работает базовые вызовы, давайте взглянем на диаграмму
объекта cp
:
ColorPoint.prototype.toString
делает вызов метода базового класса (строка B)
(начиная со строки A), который переопределен. Давайте вызовем объект, в
котором хранится этот метод, домашний объект. Например,
ColorPoint.prototype
— это домашний объект для
ColorPoint.prototype.toString()
.
Вызов базового класса на строке B состоит из трёх этапов:
-
Начинается поиск в прототипе домашнего объекта текущего метода.
-
Поиск метода с названием
toString
. Этот метод должен быть найден в объекте, где начался поиск, или позже по цепочке прототипов. -
Вызвать этот метод с текущим
this
. Причина почему это происходит: метод вызываемый как базовый должен иметь возможность доступа к тем же свойствам экземпляра (в нашем примере, к свойствамcp
).
Обратите внимание, что даже если вы только получаете или устанавливаете
свойство (без вызова метода), вам все равно придется учитывать this
в шаге 3, потому что свойство может быть реализовано через геттер или сеттер.
Давайте реализуем эти шаги в трех различных, но эквивалентных способах:
// Вариант 1: вызов супер-метода в ES5
var result = Point.prototype.toString.call(this) // шаги 1,2,3
// Вариант 2: ES5, после рефакторинга
var superObject = Point.prototype; // шаг 1
var superMethod = superObject.toString; // шаг 2
var result = superMethod.call(this) // шаг 3
// Вариант 3: ES6
var homeObject = ColorPoint.prototype;
var superObject = Object.getPrototypeOf(homeObject); // шаг 1
var superMethod = superObject.toString; // шаг 2
var result = superMethod.call(this) // шаг 3
Способ 3 показывает, как в ECMAScript 6 обрабатываются вызовы базового класса. Этот подход поддерживается двумя внутренними привязками, которые имеют состояния функций (состояние обеспечивает хранилище, так называемые привязки, для переменных окружения):
[[thisValue]]
: Эта внутренняя привязка также есть и в ECMAScript 5 и хранит значение переменнойthis
.[[HomeObject]]
: Относится к домашнему объекту состояния функции. Заполняется через внутреннее свойство[[HomeObject]]
которое имеют все функции, использовавшиеsuper
. И привязка и свойство являются новыми в ECMAScript 6.
Определение метода в литерале класса, который использует super
, теперь имеет
особенность: это значение все еще функция, но имеет внутреннее свойство
[[HomeObject]]
. Это свойство устанавливается определением метода и не может
быть изменено в JavaScript. Таким образом, вы не можете перенести этот метод
в другой объект.
Использование super
не допускается для обращения к свойству в определениях
функций, выражениях функций и генераторах.
Ссылаться на базовые свойства удобно, когда используются прототипы цепочек, поэтому вы можете использовать их в определениях методов, внутри литералов объектов и литералах классов (класс при этом может быть унаследованным или нет, метод может быть статическим или нет).
Код JavaScript в этом разделе достаточно упрощен по сравнению с тем, как спецификация описывает вызовы конструктора и вызовы базового конструктора. Это может быть интересно для вас, если вы предпочитаете объяснения кода человеческим языком. Прежде чем мы углубимся в функциональность, мы должны понимать несколько других механизмов.
Спецификация описывает внутренние переменные и свойства в двойных скобках
([[Foo]]
). В коде я использую двойные подчеркивания вместо этого
(__Foo__
).
Внутренние переменные используемые в коде:
[[NewTarget]]
: Операнд оператораnew
, который запускает текущий вызов конструктора (передается, если [[Construct]] вызывается рекурсивно черезsuper()
).[[thisValue]]
: Хранит значениеthis
.[[FunctionObject]]
: Ссылается на функцию, которая в настоящее время выполняется.
Внутренние свойства используемые в коде:
[[Construct]]
: Все функции конструктора (включая также созданные классом) имеют этот собственный (не наследуемый) метод. Он реализует вызов конструктора и вызывается черезnew
.[[ConstructorKind]]
: Свойство функций конструктора значение которого либо «base» либо «derived».
Состояния обеспечивают хранилище для переменных, одно состояние на окружение. Состояния управляются как стек. Состояние на вершине стека считается активным. Следующий код демонстрирует, как состояния обрабатываются.
/**
* Окружение функций — особенное, в нем на несколько
* внутренних переменных больше, чем в других окружениях.
* (Окружение тут показывается)
*/
class FunctionEnvironment extends Environment {
constructor(Func) {
// [[FunctionObject]] это специфическая для функций
// внутренняя переменная
this.__FunctionObject__ = Func;
}
}
/**
* Добавляем окружение в стек
*/
function PushEnvironment(env) { ••• }
/**
* Удаляем самое верхнее окружение из стека
*/
function PopEnvironment() { ••• }
/**
* Находим самое верхнее окружение в стеке
*/
function GetThisEnvironment() { ••• }
Давайте начнем с основ (ES6 спецификация, Секция. 9.2.3), где вызовы конструктора обрабатываются для функций:
/**
* У всех функций с конструктором есть этот метод,
* он вызывается оператором `new`
*/
AnyFunction.__Construct__ = function (args, newTarget) {
let Constr = this;
let kind = Constr.__ConstructorKind__;
let env = new FunctionEnvironment(Constr);
env.__NewTarget__ = newTarget;
if (kind === 'base') {
env.__thisValue__ = Object.create(newTarget.prototype);
} else {
// Пока «this» не инициализировано, попытка установить или считать её
// приведет к выбрасыванию «ReferenceError»
env.__thisValue__ = uninitialized;
}
PushEnvironment(env);
let result = Constr(...args);
PopEnvironment();
// Давайте представим, что есть способ сказать, был ли «result»
// возвращен в явном виде или нет
if (WasExplicitlyReturned(result)) {
if (isObject(result)) {
return result;
}
// Явно возвращаем примитив
if (kind === 'base') {
// Конструкторы должны обладать обратной совместимостью
return env.__thisValue__; // всегда инициализирована!
}
throw new TypeError();
}
// Implicit return
if (env.__thisValue__ === uninitialized) {
throw new ReferenceError();
}
return env.__thisValue__;
}
Вызов базового конструктора обрабатывается следующим образом (ES6 спецификация, Секция. 12.3.5.1).
/**
* Обработка вызовов супер-конструктора
*/
function super(...args) {
let env = GetThisEnvironment();
let newTarget = env.__NewTarget__;
let activeFunc = env.__FunctionObject__;
let superConstructor = Object.getPrototypeOf(activeFunc);
env.__thisValue__ = superConstructor
.__Construct__(args, newTarget);
}
Еще один механизм встроенных конструкторов был расширен в ECMAScript 6:
если метод, такой как Array.prototype.map()
, возвращает экземпляр, то какой
конструктор следует использовать для создания этого экземпляра? По умолчанию,
используется тот же конструктор, который создал this
, но некоторые
наследники могут оставаться прямым экземпляром Array
. ES6 позволяет
классам-наследникам переопределить значение по умолчанию с помощью так
называемого шаблона разновидности:
- При создании нового экземпляра
Array
, методы, такие какmap()
используют конструктор, хранящийся вthis.constructor[Symbol.species]
. - Если конструктор наследника
Array
ничего не делает, он наследуетArray[Symbol.species]
. Это свойство является геттером, который возвращаетthis
.
Вы можете изменить настройки по умолчанию, с помощью статического геттера (строка A):
class MyArray1 extends Array {
}
let result1 = new MyArray1().map(x => x);
console.log(result1 instanceof MyArray1); // true
class MyArray2 extends Array {
static get [Symbol.species]() { // (A)
return Array;
}
}
let result2 = new MyArray2().map(x => x);
console.log(result2 instanceof MyArray2); // false
Альтернативой является использование Object.defineProperty()
(вы не можете
использовать присвоение, т.к. вызываете сеттер, который не существует):
Object.defineProperty(
MyArray2, Symbol.species, {
value: Array
});
Следующие геттеры возвращают this
, это означает, что такие методы
как Array.prototype.map()
, используют конструктор, который создал текущий
экземпляр их результатов.
Array.get [Symbol.species]()
ArrayBuffer.get [Symbol.species]()
Map.get [Symbol.species]()
Promise.get [Symbol.species]()
RegExp.get [Symbol.species]()
Set.get [Symbol.species]()
%TypedArray%.get [Symbol.species]()
Существует интересная тенденция в ECMAScript 6: ранее единственный вид функции был на трех ролях: функция, метод и конструктор. В ES6, есть еще специализация:
-
Стрелочные функции специализируются на функциях обратного вызова, где их использует
this
в окружающем методе, или конструктор как преимущество. Безthis
они не имеют смысла как методы и они бросают исключение, если вызываются черезnew
. -
Определения метода позволяют использовать
super
, определив свойство[[HomeObject]]
. Функции, которые они производят, не могут быть вызываемыми конструкторами. -
Определения классов являются единственным способом создания производных конструкторов (включающий в ES6 наследование, которое работает на встроенных конструкторах). Определения классов создают функции, которые могут быть только вызываемыми конструкторами.
Дизайн классов был «максимально минимальным». Обсуждались несколько расширяющих функциональностей, но, в конечном итоге, от них отказались, чтобы получить вариант, который был принят единогласно TC39.
Будущие версии ECMAScript теперь могут расширять этот минималистичный дизайн — классы обеспечивают основу для такой функциональности как признаки (или миксины), значения объектов (где различные объекты равны, если они имеют одинаковое содержание) и константные классы (которые создают неизменяемые экземпляры).
Классы являются спорными в сообществе JavaScript. С одной стороны, люди которые пришли из языков, основанных на классах — счастливы, что им больше не придется иметь дело с необычными механизмами наследования в JavaScript. С другой стороны, существует множество JavaScript программистов, которые утверждают, что в JavaScript прототипное наследование проще, чем наследование с помощью конструкторов 6.
Классы ES6 обеспечивают несколько очевидных преимуществ:
-
Они обратно совместимы с большей частью текущего кода.
-
По сравнению с конструкторами и наследованием конструкторов, классы реализуют это проще для начинающих.
-
Наследование поддерживается в языке.
-
Встроенные конструкторы наследуемы.
-
Теперь не нужны библиотеки, реализующие наследование; код станет более переносимым между фреймворками.
-
Они обеспечивают основу для расширенной функциональности в будущем (миксины и т.п).
-
Они помогают инструментам, которые статически анализируют код (IDE, проверки типов, проверки стилей, и т.д.).
Я закончил эту статью с классами и я рад, что они есть в ES6. Я бы предпочел, чтобы они были прототипными (на основе конструктора объектов 6, а не конструктора функций), но я также понимаю, что обратная совместимость является важной.
Обратите внимание на №1 — он играл роль значимого источника информации при написании этой статьи.
1. Реформа создания экземпляров: в последний раз, слайды Аллена Вирфс-Брока (Allen Wirfs-Brock)
2. Анализ ES6: Обновление до новой версии JavaScript, книга Акселя Роушмайера (Axel Rauschmayer)
4. Итераторы и генераторы в ECMAScript 6
5. Метапрограммирование с прокси в ECMAScript 6
6. Прототипы и классы – введение в наследование на JavaScript