Сегодня у меня была небольшая задачка на рефакторинг JS кода, и я натолкнулся на неожиданную особенность языка, о которой на протяжении 7 лет своего опыта программирования на этом ненавистном многими языке не задумывался и не сталкивался.


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


Чтобы не пользоваться традиционными и бессмысленными константами foo/bar, покажу непосредственно на примере, который был у нас в проекте, но всё же без кучи внутренней логики и с фейковыми значениями. Помните, что всё равно примеры получились довольно синтетические

Наступаем на грабли


Итак, у нас есть класс:


class BaseTooltip {
    template = 'baseTemplate'
    constructor(content) {
        this.render(content)
    }
    render(content) {
        console.log('render:', content, this.template)
    }
}

const tooltip = new BaseTooltip('content')
// render: content baseTemplate

Всё логично


А потом нам понадобилось создать другой тип тултипов, в котором изменяется поле template


class SpecialTooltip extends BaseTooltip {
    template = 'otherTemplate'
}

И вот тут меня ждал сюрприз, потому что при создании объекта нового типа происходит следующее


const specialTooltip = new SpecialTooltip('otherContent')
// render: otherContent baseTemplate
//                          ^ СТРАННО

Метод render вызвался со значением BaseTooltip.prototype.template, а не с SpecialTooltip.prototype.template, как я ожидал.


Наступаем на грабли внимательнее, снимая на видео


Поскольку chrome DevTools не умеют дебажить присваивание полей класса, то чтобы разобраться в происходящем, приходится прибегать к ухищрениям. С помощью небольшого хелпера логируем момент присваивания переменной


function logAndReturn(value) {
    console.log(`set property=${value}`)
    return value
}

class BaseTooltip {
  template = logAndReturn('baseTemplate')
  constructor(content) {
      console.log(`call constructor with property=${this.template}`)
      this.render(content)
  }
  render(content) {
    console.log(content, this.template)
  }
}

const tooltip = new BaseTooltip('content')
// set property=baseTemplate
// called constructor BaseTooltip with property=baseTemplate
// render: content baseTemplate

И когда мы применим этот подход к наследуемому классу, получим следующее странное:


class SpecialTooltip extends BaseTooltip {
    template = logAndReturn('otherTemplate')
}

const tooltip = new SpecialTooltip('content')
// set property=baseTemplate
// called constructor SpecialTooltip with property=baseTemplate
// render: content baseTemplate
// set property=otherTemplate

Я был уверен, что сначала инициализируются поля объекта, а потом вызывается остальная часть конструктора. Оказывается, что всё хитрее.


Наступаем на грабли, покрасив черенок


Усложним ситуацию, добавив в конструктор ещё один параметр, который присвоим нашему объекту


class BaseTooltip {
  template = logAndReturn('baseTemplate')
  constructor(content, options) {
      this.options = logAndReturn(options) // <--- новое поле
      console.log(`called constructor ${this.constructor.name} with property=${this.template}`)
      this.render(content)
  }
  render(content) {
    console.log(content, this.template, this.options) // <--- поменяли вывод
  }
}
class SpecialTooltip extends BaseTooltip {
    template = logAndReturn('otherTemplate')
}

const tooltip = new SpecialTooltip('content', 'someOptions')
// в результате вообще путаница:
// set property=baseTemplate
// set property=someOptions
// called constructor SpecialTooltip with property=baseTemplate
// render: content baseTemplate someOptions
// set property=otherTemplate

И только такой способ дебага (хорошо что не алертами) немножко прояснил мне происходящее


Откуда взялась эта проблема:

Раньше этот код был написан на фреймворке Marionette и выглядел (условно) так


const BaseTooltip = Marionette.Object.extend({
    template: 'baseTemplate',
    initialize(content) {
         this.render(content)
    },
    render(content) {
        console.log(content, this.template)
    },
})

const SpecialTooltip = BaseTooltip.extend({
    template: 'otherTemplate'
})

При использовании Marionette всё работало так, как я ожидал, то есть метод render вызывался с указанным в классе значением template, но при переписывании логики модуля на ES6 в лоб и вылезла описанная в статье проблема


