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

от автора

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

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

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

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

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

Конкретно в моей ситуации проблему можно решить или через миксины (https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Classes#Mix-ins)
или передавая template в конструктор, но когда логика приложения требует переопределять большое количество полей, это становится довольно грязным способом.

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


ссылка на оригинал статьи https://habr.com/ru/post/461399/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *