Как стать автором
Обновить

Неожиданный порядок инициализации наследованных классов в JavaScript

Время на прочтение3 мин
Количество просмотров7.8K

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


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

Теги:
Хабы:
+10
Комментарии120

Публикации

Истории

Работа

Ближайшие события