[Перевод] Как работает 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 и соответствующего конструктора. Как результат, за счёт автоматического создания объектов, инкапсулирующих примитивные значения, с такими значениями можно работать так, как будто они являются объектами, в частности — обращаться к их методам и свойствам.

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

let names = ["SessionStack"];

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


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

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


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

633ba7cabdf251ed3747fb2a3764b344.png


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

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

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

function Component(content) {
  this.content = content;
}

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


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

5536d874d81cc44b164174953614f725.png


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

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

function InputField(value) {
    this.content = ``;
}


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

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


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

f5cc43ba8e7a66cf1bb5c8aecec04572.png


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

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

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


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

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

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


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

80237bf2c453fb8f9d7913c310a21384.png


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

Одним из наиболее популярных транспиляторов для JavaScript является Babel.js. Посмотрим как он работает, выполнив транспиляцию кода объявления класса Component, о котором мы говорили выше. Итак, вот 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() и _createClass(), включённых в транспилированный код. Первая функция, _classCallCheck(), предназначена для того, чтобы функция-конструктор не вызывалась как обычная функция. Для этого тут выполняется проверка того, является ли контекст, в котором вызывается функция, контекстом экземпляра класса Component. В коде проверяется, указывает ли ключевое слово this на подобный экземпляр. Вторая функция, _createClass(), занимается созданием свойств объекта, которые передаются ей как массив объектов, содержащих ключи и их значения.

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

class InputField extends Component {
    constructor(value) {
        const content = ``;
        super(content);
    }
}


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

var InputField = function (_Component) {
  _inherits(InputField, _Component);

  function InputField(value) {
    _classCallCheck(this, InputField);

    var content = '';
    return _possibleConstructorReturn(this, (InputField.__proto__ || Object.getPrototypeOf(InputField)).call(this, content));
  }

  return InputField;
}(Component);


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

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

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


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

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

  render() {
    console.log(this.content)
  }
}


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

a8b4c5c1d077388bac770d1b452e51ed.png


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

После создания дерева каждый его узел трансформируется в соответствующий ему узел 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)
    }
}


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

332806ad0bd9882f32c58c0280a8ae40.png


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

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

class InputField extends Component {
    constructor(value: string) {
        const content = ``;
        super(content);
    }
}


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

var InputField = /** @class */ (function (_super) {
    __extends(InputField, _super);
    function InputField(value) {
        var _this = this;
        var content = "";
        _this = _super.call(this, content) || this;
        return _this;
    }
    return InputField;
}(Component));


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

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

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


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

ad5860a550c7956d6bbf17cd1b0afbb6.png


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

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

b61d1ca5b3d35b3b1f431ae8e82c48bd.png


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

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


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

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

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

Итоги


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

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

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru