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