Хабрахабр

[Перевод] Как работает JS: классы и наследование, транспиляция в Babel и TypeScript

[Советуем почитать] Предыдущие 14 частей цикла

В наши дни использование классов являются одним из самых популярных способов структурирования программных проектов. Этот подход к программированию применяется и в JavaScript. Сегодня мы публикуем перевод 15 части серии материалов, посвящённых экосистеме JS. В этой статье речь пойдёт о различных подходах к реализации классов в JavaScript, о механизмах наследования и о транспиляции. Начнём мы с рассказа о том, как работают прототипы и с анализа различных способов имитации наследования, основанного на классах, в популярных библиотеках. Далее мы поговорим о том, как, благодаря транспиляции, можно писать JS-программы, использующие возможности, которые либо отсутствуют в языке, либо, хотя и существуют в виде новых стандартов или предложений, находящихся на разных стадиях согласования, пока не реализованы в JS-движках. В частности, речь пойдёт о Babel и TypeScript и о классах ECMAScript 2015. После этого мы разберём несколько примеров, демонстрирующих особенности внутренней реализации классов в JS-движке V8.

image

Обзор

В JavaScript мы постоянно сталкиваемся с объектами, даже тогда, когда, казалось бы, работаем с примитивными типами данных. Создадим, например, строковый литерал:

const name = "SessionStack";

После этого мы сразу же можем, обращаясь к name, вызывать различные методы объекта типа String, к которому созданный нами строковой литерал будет автоматически преобразован.

console.log(name.repeat(2)); // SessionStackSessionStack
console.log(name.toLowerCase()); // sessionstack

В отличие от других языков, в JavaScript, создав переменную, содержащую, например, строку или число, мы можем, не проводя явного преобразования, работать с этой переменной так, как будто она изначально была создана с использованием ключевого слова new и соответствующего конструктора. Как результат, за счёт автоматического создания объектов, инкапсулирующих примитивные значения, с такими значениями можно работать так, как будто они являются объектами, в частности — обращаться к их методам и свойствам.

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

let names = ["SessionStack"]; let names = { "0": "SessionStack", "length": 1
}

В результате доступ к элементам массива и к свойствам объекта выполняется с одинаковой скоростью. Автор этой статьи говорит, что выяснил это в ходе решения одной сложной задачи. А именно, однажды ему нужно было провести серьёзную оптимизацию весьма важного фрагмента кода в проекте. После того, как он перепробовал множество простых подходов, он решил заменить все объекты, используемые в этом коде, на массивы. В теории доступ к элементам массива быстрее, чем работа с ключами хэш-таблицы. К его удивлению на производительности эта замена никак не отразилась, так как и работа с массивами, и работа с объектами в JavaScript сводится к взаимодействию с ключами хэш-таблицы, что, и в том и в другом случае, требует одинаковых затрат времени.

Имитация классов с помощью прототипов

Когда мы размышляем об объектах, то первое, что приходит в голову — это классы. Пожалуй, каждый из тех, кто занимается сегодня программированием, создавал приложения, структура которых основана на классах и на взаимоотношениях между ними. Хотя объекты в JavaScript можно найти буквально повсюду, в языке не используется традиционная система наследования, основанная на классах. В JavaScript для решения схожих задач используются прототипы.

Объект и его прототип

Когда вы пытаетесь обратиться к свойству или методу объекта, поиск того, что вам нужно, сначала выполняется в самом объекте. В JavaScript каждый объект связан с ещё одним объектом — со своим прототипом. Если поиск не увенчался успехом, он продолжается в прототипе объекта.

Рассмотрим простой пример, в котором описана функция-конструктор для базового класса Component:

function Component(content) { this.content = content;
} Component.prototype.render = function() { console.log(this.content);
}

Здесь мы назначаем функцию render() методом прототипа, так как нам надо, чтобы каждый экземпляр класса Component мог бы этим методом воспользоваться. Когда, в любом экземпляре Component, вызывают метод render, его поиск начинается в самом объекте, для которого он вызван. Затем поиск продолжается в прототипе, где система и находит этот метод.

Прототип и два экземпляра класса Component

Создадим конструктор нового класса — InputField: Попытаемся теперь расширить класс Component.

function InputField(value) " />`;
}

Если нам надо, чтобы класс InputField расширял бы функционал класса Component и имел бы возможность вызывать его метод render, нам нужно изменить его прототип. Когда метод вызывается для экземпляра дочернего класса, искать его в пустом прототипе бессмысленно. Нам нужно, чтобы, в ходе поиска этого метода, он был бы обнаружен в классе Component. Поэтому нам нужно сделать следующее:

InputField.prototype = Object.create(new Component());

Теперь, при работе с экземпляром класса InputField и вызове метода класса Component, этот метод будет найден в прототипе класса Component. Для реализации системы наследования нужно подключить прототип InputField к экземпляру класса Component. Многие библиотеки для решения этой задачи используют метод Object.setPrototypeOf().