Считаем шишки


Итог:


При создании объекта наследованного класса порядок происходящего следующий:


  • Инициализация полей объекта из объявления наследуемого класса
  • Выполнение конструктора наследуемого класса (в том числе инициализация полей внутри конструктора)
  • Только после этого инициализация полей объекта из текущего класса
  • Выполнение конструктора текущего класса

Возвращаем грабли в сарай


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


Было бы классно прочитать в комментариях ваши предложения о том, как элегантно решить возникшую проблему

Комментарии (56)


  1. aamonster
    26.07.2019 00:04
    +5

    Довольно логичное поведение (до инициализации инстанса класса-наследника должен отработать конструктор базового класса — а значит, инициализация полей наследника ещё не выполнена) и ьросающийся в глаза smelly code (в конструкторе дёргаете методы, опирающиеся на то, что объект уже готов...)


    Жаль, что нынче учат программированию, начиная не с паскаля: в Objective Pascal многие нюансы были бы видны из кода, и при смене языка привычки бы остались.


    А решение простое. Убрать из конструктора любую логику, кроме создания объекта. Конструктор должен выполнять ровно одну задачу: заполнить все поля так, чтобы соблюдались инварианты (в случае наследования — те поля, что отличаются от базового класса). Всё прочее выносите в функцию Init или ещё какую и вызывайте её явно.


    1. mrTyler
      26.07.2019 00:22

      Самые сильные Frontend разработчики, с котороыми мне довелось работать, именно те, чьим первым языком были Java или C#. Их код чище, шаги продуманы, ну и в целом они умеют писать весьма приятный код.


    1. Sirion
      26.07.2019 02:01

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


      1. Nikelandjelo
        26.07.2019 19:39

        Нет, не достаточно. Порядок инициализации зависит от того, как записана переменная. Например getFoo() { return 'foo' } и getFoo = () => { return 'foo '; } будут инициализированы в разном порядке в классе. Было бы хорошо найти где это прописано в спецификации языка, но пока что все тут говорят что это "очевидно".


        1. LEXA_JA
          26.07.2019 19:56

          Они не просто инициальзируются в разном порядке, первый вариант — это объявление метода. Этот метод попадает в прототип. Второй вариант — объявление поля, в которое записывается фунция. Это поле инициализируется в каждом экземпляре класса. Из чего следует еще один забавный момент: если попытаться унаследоваться от такого класса, то через super, такая функция доступна не будет.


    1. taliban
      27.07.2019 01:48

      Тоесть такой код вызовет ошибку в Java и C#? Или будет работать так же?


      1. aamonster
        27.07.2019 12:32

        Вообще я имел в виду код на js. Но тот же мысленный эксперимент на java или c# (вызвать конструктор базового класса в середине конструктора класса-потомка, после инициализации его полей) даст ту же ошибку: присваивания полей в конструкторе базового класса выполнятся позже и перкроют значения, присвоенные потомком.


  1. mrTyler
    26.07.2019 00:10
    +2

    Мне вот интересно, долго еще Javascript разработчики будут удивляться, что все работает именно так, как работать должно и именно так, как написано в документации?


    1. v1vendi Автор
      26.07.2019 00:24
      -1

      Мне вот интересно, сколько разработчиков полностью знают 12-мегабайтную спецификацию языка?


      1. mrTyler
        26.07.2019 00:27
        +2

        Я понимаю, что динамически типизированный язык расслабляет, но документация и спецификация — не одно и то же.


        1. v1vendi Автор
          26.07.2019 00:34

          Не конфликта ради, а чтобы дополнить статью — можете помочь найти ссылку на документацию по Javascript, в которой бы хотя бы нечётко указывалось на такое поведение?
          Я ни в коей мере не считаю себя специалистом высокого класса, но я изучил достаточно много книг и статей, связанных с фронтенд-разработкой, и я не разу не встречал информации по описанной мной теме


          1. aamonster
            26.07.2019 00:41

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


            Но я бы сформулировал вопрос иначе: можете ли найти ссылку на документацию, где описано другое поведение?
            Очевидно, нет. Значит, закладываться на другое поведение причин не было.
            Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.


            1. v1vendi Автор
              26.07.2019 00:57

              Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.

              Я вот не знал, как будет работать такой код. Разобрался и поделился с сообществом тем, что на мой взгляд может быть кому-то полезно. А мог бы написать другой код, про который знаю, и кто-то мне бы потом рассказывал, что, мол, если бы я начинал с Паскаля, мне не пришлось бы изобретать велосипеды.


              Хотелось бы напомнить, что бОльшую часть истории языка Javascript в нём в принципе не было такой сущности, как class, и разработчикам принципиально не нужно было знать поведение системы в таких ситуациях


              1. aamonster
                26.07.2019 01:04

                Напомню также, что традиционная реализация классов для js (определение методов prototype конструктора) придумана очень давно, и позволяет предположить, какое поведение тут будет.


                Но, тем не менее, использовать тонкости языка не стоит. Вы можете их знать, а вот тот, кто будет читать ваш код – нет. Старое правило: "пиши код так, будто сопровождать его будет склонный к насилию психопат, знающий, где ты живёшь".


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


              1. IkaR49
                26.07.2019 21:39

                Но вы же знаете, что class это синтаксический сахар для prototype? Если знаете, то просто распишите этот сахар с помощью прототипного насследования и всё сразу становится проще.


          1. staticlab
            26.07.2019 01:03
            +2

            Следует помнить, что public instance fields сейчас находятся в stage 3. Соответственно, официально в спецификацию языка не внесены, однако можно прочитать proposal по этой фиче (попытаться, ага).


            1. dagen
              26.07.2019 05:17

              public instance fields
              Кстати public и private proposal нынче объединили в proposal-class-fields.


      1. dporollo
        26.07.2019 09:39

        Человека, который знает все наизусть Я бы уволил первым


    1. Nikelandjelo
      26.07.2019 19:41

      А можете дать ссылку на то, где это прописано в спецификации? В https://tc39.es/ecma262/#sec-class-definitions я только вижу грамматики класса, но не то, как он должен инициализироваться.



  1. vvadzim
    26.07.2019 00:19

    class A {
      constructor() {
        this.initialize()
        this.render()
      }
    
      render() {
        console.log(this.value1 + this.value2)
      }
    
      initialize() {
        this.value1 = 'A'
        this.value2 = 'A'
      }
    }
    
    class B extends A {
      initialize() {
        super.initialize()
        this.value2 = 'B'
      }
    }
    
    new B()
    

    Выведет `AB`.


    1. vvadzim
      26.07.2019 00:21
      -1

      Но вообще вызывать методы из конструктора так себе идея.


      1. v1vendi Автор
        26.07.2019 01:13

        Согласен. Наткнулся я на эту проблему именно в связи с тем, что в Marionette по сути конструктор ты не объявляешь, а исполнялся и перерабатывался в конструктор как раз метод initialize
        Вот только про "не используйте глобальные переменные" только что на заборе не пишут, а вот с проблемой излишней логики в конструкторах я, например, столкнулся впервые


  1. XenonDev
    26.07.2019 00:53
    +1

    автор, а Вас не смущает, что в классе-наследнике нельзя обращаться к this до тех пор пока не проинициализируется базовый класс (с помощью super)? Вполне логично, что переменные наследников еще не будут проинициализированы.


    1. v1vendi Автор
      26.07.2019 01:06
      -2

      Я не вижу причины, кроме выбора авторов языка, почему объявленные поля класса-наследника не могут быть инициализированы до вызова конструктора. Поля родительского класса определяются именно до вызова конструктора, и ожидать такое поведение от класса-наследника мне не кажется какой-то несусветной глупостью


      1. aamonster
        26.07.2019 01:20

        Довольно очевидно, на самом деле. Попробуйте по шагам убрать синтаксический сахар:


        1. Заполнение полей вносим внутрь конструктора (в его начало).
          1.1. То же самое для конструктора базового класса.
        2. Вызов конструктора базового класса вносим в начало конструктора производного класса. Как раз попадёт перед заполнением полей (после – нельзя, в этот момент мы уже ожидаем, что объект базового класса готов)
        3. Читаем полученный код.


        1. v1vendi Автор
          26.07.2019 01:31
          -1

          Вопрос по второму пункту. Почему нельзя? Почему мы в этот момент ожидаем, что объект базового класса уже готов? В памяти нигде нет ОТДЕЛЬНОГО объекта базового класса, есть один объект, в который добавляются свойства и методы.
          На мой взгляд, это вполне могло быть реализовано внутри как
          this.prop = Subclass.prototype.prop || Baseclass.prototype.prop, а потом уже вызов конструктора с проинициализированными полями.
          Да, был выбран другой способ, но это не значит что он очевиден


          1. dagen
            26.07.2019 05:13

            Да, был выбран другой способ, но это не значит что он очевиден

            Просмотрел внимательно (и вы тоже можете) заголовки тикетов; никому не приходило в голову, что текущий способ неочевидный. Никто не заводил такой тикет. Очевидно, что неочевидно это только для вас :)

            И вы по-прежнему можете завести такой тикет, несмотря на то, что stage-3 означает, что все основные вопросы уже решены.


            1. v1vendi Автор
              26.07.2019 09:37

              https://isocpp.org/wiki/faq/strange-inheritance
              https://www.codeproject.com/Tips/641610/Be-Careful-with-Virtual-Method
              https://lustforge.com/2014/02/08/dont-call-non-final-methods-from-your-constructor-please/
              По итогу написания этой статьи, я смог найти довольно немало статей разного возраста про другие ЯП на английском, в которых описывается эта проблема. Это значит, что кому-то ещё это приходило в голову, а значит не так уж это и очевидно


              И да, по приведённой Вами ссылке есть тикет на довольно близкую тему
              https://github.com/tc39/proposal-class-fields/issues/151


              1. Mikluho
                26.07.2019 09:59

                Ну и что там неожиданного? Везде пишут не делать так, как вы хотите. Дело конструктора — только инициализация состояния. И инициализировать надо сначала базовый. И так примерно во всех ОО-языках.


                1. v1vendi Автор
                  26.07.2019 10:36

                  Если бы дело конструктора было только в инициализации состояния, любые другие действия вообще запрещалось бы делать на уровне языка. Да и понятие инициализации состояния — довольно размытое, конкретно в моём случае, например, метод this.render просто неудачно назван, он не создаёт никаких dom элементов и не изменяет сторонние объекты, он просто компилирует в себя шаблон.
                  Это вполне подходит под понятие инициализации состояния, как я считаю


                  А в приведённых выше ссылках например написано, что для языка C# инициализация переменных класса-наследника происходит ДО вызова конструктора родителя, что уже делает наш спор не таким однозначным.


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


                  1. Mikluho
                    26.07.2019 11:58

                    И само наличие десятков (я привёл маленькую выборку) статей на подобные темы значит именно то, что у других наших коллег тоже возникают вопросы по этому поводу, разве нет?
                    Да темы не такие уж похожие. И по большей части разъясняют неучам правильные подходы к программированию.
                    Большая часть вопросов возникает из желания писать код абы как.
                    Вот в вашем пример есть одно концептуальное недоразумение: у вас template это свойство, а не поле. Но это историческое наследие JS — там просто не было пропертей. И для обращения к полю базового типа надо было к нему явно обращаться. Сейчас добавили синтаксис классов, но способ обращения к элементам и последовательность инициализации никуда не делись.


          1. aamonster
            26.07.2019 06:22

            Попробуйте ответить на свой вопрос сами. Для вашего примера выполните руками пункт 1 (и 1.1) и проведите эксперимент – вставьте вызов конструктора базового класса не в начало. Какую ошибку получим?


          1. Mikluho
            26.07.2019 07:24

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


          1. Deosis
            26.07.2019 07:40

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


            1. mayorovp
              26.07.2019 10:11

              Это никак не связано с прототипами.


        1. Nikelandjelo
          26.07.2019 19:33
          -1

          Убириание синтаксического сахара не поможет. Напримеh:


          class A {
            getOne() { return 'One'; }
            getTwo = () => { return 'two'; }
          }

          Я ожидал, что getOne — это синтактический сахар, который эквивалентен getTwo. Но это не так, они будут инициализированы в разном порядке:


          class A {
            getOne() { return 'one from A'; }
            getTwo = () => { return 'two from A'; }
            constructor() {
              console.log('getOne=' + this.getOne());
              console.log('getTwo=' + this.getTwo());
            }
          }
          
          class B extends A {
            getOne() { return 'one from B'; }
            getTwo = () => { return 'two from B'; }
          
          }
          
          new B();

          Выведет


          getOne=one from B
          getTwo=two from A


          1. mayorovp
            26.07.2019 20:01
            +1

            Зря ожидали, потому что эквивалентный код — вот такой:


            function A() {
                this.getTwo = () => { return 'two'; }
            }
            A.prototype.getOne = function () {
                return 'One';
            }


            1. Nikelandjelo
              26.07.2019 20:06

              Да, я уже понял, что я зря этого ожидал исходя из результатов, которые я получил. Написание правильного эквивалентного кода далеко не самая простая задача, как многие в комментариях тут рассуждают. Мне еще интересно исходя из какой документации/спецификации я могу понять, что правильные код именно ваш. Я конечно могу написать оба и сравнить что они делают, но хочется увидеть официальную спецификацию.


              1. Sirion
                26.07.2019 21:10

                Не знаю насчёт документации, но ИМХО, десахаризация этого кода очевидна исходя из того, какой старый код он призван был заменить.


  1. pin2t
    26.07.2019 05:13

    Просто ненадо выполнять код в конструкторе

    class BaseTooltip {
        template = 'baseTemplate'
        constructor(options) {
             this.options = options
        }
        render(content) {
            console.log('render:', content, this.template, this.options)
        }
    }
    
    const tooltip = new BaseTooltip(options)
    tooltip.render('content')


  1. JustDont
    26.07.2019 06:43

    Зачем читать документацию, когда её можно не читать, да потом еще и писать по этому поводу статьи на хабре?



  1. chelovekkakvse
    26.07.2019 10:35
    -1

    Просто кто-то не знает как работает прототипное наследование. Не буду оригинальным — RFM.


    1. mayorovp
      26.07.2019 10:37

      А при чём тут прототипное наследование?


      Если бы свойство template попало в прототип — то и никакой проблемы бы как раз не было.


      1. chelovekkakvse
        26.07.2019 11:55

        Да, виноват, по диагонали код автора прочитал. Там впринципе тупо сделано.


        Пожалуй надо было так:


        class BaseTooltip {
          type = 'baseTooltip';
          content;
        
          constructor(someField) {
            this.content = someField;
          }
        
          render() {
            console.log(this.type, this.content);
          }
        }
        
        class SpecialTooltip extends BaseTooltip {
          type = 'specialTooltip';
        
          constructor(content) {
            super(content);
          }
        }
        
        const newChild = new SpecialTooltip('child');
        newChild.render();

        П.С. Однако свойство template все же берется из родителя. В обычном наследовании, однако, результат будет аналогичным.


  1. chelovekkakvse
    26.07.2019 11:55

    del


  1. Myateznik
    26.07.2019 16:19
    +1

    На самом деле поведение очень даже логичное и понятное. Разберу пример:


    class A {
      name = 'A'
    
      constructor() {
        this.log()
      }
    
      log() {
        console.log(this.name)
      }
    }
    
    class B extends A {
      name = 'B'
    }
    
    new B
    // A

    Почему так происходит?
    Всё на самом деле очень просто — в классе A не указан конструктор, соответственно используется конструктор "по умолчанию" т.е. класс выглядит так:


    class B extends A {
      name = 'B'
    
      constructor() {
        super()
      }
    }

    Любой наследующий класс должен в своём конструкторе сначала вызывать конструктор родительского класса (делается это через super()) и только потом производить необходимые манипуляции с инстансом.


    Соответственно поле name указанное в классе B будет установлено только после выполнения конструктора класса A.


    Поэтапное создание инстанса класса B будет выглядить так:


    1. Object.constructor()  -> Object {}
    2. A.name = 'A'          -> A { name: 'A', log() {...} }
    3. A.constructor()       -> A { name: 'A', log() {...} }
    4. A.log()               -> A { name: 'A', log() {...} }
    5. B.name = 'B'          -> B { name: 'B', log() {...} }
    6. B.constructor()       -> B { name: 'B', log() {...} }

    Т.е. на момент вызова метода log() инстанс класса B содержит в поле name значение A, .


    Как добиться ожидаемого в данном примере поведения?


    • Есть к примеру способ, который указал vvadzim, но он требует переноса инициализации в отдельный метод.
    • Но есть и более простой способ, не требующий переноса инициализации в отдельный метод.

    Что это за способ? Ответ: Вызов метода log() в микротаске, но как? Есть опять таки два равносильных варианта:


    // 1 вариант -  Микротаск через Promise
    class A {
      name = 'A'
    
      constructor() {
        Promise.resolve().then(() => this.log())
      }
    
      log() {
        console.log(this.name)
      }
    }
    
    // 2 вариант -  Микротаск по запросу
    class A {
      name = 'A'
    
      constructor() {
        queueMicrotask(() => this.log())
      }
    
      log() {
        console.log(this.name)
      }
    }

    Команда Polymer Project в проекте lit-element по сути так же использует микротаски (через метод _enqueueUpdate).


    1. Nikelandjelo
      26.07.2019 19:25

      Поэтапное создание класса выглядит не совсем так. B.constructor() будет вызван первым. Например если переписать B как:

      class B extends A {
        name = 'B'
      
        constructor() {
          console.log('B constructor start');
          super();
          console.log('B constructor finish');
        }
      }
      new B();
      


      То лог будет:
      B constructor start
      A
      B constructor finish


      И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().

      К тому же, если переписать код и вместо переменной name использовать метод getName тогда работает так, как автор и ожидал:

      class A {
        getName() { 
        	return 'A';
        }
      
        constructor() {
          this.log()
        }
      
        log() {
          console.log(this.getName());
        }
      }
      
      class B extends A {
        getName() {
        	return 'B';
        }
      }
      
      new B();
      <source>
      
      выведет "B". По моему личному мнению это не очевидно, что инициализация переменных и методов в классе происходит в разном порядке. Особенно учитывая что в JS разница между переменной и методом небольшая и в до ES6 классов методы и были переменными функциями.


      1. Myateznik
        26.07.2019 20:03

        Поэтапное создание класса выглядит не совсем так. B.constructor() будет вызван первым.

        Совершенно верно, но я указал именно данный порядок, чтобы было яснее, какое значение поля name будет во время вызова метода log(). Если проще я просто обратную цепочку (из вложения) описал.


        Правильнее (подробнее) ваш вариант тогда написать так:


        class A {
          name = 'A'
        
          constructor() {
            console.log('A constructor start')
            this.log()
            console.log('A constructor finish')
          }
        
          log() {
            console.log(this.name)
          }
        }
        
        class B extends A {
          name = 'B'
        
          constructor() {
            console.log('B constructor start')
            super()
            console.log('B constructor finish')
          }
        }
        
        new B()

        B constructor start
        A constructor start
        A
        A constructor finish
        B constructor finish

        И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().

        И всё же на это указывает как спецификация ECMAScript, так и рантайм, если вы попробуете любым образом обратиться к любому полю/методу или установить поле наследующего класса до вызова super().


        class B extends A {
          constructor() {
            this.name = 'B'
            super()
          }
        }
        
        new B()

        Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
            at new B (<anonymous>:15:5)
            at <anonymous>:20:1

        Т.е. следующие три варианта эквивалентны за исключением того, что в первом и втором случаях поле устанавливается по семантике [[define]], а в третьем по семантике [[set]].


        // Первый
        class B extends A {
          name = 'B'
        }
        
        // Второй (эквивалент первого с точностью до принципа установки поля)
        class B extends A {
          constructor() {
            super()
            Object.defineProperty(this, 'name', { value: 'B' })
          }
        }
        
        // Третий
        class B extends A {
          constructor() {
            super()
            this.name = 'B'
          }
        }

        К тому же, если переписать код и вместо переменной name использовать метод getName тогда работает так, как автор и ожидал:

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


        К слову в моем описании поэтапной инициализации инстанса метод log() появился уже на этапе присвоения значения полю name.


        Т.е. поля и методы класса при объявлении ведут себя как function, var, let, const в любой области видимости.


        LEXA_JA правильно указал, что методы попадают в prototype, а поля непосредственно в объект (по сути таким образом и соблюдается стандартный порядок объявления функций и переменных в областях видимости, но с поправкой на специфику классов). Прототип класса уже полностью известен и сформирован на момент создания инстанса.


        И ответ на ваш комментарий в соседней ветке: https://github.com/tc39/proposal-class-fields.


        Опять таки я указал способы достижения ожидаемого результата, используя микротаск — всё, что нам нужно это вызвать условную функцию log() в следующем такте исполнения.


      1. Druu
        27.07.2019 05:22

        И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().

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


        1. v1vendi Автор
          27.07.2019 13:21

          Половина комментаторов нам тут говорят, что при инициализации методы предка дёргать нельзя, а вы утверждаете, что предок инициализируется полностью именно для того, чтобы дёргать его методы.


          Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?


          1. Myateznik
            27.07.2019 14:44

            Все методы попадают в прототип т.е. все методы уже определены ещё до инициализации любого инстанса. А вот поля в инстансе устанавливаются именно в момент инициализации и именно в порядке из глубины (от самого первого предка).


            Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?

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


            Пример ниже по своей сути ведёт себя совершенно так же как и ваш случай с классом.


            function greet() {
              console.log(`Hello, ${name}!`)
            }
            
            function constructorA() {
              Object() // В случае с классами на данном месте неявно вызывается конструктор объекта (super()).
              name = 'ninja cat' // Установка условного поля `name` в родительском классе.
              greet()
            }
            
            function constructorB() {
              constructorA() // Это место явного вызова super()
              name = 'world' // Установка условного поля `name` в дочернем классе.
            }
            
            constructorB()
            // Hello, ninja cat!

            Данный пример показывает именно то, как представляется синтаксический сахар класса в рантайме (но на обычных функциях).


            А это упрощенный пример, показывающий, что любая функция/метод использует значение переменной скоупа на момент вызова. Обращаю внимание, что на момент объявления функции greet() в скоупе о переменной name вообще ничего не известно.


            function greet() {
              console.log(`Hello, ${name}!`)
            }
            
            let name = 'ninja cat'
            greet()
            // Hello, ninja cat!
            
            name = 'world'
            greet()
            // Hello, world!


  1. Riim
    26.07.2019 16:45
    -1

    Как обычно набежала куча любителей самоутверждения чтения документации. Хабру явно не хватает возможности отключать комментарии.


    v1vendi на многих ЯП ты бы получил ожидаемый результат:


    class BaseTooltip:
        template = 'baseTemplate'
    
        def __init__(self, content):
            self.render(content)
    
        def render(self, content):
            print('render:', content, self.template)
    
    BaseTooltip('content')
    
    class SpecialTooltip(BaseTooltip):
        template = 'otherTemplate'
    
    SpecialTooltip('otherContent')
    
    # render: content baseTemplate
    # render: otherContent otherTemplate

    , плюс все (которые я видел) обёртки имитирующие классы до ES6 вели себя именно так. Я тоже когда-то попался на этом хоть и заглядываю в спецификацию.


    UPD: одно из решений — использование статических свойств с обращением к ним через this.constructor.


  1. justboris
    26.07.2019 20:19

    Это довольно известная проблема Backbone (и Marionette тоже). Вот тут на гитхабе есть обсуждение с возможными решениями: https://github.com/jashkenas/backbone/issues/3560


  1. zim32
    27.07.2019 16:03

    Все логично. В c++ тоже так. И вообще есть хорошая практика что конструкторы не должны содержать никаких side effect. Но периодически появляются такие статьи как эта