I'm currently working on a browser extension to manage opened tabs and I notice that in JS ES polymorphism works a bit strange when I declare class fields at the top of the class.
Let say that we want to use polymorphism in object initialization.
E.g. we have base class View:
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { }
}
and derived class TabView:
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
}
init() {
this.title = "test";
}
get title() {
return this._title;
}
set title(value) {
this._title = value;
}
}
Now lets try to call simple script in index file to debug this example.
const tabView = new TabView("model");
console.log(tabView.title);
Call stack for this example looks right(read from up do down):
Expected values for TabView:
This example values for TabView:
When I debug this example, it looks like when init()
method is invoked from View
then this
refers to View
class instead of TabView
. The value was saved in View
instance, and TabView
field was still 'undefined'. When I removed _title
field from the top of TabView
class then everything works as I want. Result was the same for the newest version of Firefox and Microsoft Edge.
I like to have class fields written at the top, so I want to ask if is it correct behavior of JS ES or maybe it is a bug that maybe will be correcter in future version of ECMA Script?
When I debug this example, it looks like when
init()
method is invoked fromView
thenthis
refers toView
class instead ofTabView
. The value was saved inView
instance, andTabView
field was still'undefined'
.
Take a look at this code:
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { console.log("View init"); }
}
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
}
init() {
console.log("TabView init");
this.title = "test";
}
get title() {
console.log("get title");
return this._title;
}
set title(value) {
console.log("set title");
this._title = value;
}
}
const tabView = new TabView("model");
console.log(tabView.title);
This logs
TabView init
set title
get title
Which means the constructor calls init
from TabView
which in turn calls the setter for title
.
The reason _title
is undefined
in the end is the specification for class fields (a Stage 3 proposal, at the time of writing). Here is the relevant part:
Fields without initializers are set to
undefined
Both public and private field declarations create a field in the instance, whether or not there's an initializer present. If there's no initializer, the field is set to
undefined
. This differs a bit from certain transpiler implementations, which would just entirely ignore a field declaration which has no initializer.
Because _title
is not initialised within TabView
, the spec defines that its value should be undefined
after the constructor finishes executing.
You have a few options here but if you want to declare _title
as a class field and have a different value for it, you have to give the field a value as part of the TabView
instantiation, not as part of its parent (or grandparents, etc.).
class TabView extends View {
_title = "test"; //give value to the field directly
constructor(viewModel) {
super(viewModel);
}
/* ... */
}
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { }
}
class TabView extends View {
_title = "test"; //give value to the field directly
constructor(viewModel) {
super(viewModel);
}
get title() {
return this._title;
}
set title(value) {
this._title = value;
}
}
const tabView = new TabView("model");
console.log(tabView.title);
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
this._title = "test"; //give value to `_title` in the constructor
}
/* ... */
}
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { }
}
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
this._title = "test"; //give value in the constructor
}
get title() {
return this._title;
}
set title(value) {
this._title = value;
}
}
const tabView = new TabView("model");
console.log(tabView.title);
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
this.init(); //call `init` which will give value to the `_title` field
}
init() {
this.title = "test";
}
/* ... */
}
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { }
}
class TabView extends View {
_title;
constructor(viewModel) {
super(viewModel);
this.init(); //call `init` which will give value to the `_title` field
}
init() {
this.title = "test";
}
get title() {
return this._title;
}
set title(value) {
this._title = value;
}
}
const tabView = new TabView("model");
console.log(tabView.title);
class TabView extends View {
//no declaration here
constructor(viewModel) {
super(viewModel);
}
/* ... */
}
class View {
_viewModel;
constructor(viewModel) {
this._viewModel = viewModel;
this.init();
}
init() { console.log("View init"); }
}
class TabView extends View {
//no declaration here
constructor(viewModel) {
super(viewModel);
}
init() {
this.title = "test";
}
get title() {
return this._title;
}
set title(value) {
this._title = value;
}
}
const tabView = new TabView("model");
console.log(tabView.title);