- Magento 2: Defaults, uiElement, Observables, and the Fundamental Problem of Userland Object Systems
- Magento 2: Javascript Primer for uiElement Internals
- Tracing Javascript’s Prototype Chain
- Magento 2: uiElement Standard Library Primer
- Magento 2: Using the uiClass Object Constructor
- Magento 2: uiClass Internals
Last time we described Magento’s uiClass
constructor function. We covered how to use it, and what sort of extra features it has above and beyond javascript’s built in constructor functions. These features include
- The
new
keyword is optional - Default values, and ES6 template literal parsing of same
- Inheriting from other constructor functions
- Inheritance includes parent/super method calling
Today we’re going to dive into the source of the uiClass
module, and figure out how Magento’s core team implemented these features.
As per usual, a few caveats before we begin. The specifics here are Magento 2.1.x, but the concepts should apply across versions. Also, we’re going to assume you’re passingly familiar with javascript’s object system. If you’re not we’ve written a few tutorials to get you up to speed. Related to that, when talking about “prototypes” in javascript, we’ve tried to adopt the “.prototype
property” and “javascript-[[prototype]]” convention in order disambiguate these two related, but very different, features.
The uiClass
Module
Magento’s uiClass
alias points at the Magento_Ui/js/lib/core/class
module.
$ find vendor/magento/ -name 'requirejs-config.js' | xargs ack uiClass
vendor/magento/module-ui/view/base/requirejs-config.js
15: uiClass: 'Magento_Ui/js/lib/core/class',
Let’s take a look at this module’s source from a high level
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
define([
'underscore',
'mageUtils',
'mage/utils/wrapper'
], function (_, utils, wrapper) {
'use strict';
var Class;
/*... lots of stuff ... */
return Class;
});
This module imports three RequireJS dependencies — underscore
(as _
), mageUtils
(as utils
), and mage/utils/wrapper
(as wrapper
). We’ve covered these modules previously.
We also see this module is running in strict mode
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
'use strict';
and defines a Class
variable, which it ultimately returns
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
var Class;
/*... lots of stuff ... */
return Class;
This Class
variable will contain the base requirejs('uiClass')
constructor function.
If we look at the lots of stuff section (again, from a high level), we see that there’s two local/private functions defined (getOwn
and createConstructor
)
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
function getOwn(obj, prop) {
/*...*/
}
/*...*/
function createConstructor(protoProps, consturctor) {
/*...*/
}
Then we see the module populates the Class
variable by calling createConstructor
.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
Class = createConstructor({
/*...*/
});
Finally, the module adds a few properties and methods to the Class
variable via _.extend
before returning Class
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
_.extend(Class, {
/* ... */
});
return Class;
Given the varying code styles in Magento’s RequireJS modules, I’ve found it’s useful to look at a module at this high level to get an idea of what it’s doing. While the code inside is complex, the module itself isn’t. All that’s really happening is
- The module creates a constructor function
- The module adds some new properties to that constructor function
- The module returns the constructor function
Now that we understand the module at this high level, we have a map for continuing our exploration.
Understanding createConstructor
The private createConstructor
function has a few jobs. It
- Automates the creation of javascript object constructor functions with automatic
.prototype
and.prototype.constructor
assignments. -
Offers up a default constructor function that implements the “
new
-less” feature, and the “callinitialize
on instantiation” feature
That is, in native javascript, when you define a constructor function
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
var Foo = function(){
this.message = "Hello";
}
console.log(Foo.prototype);
this function’s .prototype
property is automatically set to a blank object. The createConstructor
method allows you to assign your own object to this .prototype
property, and have that same object’s .constructor
property properly reflect the uiClass
inheritance/instantiation chain. The code related to this is here
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
function createConstructor(protoProps, consturctor) {
var UiClass = consturctor;
/* ... */
UiClass.prototype = protoProps;
UiClass.prototype.constructor = UiClass;
return UiClass;
}
The more important section of this function, however, is the standard default constructor. When Magento does its initial population of the Class
variable
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
Class = createConstructor({/* ... */});
there’s only a single argument passed. This means the constructor
parameter
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
function createConstructor(protoProps, consturctor) {
is undefined. This means Magento creates a default constructor in the conditional block here
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
var UiClass = consturctor;
if (!UiClass) {
UiClass = function () {
var obj = this;
if (!_.isObject(obj) || Object.getPrototypeOf(obj) !== UiClass.prototype) {
obj = Object.create(UiClass.prototype);
}
obj.initialize.apply(obj, arguments);
return obj;
};
}
This default constructor does two things. First,if it detects it was called without the new
keyword, this function instantiates a new object in a way that mimics Magento’s standard constructor function, and returns that object. i.e. it implements the new
-less behavior we’ve previously discussed.
Second, this constructor function calls the initialize
method on the just instantiated object. This is what gives uiClass
objects the _construct
like behavior with the initialize
method.
Using createConstructor
If we take another, fuller, look at Magento’s first use of the createConstructor
method.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
Class = createConstructor({
initialize: function (options) {
/* ... */
},
initConfig: function (options) {
/* ... */
}
});
we see its sole argument is an object with two methods — initialize
and initConfig
. The system will assign this object as Class
‘s .prototype
property. This means all objects instantiated from this Class
function will have an initialize
and initConfig
method available via their parent .prototype
object. This does not give Class
these methods. This only gives objects created with Class
these methods.
At this point, we could use the Class
function to create objects. These new objects could be created with, or without new
. The system would automatically call the initialize
method on these objects.
However, Class
does not have the ability to create objects that inherit from one another. To understand where the inheritance functionality comes from, we need to move on to the _.extend
call
Adding Methods to the Class
Objects
Next up, our module adds two properties to the Class
constructor function
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
_.extend(Class, {
defaults: {
ignoreTmpls: {
templates: true
}
},
extend: function (extender) {
/* ... */
}
});
These are properties that will exist on the constructor function itself — and not on objects the constructor function creates. Remember, at this point in the code Class
is a javascript function, but javascript functions are also objects, and function-objects can have properties added to them just like any other object.
The first property, defaults
, is the defaults
that all inheriting constructor functions will inherit. The only value here is an ignoreTmpls
object that ensures the property named templates
will not, by default, be scanned for template literals.
Of more interest to us at the moment is the extend
property. This adds an extend
method to our Class
constructor function. As we learned last time, if we pass this extend
method a specially formatted object, Magento will return a new constructor function that inherits from another constructor function. This is the extend
method all Magento uiClass
based constructor functions share, and the method we’ll be walking through next.
The Parent, The Child, and the Extender
There are three main objects involved in setting up constructor function inheritance. There’s the parent object. This is the original constructor function we called extend
on. There’s the child object. This is the new constructor function that extend
will return. Finally, there’s the extender object. This is the object we pass to extend
with a new set of defaults
, and new methods for objects instantiated with our new constructor function.
We also need to be aware of two related objects — the parent constructor object’s .prototype
property, and the child object’s .prototype
property. These are the objects the constructor function will assign as the javascript-[[prototype]] for any object instantiated with these constructors.
With the above in mind, let’s take a look at the first few lines of extend
, where the code performs some initialization and variable setup.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
extend: function (extender) {
var parent = this,
parentProto = parent.prototype,
childProto = Object.create(parentProto),
child = createConstructor(childProto, getOwn(extender, 'constructor')),
/* ... */
return _.extend(child, {
__super__: parentProto,
extend: parent.extend
});
}
The object we pass to extend
(the extender) becomes the variable named extender
via a function parameter. Magento assigns the value in this
to the parent
variable, which is the original constructor function we’re trying to extend.
Next, Magento assigns the value of parent.prototype
to parentProto
. Remember, this is not the parent object’s javascript-[[prototype]]. The .prototype
property is the property a constructor function will assign as the javascript-[[prototype]] when instantiating objects.
Next, Magento creates a childProto
variable by creating a new object via Object.create
, specifying the parentProto
object as this new object’s javascript-[[prototype]].
Then, the code uses createConstructor
to create a new constructor function object. This call is worth examining in greater detail, since it’s the child
constructor function that extend
eventually returns.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
child = createConstructor(childProto, getOwn(extender, 'constructor')),
By passing the childProto
in as the first argument, our new constructor function’s .prototype
property will be set to an object with parentProto
set as its javascript-[[prototype]]. (i.e. the parent’s .prototype
property).
The second argument is also interesting
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
getOwn(extender, 'constructor')
Magento checks the extender
object for a property named constructor. The getOwn
method ensures Magento is checking the object itself and not following its prototype-chain all the way back (since there’s always a .constructor
property at the top of the prototype chain).
If there’s no constructor
property, we end up passing a value of false to createConstructor
and createConstructor
acts as it did previously. However, if our extender object does have a constructor
property, the createConstructor
method will use this function as the basis for the returned constructor function. You’ll want to be very careful with this feature. While powerful, if your new constructor function doesn’t contain a similar new
-less and initialize
implementation, your objects will behave differently than standard uiClass
objects.
Finally (putting aside the stuff that happens in /* ... */
comment, which we’ll cover next), extend
uses _.extend
to add a few properties to child
before returning it.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
return _.extend(child, {
__super__: parentProto,
extend: parent.extend
});
The first property is __super__
, which will point back to the parent constructor function’s prototype. It’s not 100% clear why this is here — no other Magento javascript code references it. However, the javascript backbone framework appears to have a similar object extending system, and this system also creates a __super__
property. Perhaps __super__
is here for compatibility with backbone. Or perhaps the Magento core team was inspired by backbone and has future plans for this property.
The second property, extend
, ensures our new constructor function also has the very same extend method assigned to it. This allows new constructor functions created with extend
to be further extended.
With the code discussed above, Magento takes care of ensuring the javascript-[[prototype]] chain will be property setup for objects created via our extend
ed constructor function. Next, let’s take a look at how the extend
method ensures that the new defaults
and methods from the extender object will also be available on objects created via our new constructor function.
Assigning New Methods and Defaults
The middle chunk of extend
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
extender = extender || {};
defaults = extender.defaults;
delete extender.defaults;
_.each(extender, function (method, name) {
childProto[name] = wrapper.wrapSuper(parentProto[name], method);
});
child.defaults = utils.extend({}, parent.defaults || {});
if (defaults) {
utils.extend(child.defaults, defaults);
extender.defaults = defaults;
}
handles processing the extender object
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
//the object passed to extend is the extender
UiClass.extend({
defaults:{
/*... new defaults here */
},
/* ... new method here ... */
})
Let’s look at the start of this block.
extender = extender || {};
Here we have some simple parameter validation. If someone calls extend
without an object, Magento ensures that extender
is assigned a blank object.
Then, curiously, Magento removes the defaults
property from the extender (after storing it in another variable)
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
defaults = extender.defaults;
delete extender.defaults;
This seems weird, but starts to make more sense if we consider the next code block
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
_.each(extender, function (method, name) {
childProto[name] = wrapper.wrapSuper(parentProto[name], method);
});
Here, using _.each
, Magento loops over the remaining properties of the extender object (which, assuming a well behaved client-programmer, should be only methods at this point), and
- Grabs a method with the same name from
parentProto
and wraps the methods withwrapper.super
(we previously discussed wrapSuper here) -
Assigns those methods to the
childPrototype
variable
This step ensures that the new constructor function’s .prototype
property will be assigned any new function from the extender. If the extender contains a method name that already exists on the parent object’s .prototype
property, this is the code that “wraps” the function so we can call the parent with this._super
. If parentProto[name]
is null/undefined, wrapper.wrapSuper
just returns the original function.
That takes care of the methods from extender
. The only thing left is the defaults
object.
First, Magento copies the defaults
object from the parent constructor function to the child constructor function (or assigns an empty object if parent.defaults
isn’t there).
http://alanstorm.com/magento-2-uielement-standard-library-primer/
child.defaults = utils.extend({}, parent.defaults || {});
This copy uses utils.extend
, which does a deep copy of the object. Then, if the extender object contained a .defaults
property, Magento merges those values into the new child.defaults
object.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
if (defaults) {
utils.extend(child.defaults, defaults);
extender.defaults = defaults;
}
This means the child constructor function will inherit the defaults
from the parent constructor function, but anything in the extender will replace the parent default.
Also, this is where Magento re-adds the defaults
property to the extender
object, ensuring that extender
remains unchanged if end-user-programmers do something like
var extender = {...};
UiClass.extend(extender);
SomeotherThingThatNeedTheDefaulsProperty(extender);
This deletion
and re-adding does introduce a small possibility of race conditions if the extender object is used is something like an ajax callback, which could fire at any time.
Once extended, Magento finally returns the new constructor function, ready for the end-user-programmer to use as they want.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
if (defaults) {
utils.extend(child.defaults, defaults);
extender.defaults = defaults;
}
return _.extend(child, {
__super__: parentProto,
extend: parent.extend
});
Running Through Object Instantiation
Now that we’ve worked our way through the entire module, let’s consider what happens when a user instantiates an object from a uiClass
constructor function.
When we instantiate an object like this
requirejs(['uiClass'], function(UiClass){
var object = new UiClass({foo:"bar"});
});
Javascript will jump to the anonymous function defined in createConstructor
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
function () {
var obj = this;
if (!_.isObject(obj) || Object.getPrototypeOf(obj) !== UiClass.prototype) {
obj = Object.create(UiClass.prototype);
}
obj.initialize.apply(obj, arguments);
return obj;
};
The this
variable, and therefore the obj
variable, will be an empty javascript object whose javascript-[[prototype]] points to the requirejs('uiClass').prototype
object, since it’s this function that is this object’s constructor function.
Since obj
is already an object, the if
block will be skipped.
Then, the constructor function calls the initialize
method, with this
inside initialize
bound to obj
. All the arguments to the constructor are passed along.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
obj.initialize.apply(obj, arguments);
The initialize
method is not defined directly on obj
, but instead is part of its javascript-[[prototype]].
The return obj
line will be executed, but is irrelevant when the constructor function is called in new
context.
If we consider the same call without the new
keyword
requirejs(['uiClass'], function(UiClass){
var object = UiClass({foo:"bar"});
});
Again, javascript will jump to the function defined in createConstructor
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
function () {
var obj = this;
if (!_.isObject(obj) || Object.getPrototypeOf(obj) !== UiClass.prototype) {
obj = Object.create(UiClass.prototype);
}
obj.initialize.apply(obj, arguments);
return obj;
};
However, since we called the requirejs('uiClass')
function without the new
keyword, the obj
variable will either be undefined
, or equal to the window
object, (depending on whether your javascript engine properly passes on the "use strict"
context to this anonymous function).
In either case, this time the if
block will run, and the obj
will be redefined, with its javascript-[[prototype]] pointing at the UiClass.prototype
.
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
obj = Object.create(UiClass.prototype);
This is the same sort of object new UiClass
would have created.
At this point the constructor function behaves the same as before
//File: vendor/magento//module-ui/view/base/web/js/lib/core/class.js
obj.initialize.apply(obj, arguments);
return obj;
except this time the return obj
is important. Since we invoked the constructor function without the new
keyword, the value returned by the function is the actual value returned.
The same thing happens for other constructor functions, such as requirejs('uiElement')
or requirejs('uiCollection')
. All these objects use this default constructor function.
The only objects this would not happen for are those defined with a constructor
in their defaults
. For what it’s worth, Magento doesn’t appear to do this anywhere in their core code.
Wrap Up
While tricky, now that we understand uiClass
objects from head to toe, we’re much better equipped to debugged common problems like data loading and Knockout.js template rendering.
Next time, we’ll round the home stretch and give uiElement
objects the same sort of in depth look.