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

https://blog.sessionstack.com/how-javascript-works-the-internals-of-classes-and-inheritance-transpiling-in-babel-and-113612cdc220
  • Перевод
В наши дни использование классов являются одним из самых популярных способов структурирования программных проектов. Этот подход к программированию применяется и в JavaScript. Сегодня мы публикуем перевод 15 части серии материалов, посвящённых экосистеме JS. В этой статье речь пойдёт о различных подходах к реализации классов в JavaScript, о механизмах наследования и о транспиляции. Начнём мы с рассказа о том, как работают прототипы и с анализа различных способов имитации наследования, основанного на классах, в популярных библиотеках. Далее мы поговорим о том, как, благодаря транспиляции, можно писать JS-программы, использующие возможности, которые либо отсутствуют в языке, либо, хотя и существуют в виде новых стандартов или предложений, находящихся на разных стадиях согласования, пока не реализованы в JS-движках. В частности, речь пойдёт о Babel и TypeScript и о классах ECMAScript 2015. После этого мы разберём несколько примеров, демонстрирующих особенности внутренней реализации классов в JS-движке V8.
image

[Советуем почитать] Другие 19 частей цикла
Часть 1: Обзор движка, механизмов времени выполнения, стека вызовов
Часть 2: О внутреннем устройстве V8 и оптимизации кода
Часть 3: Управление памятью, четыре вида утечек памяти и борьба с ними
Часть 4: Цикл событий, асинхронность и пять способов улучшения кода с помощью async / await
Часть 5: WebSocket и HTTP/2+SSE. Что выбрать?
Часть 6: Особенности и сфера применения WebAssembly
Часть 7: Веб-воркеры и пять сценариев их использования
Часть 8: Сервис-воркеры
Часть 9: Веб push-уведомления
Часть 10: Отслеживание изменений в DOM с помощью MutationObserver
Часть 11: Движки рендеринга веб-страниц и советы по оптимизации их производительности
Часть 12: Сетевая подсистема браузеров, оптимизация её производительности и безопасности
Часть 12: Сетевая подсистема браузеров, оптимизация её производительности и безопасности
Часть 13: Анимация средствами CSS и JavaScript
Часть 14: Как работает JS: абстрактные синтаксические деревья, парсинг и его оптимизация
Часть 15: Как работает JS: классы и наследование, транспиляция в Babel и TypeScript
Часть 16: Как работает JS: системы хранения данных
Часть 17: Как работает JS: технология Shadow DOM и веб-компоненты
Часть 18: Как работает JS: WebRTC и механизмы P2P-коммуникаций
Часть 19: Как работает JS: пользовательские элементы

Обзор


В 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 для решения схожих задач используются прототипы.


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

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

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

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

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

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


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

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

function InputField(value) {
    this.content = `<input type="text" value="${value}" />`;
}

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

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

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


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

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

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

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

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

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


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


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

Одним из наиболее популярных транспиляторов для 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 = `<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(). Она выполняет те же действия, которые мы описывали выше, связанные, в частности, с записью в прототип класса-потомка экземпляра родительского класса.

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

Абстрактное синтаксическое дерево в 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?

RUVDS.com

962,72

RUVDS – хостинг VDS/VPS серверов

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


    Конечно если сидеть на одном JS и не планировать никогда в жизни другие языки (и не использовать другие языки параллельно), тогда можно привыкнуть к его прототипам, и прочее. Но если я в основном пишу на Java-Python-PHP-Ruby-TypeScript-...-ANY_Language, то зачем переключать мозги в JS, когда я могу использовать те же самые конструкции, что и в других языках?

    Поэтому, если я могу создать класс, как и в других языках, и работать с ним точно так же, а компиляторы за меня переведут все это дело в более мутный (для меня) JS, то конечно.
      0
      Я не особенно пользуюсь Babel, хватает TypeScript, но Babel эти _classCallCheck() и _createClass() пихает в каждый генерируемый класс или опционально можно включить импорт? В TypeScript например все эти вспомогательные функции вынесены в github.com/Microsoft/tslib и при включенном флаге compilerOptions.importHelpers эта библиотека будет импортироваться вместо включения самой реализации функции в каждом месте.
      0
      Оптимизация такого уровня требует учёта мельчайших деталей экосистемы JavaScript, влияющих на производительность, в частности, учёта особенностей того, как устроены классы и механизмы наследования в ES6.

      кстати, для тех кому важна производительность, не стоит сразу использовать все новые плюшки языка: fhinkel.rocks/six-speed
        0
        Меня больше смущает это:
        Предупреждение: Изменение прототипа [[Prototype]] объекта является, по самой природе оптимизации доступа к свойствам в современных движках JavaScript, очень медленной операцией, это справедливо для любого браузера и движка JavaScript. Изменение прототипов очень тонко и обширно влияет на производительность, причём это влияние не ограничивается просто временем, проведённым внутри метода Object.setPrototypeOf(), оно может распространяться на любой код, который имеет доступ к любому объекту, чей прототип [[Prototype]] был изменён. Если вы заботитесь о производительности, вы никогда не должны изменять прототип [[Prototype]] объекта. Вместо этого создайте объект с нужным прототипом [[Prototype]], с помощью метода Object.create().
        и TypeScript, и Babel используют его. Как с этим обстоят дела?
          0

          Они используют его там, где без него никак: для функций. Функция не может быть создана при помощи Object.create.

          0
          Почему когда говорят о фичах ES6 никогда не забывают упомянуть, что это «синтаксический сахар». Разве язык не должен абстрагировать нас от деталей реализации виртуальной машины. Сегодня браузеры преобразуют это к старые конструкции. А завтра не будут преобразовывать.
          Я не обращал внимания, может в спецификации указано явно, что новые конструкции являются эквивалентом старых. Надо посмотреть. Если нет, то это никакой не синтаксичский сахар.
            +2
            Когда мы размышляем об объектах, то первое, что приходит в голову — это классы.

            Видимо, зря придумали ключевое слово class, оно затмевает сознание и мешает думать об объектах. В JS можно создавать объекты вообще без this, new и class, а только с помощью фабрик, замыканий и, собственно, объектов:


            function Queue() {
              const store = [];
              return {
                enqueue(elem) { store.unshift(elem); },
                dequeue() { return store.pop(); },
              }
            }

            Чем такой способ создания объектов хуже классов?

              0

              Не хуже, тут вопрос стандартизации. Если в большинстве языков объекты задаются через class Name {} и вызываются через new Name();, то логичней использовать именно эту структуру, а не то, что Вы написали выше.

                +1
                Это называется Module Pattren.
                Невозможно наследовать. Невозможно мокнуть куски для тестирования. Невозможность распилить код на несколько файлов в таком виде. При разростании это ведёт к такому github.com/Microsoft/TypeScript/blob/master/src/compiler/checker.ts и вот таким попыткам это исправить. github.com/Microsoft/TypeScript/issues/17861 Как думаете стоит ли написать полноценную статью с критикой такого подхода? Единственный сомнительный плюс — тру приваты, до store в вашем случае вообще никак не достучаться снаружи. Но имхо это намеренная потеря гибкости не понятно зачем.
                  0
                  Ну ещё плюс, не надо биндить коллбеки, и так всё замкнуто.
                    0
                    Невозможно наследовать.

                    Вам действительно нужно наследование? Если без него действительно никак не обойтись, то можно реализовать вот так:


                    function LimitQueue(limit) {
                      const q = Queue();
                      const self = {
                        enqueue(elem) { q.size() < limit && q.enqueue(elem); }
                      };
                      return Object.setPrototypeOf(self, q);
                    }
                    
                    function Queue() {
                      const state = [];
                      return {
                        enqueue(elem) { state.unshift(elem); },
                        dequeue() { return state.pop(); },
                        size() { return state.length; }, 
                      };
                    }

                    Невозможно мокнуть куски для тестирования.

                    Из-за "мокнутых кусков" тестирование превращается в ад. В данном случае, если очень хочется, можно создать фэйковый Queue и использовать его в тестах:


                    function someTest() {
                      let calledTimes = 0;
                      const fakeQueue = { enqueue(elem) { calledTimes += 1; } };
                    
                      objectUnderTest.someMethod(fakeQueue);
                    
                      assertEqual(1, calledTimes);
                    }

                    Конкретно этот пример надуманный, но суть он показывает.


                    При разростании это ведёт к такому

                    А зачем разрастаться, если объекты можно делать маленькими и разносить по разным файлам?


                    // LimitQueue.js
                    import Queue from './Queue';
                    
                    export default function LimitQueue(limit) {
                      const q = Queue();
                      const self = {
                        enqueue(elem) { q.size() < limit && q.enqueue(elem); }
                      };
                      return Object.setPrototypeOf(self, q);
                    }
                    
                    // Queue.js
                    export default function Queue() {
                      const state = [];
                      return {
                        enqueue(elem) { state.unshift(elem); },
                        dequeue() { return state.pop(); },
                        size() { return state.length; }, 
                      };
                    }

                    Как думаете стоит ли написать полноценную статью с критикой такого подхода

                    Напишите, мне будет интересно почитать.


                    Но имхо это намеренная потеря гибкости не понятно зачем.

                    Лично мне такой подход дает больше гибкости. А вот классы обычно подталкивают к мышлению в рамках классов, код из-за этого наоборот теряет в гибкости.


                    JS прекрасный ОО-язык, но его зачем-то превращают в еще одну Java, а рынок, к сожалению, этот тренд поддерживает.

                      0

                      Вместо Object.setPrototypeOf(self, q) можно использовать Object.assign({}, q, self).

                      +2
                      Есть люди, весьма не глупые люди замечу, которые уже давно утверждают «Favor object composition over class inheritance.» — The Gang of Four. А еще есть люди (Eric Elliott, Mattias Petter Johansson например), тоже кстати не глупые, которые уже в наше время достаточно хорошо и понятно на примерах разъясняют почему это утверждение верно и композиция ведет большей гибкости в разработке, а наследование ведет к «The Gorilla/banana problem»
                        0
                        Так нормальные классы не мешают композиции. Но это получается композиция скажем так открытая, можно подменить часть. А в module pattern это совсем закрыто. Инкапсуляция конечно хорошо, но не всегда.
                    0
                    InputField.prototype = Object.create(new Component());

                    Зачем так делать? Правильный вариант вот такой:


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

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое