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

Типы сущностей в исходном коде

Сам я сталкивался со следующими типами:

  • примитивы: строка, число, логическое значение, null, undefined, символ;

  • области видимости (scopes)

  • (update) замыкания

  • объекты

  • массивы

  • функции

  • классы

  • модули

  • пакеты


Примитивы

С примитивами ничего интересного, на то они и примитивы. Вот код:

const aBool = true;
const aNull = null;
const aNum = 128;
const aStr = '128';
const aSymLocal = Symbol('local symbol');
const aSymGlobal = Symbol.for('global symbol');
let aUndef;

А вот так примитивы выглядят под отладчиком (слева - в браузере Chrome, справа - в IDE PhpStorm):

Ну разве что обращает на себя внимание стрелка рядом с символом в IDEA (PhpStorm), как будто aSymGlobal и aSymLocal являются составными компонентами, а не примитивными элементами. Стрелку на aSymGlobal я развернул - нет там ничего.


Области видимости

Проще всего организовать различные области видимости переменных при помощи блоков:

{
    const outer = 'outer scope';
    {
        const medium = 'medium scope';
        {
            const inner = 'inner scope';
            debugger;
        }
    }
}

При остановке в отладчике во внутреннем блоке видны переменные из всех трёх областей:

Также и в браузере, и в nodejs доступна глобальная область видимости (Global), а в nodejs ещё доступна область видимости исполняемого фрагмента кода (скрипта) - Local.


Объекты

В JavaScript'е всё, что не примитив, то объект (включая функции и массивы). В данном разделе я рассматриваю именно объекты (которые не функции и не массивы):

const id = Symbol('id');
const code = Symbol();
const name = Symbol();
const obj = {
    [id]: 1,
    [code]: 'ant',
    [name]: 'cat',
    aStr: 'string',
    aNum: 64,
    anObj: {
        [code]: 'dog'
    }
}

Символы рекомендуется использовать в качестве идентификаторов свойств объекта и из кода понятно, что 'ant' - это код для объекта obj, а 'cat' - это имя. Для объекта obj.anObj 'dog' - это код.

В отладчике не всё так однозначно:

Если у символа отсутствует описание, то непонятно, какое свойство является именем, а какое - кодом.

Прототип объекта

В свойстве obj.__proto__ находится ссылка на прототип, по которому создавался данный объект. Объекты создаются при помощи конструктора (функция Object.constructor()), который в качестве прототипа для новых объектов использует свойство Object.constructor.prototype:

const obj = {};

Таким образом obj.__proto__ === obj.__proto__.constructor.prototype:

prototype в свою очередь содержит ту же функцию constructor, которая содержит тот же prototype, и т.д. - циклическая зависимость, по которой можно спускаться вглубь, пока хватит ресурсов компьютера.

В отладчике также видно, что, например, функция assign является методом конструктора f Object() (методом класса Object), а не методом свежесозданного объекта obj.

Таким образом отладчик может быть своего рода кратким справочником по методам соответствующих базовых классов:

obj.__proto__.constructor.assign // Object.assign

Массивы

Массивы - это такие специфические объекты, которые и в коде, и под отладчиком выглядят слегка иначе, чем обычные объекты. Вместо фигурных скобок {} применяются квадратные []:

let undef;
const id = Symbol.for('id');
const arr = [1, 'str', null, undef, {[id]: 'ant'}, ['internal', 'array']];

Массив очень похож на объект, только вместо имён ключей (свойств) применяются числовые индексы:

Прототип массива

Под отладчиком видно, что в основе у массивов находится Array:

arr.__proto__ => Array
arr.__proto__.constructor.isArray // Array.isArray

у которого в основе находится Object:

arr.__proto__.__proto__ => Object

Функции

Стрелочные vs. Обычные

Стрелочные функции исполняются в области видимости родителя, обычные - создают собственную область видимости.

// arrow function
((a) => {
    debugger;
    return a + 2;
})(1);

// regular function
(function (a) {
    debugger;
    return a + 2;
})(2);

Если запустить данный код в браузере/nodejs, то переменная this в локальной области видимости будет неопределена для стрелочных функций:

и будет соответствовать глобальному объекту (Window или global) для обычных:

Именованные vs. Анонимные

Различия между именованными и анонимными функциями видны в стеке вызовов.

// anonymous functions
(function (a) {
    return 2 + (function (b) {
        debugger;
        return b + 4;
    })(a);
})(1);

// named functions
(function outer(a) {
    return 2 + (function inner(b) {
        debugger;
        return b + 4;
    })(a);
})(1);

Для анонимных функций в стеке указывается только файл и строка кода:

Для именованных - ещё и имя функции, что удобно:

Прототип функции

Прототипом функции является объект Function, для которого прототипом является Object:

func.__proto__ => Function
func.__proto__.constructor.caller // Function.caller

func.__proto__.__proto__ => Object

Классы

Именованные vs. Анонимные

{
    const AnonClass = class {
        name = 'Anonymous'
    };

    class NamedClass {
        name = 'Named'
    }

    function makeAnonClass() {
        return class {
            name = 'Dynamic Anon'
        };
    }

    function makeNamedClass() {
        return class DynamicNamed {
            name = 'Dynamic Named'
        };
    }

    const DynamicAnonClass = makeAnonClass();
    const DynamicNamedClass = makeNamedClass();


    const anon = new AnonClass();
    const named = new NamedClass();
    const dynAnon = new DynamicAnonClass();
    const dynNamed = new DynamicNamedClass();
    const justObj = new (class {
        name = 'Just Object'
    })();

    debugger;
}

Объекты, созданные при помощи анонимного класса, приравненного к какой-либо переменной, в отладчике видны под именем этой переменной (anon).

Объекты, созданные при помощи именованных классов, в отладчике видны под именами этих классов (dynNamed и named).

Имя класса, к которому принадлежит объект, находится в obj.__proto__.constructor.name.

Объекты, созданные при помощи динамически созданного анонимного класса, видны в отладчике IDEA под именем базового класса Object, а в отладчике Хрома - без названия, как и простой объект (dynAnon). Т.е., у них obj.__proto__.constructor.name отсутствует.

Объект justObjпроще было бы создать при помощи обычных фигурных скобок {name: 'Just Object'}, чем при помощи одноразовой конструкции new (class {name = 'Just Object'})().

В общем, объекты, созданные при помощи именованных классов, в отладчике маркируются именем соответствующего класса (именем конструктора), что очень сильно облегчает жизнь разработчику.

Отладчик Хрома выводит и классы, и объекты-переменные в едином списке, IDEA выделяет функции и классы в отдельный список Functions внутри соответствующей области видимости.

Класс - это функция

class Demo {}

В отладчике видно, что класс Demo является функцией (Demo.__proto__ => Function). IDEA выносит классы в секцию Functions внутри блока:

У класса есть свойство prototype которое он использует в качестве свойства __proto__ для новых объектов, создаваемых при помощи оператора new:

const demo = new Demo();
demo.__proto__ === Demo.prototype // true

Экземпляры класса

Экземпляры, создаваемые при помощи оператора new, являются объектами (не функциями, как сам класс):

{
    class Demo {
        propA
        methodA() {}
    }

    const demo = new Demo();
    debugger;
}

Под отладчиком видно, что методы нового объекта находятся в его прототипе (demo.__proto__.methodA), а свойства - в самом объекте (demo.propA).

Статические свойства и методы

{
    class Demo {
        static propStat

        static methodStat() {
            return this.propStat;
        }

    }

    const demo = new Demo();
    Demo.methodStat();
    debugger;
}

Статические члены "вешаются" на саму класс-функцию, а не на объекты, создаваемые при помощи оператора new:

Видно, что у объекта demo нет никаких свойств и методов, зато у класс-функции Demo есть свойство propStat и метод methodStat.

Приватные свойства и методы

{
    class Demo {
        #propPriv = 'private'

        #methodPriv() {
            return this.#propPriv;
        }

    }

    const demo = new Demo();
    debugger;
}

Приватные свойства и методы видны в Хроме, а в IDEA прячутся в деталях объекта, но видны в его аннотации:

Акцессоры (get & set)

Акцессоры позволяют реализовать "виртуальное" свойство, позволяя контролировать присвоение данных этому свойству и получение данных от свойства:

{
    class Demo {
        #prop

        get prop() {
            return this.#prop;
        }

        set prop(data) {
            this.#prop = data;
        }

    }

    const demo = new Demo();
    demo.prop = 'access';
    debugger;
}

И в Хроме, и в IDEA данное "виртуальное" свойство при отладке сразу не отображается (стоит троеточие вместо значения), а для получения данных нужно в явном виде вызвать getter (двойной щелчок мыши по свойству):

В IDEA в аннотации прототипа класс-функции (Demo.prototype) видно, что prop: Accessor. Также стоит отметить, что "виртуальное" свойство (являясь парой функций) относится скорее к прототипу объекта, чем к самому объекту: если Хром отображает prop в свойствах объекта и в свойствах его прототипа, то IDEA - только в свойствах прототипа.

Наследование

{
    class Parent {
        name = 'parent'
        parentAge = 64
        action() {}
        actionParent() {}
    }

    class Child extends Parent {
        name = 'child'
        childAge = 32
        action() {}
        actionChild() {}
    }

    const child = new Child();
    debugger;
}

При наследовании прототипы выстраиваются в цепочку, а при добавлении свойств в новый объект конструктор наследника перекрывает значения таких же свойств родителя (name в итоге равен "child"):

Также видно, что перекрытые методы родителя доступны через прототипы:

child.__proto__.__proto__.action();

Из необычного, и Хром, и Idea аннотируют прототип child.__proto__ как Parent, хотя прототип по факту содержит методы из класса Child.


Модули

Модуль в JS - это отдельный файл, подключаемый через import. Пусть содержимое модуля находится в файле ./sub.mjs (расширение "*.mjs" означает, что в файл содержит ES6-модуль):

function modFunc() {}
class ModClass {}
const MOD_CONST='CONSTANT';

export {modFunc, ModClass, MOD_CONST};

а вызывающий скрипт выглядит так:

import * as sub from './sub.mjs';

debugger;

Под отладчиком в вызывающем скрипте виден элемент sub, который не является обычным JS-объектом (у него нет прототипа):

Также видно, что экспортируемые объекты модуля являются "виртуальными" свойствами (доступны через акцессоры).


Пакеты

Пакет - это способ организации кода в nodejs, в браузере пакеты отсутствуют. Если JS-модуль представляет из себя файл, то пакет - это группа файлов, главным из которых является package.json, в котором задаётся точка входа в пакет (по-умолчанию - index.js). В точке входа описывается экспорт пакета, аналогично тому, как описывается экспорт в модуле. Поэтому импорт пакета аналогичен импорту модуля, за исключением того, что при импорте указывается не путь к модулю (filepath или URL), а имя пакета:

// import * as sub from './sub.mjs';
import * as express from 'express';

Под отладчиком сущности, импортируемые из пакета, аналогичны импортируемым из модуля:

Резюме

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

Всем спасибо за внимание. Хэппи, как говорится, кодинга. Ну и дебаггинга.