Расширение возможностей класса Component с помощью класса InputField

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

  • Сделать прототип класса-потомка экземпляром родительского класса.
  • Вызвать, в конструкторе класса-потомка, конструктор родительского класса для обеспечения правильной инициализации родительского класса.
  • Предусмотреть механизм вызова методов родительского класса в ситуациях, когда класс-потомок переопределяет родительский метод, но возникает необходимость в вызове исходной реализации этого метода из родительского класса.

Как видите, если JS-разработчик хочет пользоваться возможностями наследования, основанного на классах, ему постоянно придётся выполнять вышеописанные действия. В том случае, если нужно создавать множество классов, всё это можно оформить в виде функций, подходящих для многократного использования.

В частности, с помощью различных библиотек. На самом деле, задача организации наследования, основанного на классах, изначально решалась в практике JS-разработки именно так. Именно поэтому в ECMAScript 2015 были представлены новые синтаксические конструкции, направленные на поддержку работы с классами и на реализацию соответствующих механизмов наследования. Подобные решения стали весьма популярными, что недвусмысленно указывало на то, что в JavaScript чего-то явно не хватает.

Транспиляция классов

После того, как были предложены новые возможности ECMAScript 2015 (ES6), сообщество JS-разработчиков захотело воспользоваться ими как можно скорее, не дожидаясь завершения длительного процесса добавления поддержки этих возможностей в JS-движки и браузеры. В решении подобных задач хорошо показывает себя транспиляция. В данном случае транспиляция сводится к трансформации JS-кода, написанного по правилам ES6, к виду, понятному браузерам, которые пока возможности ES6 не поддерживают. В результате, например, становится возможным объявление классов и реализация механизмов наследования, основанных на классах, по правилам ES6 и преобразование этих конструкций в код, работающий в любых браузерах. Схематично этот процесс, на примере обработки транспилятором стрелочной функции (ещё одной новой возможности языка, для обеспечения поддержки которой нужно время), можно представить так, как показано на рисунке ниже.

Транспиляция

Посмотрим как он работает, выполнив транспиляцию кода объявления класса Component, о котором мы говорили выше. Одним из наиболее популярных транспиляторов для JavaScript является Babel.js. Итак, вот ES6-код:

class Component { constructor(content) { this.content = content; } render() { console.log(this.content) }
} const component = new Component('SessionStack');
component.render();

А вот во что превращается этот код после транспиляции:

var Component = function () { function Component(content) { _classCallCheck(this, Component); this.content = content; } _createClass(Component, [{ key: 'render', value: function render() { console.log(this.content); } }]); return Component;
}();

Как видите, на выходе транспилятора получился ECMAScript 5-код, который можно запустить в любом окружении. Кроме того, тут добавлены вызовы некоторых функций, являющихся частью стандартной библиотеки Babel.

Первая функция, _classCallCheck(), предназначена для того, чтобы функция-конструктор не вызывалась как обычная функция. Речь идёт о функциях _classCallCheck() и _createClass(), включённых в транспилированный код. В коде проверяется, указывает ли ключевое слово this на подобный экземпляр. Для этого тут выполняется проверка того, является ли контекст, в котором вызывается функция, контекстом экземпляра класса Component. Вторая функция, _createClass(), занимается созданием свойств объекта, которые передаются ей как массив объектов, содержащих ключи и их значения.

Вот как взаимоотношения классов оформляются в ES6: Для того чтобы разобраться с тем, как работает наследование, проанализируем класс InputField, являющийся наследником класса Component.

class InputField extends Component { constructor(value) { const content = `<input type="text" value="${value}" />`; super(content); }
}

Вот — результат транспиляции этого кода с помощью Babel:

var InputField = function (_Component) { _inherits(InputField, _Component); function InputField(value) { _classCallCheck(this, InputField); var content = '<input type="text" value="' + value + '" />'; return _possibleConstructorReturn(this, (InputField.__proto__ || Object.getPrototypeOf(InputField)).call(this, content)); } return InputField;
}(Component);

В этом примере логика механизмов наследования инкапсулирована в вызове функции _inherits(). Она выполняет те же действия, которые мы описывали выше, связанные, в частности, с записью в прототип класса-потомка экземпляра родительского класса.

Для начала осуществляется парсинг ES6-кода и его преобразование в промежуточное представление, называемое абстрактным синтаксическим деревом. Для того чтобы транспилировать код, Babel выполняет несколько его трансформаций. В итоге же это дерево преобразуется в JS-код. Затем полученное абстрактное синтаксическое дерево преобразуется в другое дерево, каждый узел которого трансформируется в свой ES5-эквивалент.

Абстрактное синтаксическое дерево в Babel

Абстрактное синтаксическое дерево содержит узлы, у каждого из которых есть лишь один родительский узел. В Babel имеется базовый тип для узлов. Он содержит информацию о том, чем является узел, и о том, где его можно обнаружить в коде. Существуют различные типы узлов, например, узлы для представления литералов, таких, как строки, числа, значения null, и так далее. Кроме того, есть узлы для представления выражений, используемых для управления потоком выполнения программ (конструкция if), и узлы для циклов (for, while). Есть тут и особый тип узла для представления классов. Он является потомком базового класса Node. Он расширяет этот класс, добавляя поля для хранения ссылок на базовый класс и на тело класса в виде отдельного узла.
Преобразуем следующий фрагмент кода в абстрактное синтаксическое дерево:

class Component { constructor(content) { this.content = content; } render() { console.log(this.content) }
}

Вот как будет выглядеть его схематичное представление.

Абстрактное синтаксическое дерево

В ходе процесса преобразования сначала находят узел, который расположен дальше всего от корневого узла, после чего данный узел преобразуется в код с использованием сниппетов, генерируемых для каждого узла. После создания дерева каждый его узел трансформируется в соответствующий ему узел ES5, после чего это новое дерево преобразуется в код, соответствующий стандарту ECMAScript 5. Эта методика называется поиском в глубину. После этого процесс повторяется.

В вышеприведённом примере сначала будет сгенерирован код для двух узлов MethodDefinition, после чего будет создан код для узла ClassBody, и, наконец, код для узла ClassDeclaration.

Транспиляция в TypeScript

Ещё одной популярной системой, в которой используется транспиляция, является TypeScript. Это язык программирования, код на котором трансформируется в код на ECMAScript 5, понятный любому JS-движку. Он предлагает новый синтаксис для написания JS-приложений. Вот как реализовать класс Component на TypeScript:

class Component { content: string; constructor(content: string) { this.content = content; } render() { console.log(this.content) }
}

Вот абстрактное синтаксическое дерево для этого кода.

Абстрактное синтаксическое дерево

TypeScript поддерживает наследование.

class InputField extends Component { constructor(value: string) { const content = `<input type="text" value="${value}" />`; super(content); }
}

Вот что получится в результате транспиляции этого кода:

var InputField = /** @class */ (function (_super) { __extends(InputField, _super); function InputField(value) { var _this = this; var content = "<input type=\"text\" value=\"" + value + "\" />"; _this = _super.call(this, content) || this; return _this; } return InputField;
}(Component));

Как видите, перед нами опять ES5-код, в котором, помимо стандартных конструкций, имеются вызовы некоторых функций из библиотеки TypeScript. Возможности функции __extends() аналогичны тем, о которых мы говорили в самом начале этого материала.

Это способствовало добавлению поддержки этих механизмов в браузеры. Благодаря широкому распространению Babel и TypeScript, механизмы для объявления классов и организации наследования на основе классов превратились в стандартные средства структурирования JS-приложений.

Поддержка классов браузерами

Поддержка классов появилась в браузере Chrome в 2014 году. Это позволяет браузеру работать с объявлениями классов без применения транспиляции или каких-либо вспомогательных библиотек.

Работа с классами в JS-консоли Chrome

Эти конструкции преобразуются в те же базовые структуры, которые уже поддерживаются языком. На самом деле, поддержка этих механизмов браузерами — не более чем «синтаксический сахар». В результате, даже если пользоваться новым синтаксисом, на более низком уровне всё будет выглядеть как создание конструкторов и манипуляции с прототипами объектов:

Поддержка классов — это «синтаксический сахар»

Поддержка классов в V8

Поговорим о том, как работает поддержка классов ES6 в JS-движке V8. В предыдущем материале, посвящённом абстрактным синтаксическим деревьям, мы говорили о том, что при подготовке JS-кода к выполнению система производит его синтаксический анализ и формирует на его основе абстрактное синтаксическое дерево. При разборе конструкций объявления классов в абстрактное синтаксическое дерево попадают узлы типа ClassLiteral.

Во-первых — это конструктор в виде отдельной функции, во-вторых — это список свойств класса. В подобных узлах хранится пара интересных вещей. Такой узел, кроме того, хранит ссылку на родительский класс, который расширяет класс, для которого сформирован узел, который, опять же, хранит конструктор, список свойств и ссылку на собственный родительский класс. Это могут быть методы, геттеры, сеттеры, общедоступные или закрытые поля.

После того, как новый узел ClassLiteral трансформируется в код, он преобразуется в конструкции, состоящие из функций и прототипов.

Итоги

Автор этого материала говорит, что в компании SessionStack стремятся как можно полнее оптимизировать код своей библиотеки, так как ей приходится решать непростые задачи по сбору сведений обо всём, что происходит на веб-страницах. В ходе решения этих задач библиотека не должна замедлять работу анализируемой страницы. Оптимизация такого уровня требует учёта мельчайших деталей экосистемы JavaScript, влияющих на производительность, в частности, учёта особенностей того, как устроены классы и механизмы наследования в ES6.

Уважаемые читатели! Пользуетесь ли вы синтаксическими конструкциями ES6 для работы с классами в JavaScript?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть