While KnockoutJS bills itself as an MVVM (model, view, view model) framework, PHP developers will find the model portion a little thin. KnockoutJS itself has no native concept of data storage, and like many modern javascript frameworks it was designed to work best with a service only backend. i.e. KnockoutJS’s “Model” is some other framework making AJAX requests to populate view model values.
Something else that might catch you off guard with KnockoutJS is it’s not a “full stack” javascript application framework (and to its credit, doesn’t bill itself as such). KnockoutJS has no opinion on how you include it in your projects, or how you organize your code (although the documentation makes it clear the KnockoutJS team members are fans of RequireJS).
This presents an interesting challenge for a server side PHP framework like Magento. Not only is there a degree of javascript scaffolding that needs to surround KnockoutJS, but Magento 2 is not a service only framework. While the new API features of Magento 2 are making strides in this direction, Magento 2 is not a service only framework. i.e. The backend framework developers also need to build scaffolding to get business object data into KnockoutJS.
Today we’re going to dive into Magento 2’s KnockoutJS integration. By the end of this tutorial you’ll understand how Magento 2 applies KnockoutJS bindings as well as how Magento 2 initializes its own custom bindings. You’ll also understand how Magento has modified some core KnockoutJS behavior, why they’ve done this, and the additional possibilities these changes open for your own applications and modules.
This article is part of a longer series covering advanced javascript concepts in Magento 2. While reading the previous articles isn’t 100% mandatory, if you’re struggling with concepts below you may want to review the previous articles before pointing to your Magento Stack Exchange question in the comments below.
Creating a Magento Module
While this article is javascript heavy, we’ll want our example code to run on a page with Magento’s baseline HTML. This means adding a new module. We’ll do this the same as we did in the first article of this series, and use pestle to create a module with a URL endpoint
$ pestle.phar generate_module Pulsestorm KnockoutTutorial 0.0.1
$ pestle.phar generate_route Pulsestorm_KnockoutTutorial frontend pulsestorm_knockouttutorial
$ pestle.phar generate_view Pulsestorm_KnockoutTutorial frontend pulsestorm_knockouttutorial_index_index Main content.phtml 1column
$ php bin/magento module:enable Pulsestorm_KnockoutTutorial
$ php bin/magento setup:upgrade
These commands should be familiar to anyone who’s worked their way through the Magento 2 for PHP MVC developers series. Once you’ve run the above, you should be able to access the following URL in your system
http://magento.example.com/pulsestorm_knockouttutorial/
and see the rendered app/code/Pulsestorm/KnockoutTutorial/view/frontend/templates/content.phtml
template. Pestle isn’t mandatory here — if you have a preferred way of working with a page in Magento, feel free to use it.
RequireJS Initialization
In our previous article, and in the official KnockoutJS tutorials, KnockoutJS initialization is a simple affair.
object = SomeViewModelConstructor();
ko.applyBindings(object);
For tutorial applications, this makes sense. However, if you were to keep all your view model logic, custom bindings, components, etc. in a single chunk of code, KnockoutJS would quickly grow un-manageable.
Instead, Magento’s core team has created the Magento_Ui/js/lib/ko/initialize
RequireJS module that, when listed as a dependency, will perform any and all KnockoutJS initialization. You can use this module like this
requirejs(['Magento_Ui/js/lib/ko/initialize'], function(){
//your program here
});
One interesting thing to note about this RequireJS module is it returns no value. Instead, the sole purpose of listing the RequireJS module as a dependency is to kickoff Magento’s KnockoutJS integration. This might confuse you when you see it in the wild. For example, consider this code from a different Magento RequireJS module.
#File: vendor/magento/module-ui/view/base/web/js/core/app.js
define([
'./renderer/types',
'./renderer/layout',
'Magento_Ui/js/lib/ko/initialize'
], function (types, layout) {
'use strict';
return function (data) {
types.set(data.types);
layout(data.components);
};
});
Three RequireJS dependencies are declared,
#File: vendor/magento/module-ui/view/base/web/js/core/app.js
[
'./renderer/types',
'./renderer/layout',
'Magento_Ui/js/lib/ko/initialize'
]
but only two parameters are used in the resulting function
#File: vendor/magento/module-ui/view/base/web/js/core/app.js
function (types, layout) {
//...
}
It’s not clear to me if this is a clever bit of programming, or if its something that violates the spirit of RequireJS. Maybe it’s both.
Regardless, the first time you use this library in your own RequireJS based programs Magento will initialize KnockoutJS. Subsequent inclusions will effectively do nothing, as RequireJS caches your modules the first time you load them.
KnockoutJS Initialization
If we take a look at the source of of the Magento_Ui/js/lib/ko/initialize
module
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
'ko',
'./template/engine',
'knockoutjs/knockout-repeat',
'knockoutjs/knockout-fast-foreach',
'knockoutjs/knockout-es5',
'./bind/scope',
'./bind/staticChecked',
'./bind/datepicker',
'./bind/outer_click',
'./bind/keyboard',
'./bind/optgroup',
'./bind/fadeVisible',
'./bind/mage-init',
'./bind/after-render',
'./bind/i18n',
'./bind/collapsible',
'./bind/autoselect',
'./extender/observable_array',
'./extender/bound-nodes'
], function (ko, templateEngine) {
'use strict';
ko.setTemplateEngine(templateEngine);
ko.applyBindings();
});
We see a program that’s relatively simple, but that also includes nineteen other modules. Covering what each of these modules does is beyond the scope of this article. Consider the following a highlight reel.
The ko
module is an alias to the knockoutjs/knockout
module.
vendor/magento/module-theme/view/base/requirejs-config.js
11: "ko": "knockoutjs/knockout",
12: "knockout": "knockoutjs/knockout"
The knockoutjs/knockout
module is the actual knockout library file. The knockoutjs/knockout-repeat
,knockoutjs/knockout-fast-foreach
, and
knockoutjs/knockout-es5
modules are KnockoutJS community extras. None of these are formal RequireJS modules.
The modules that start with ./bind/*
are Magento’s custom bindings for KnockoutJS. These are formal RequireJS modules, but do not actually return a module. Instead each script manipulates the global ko
object to add bindings to KnockoutJS. We’ll discuss the scope
binding below, but if you’re the curious type try investigating the implementation details of the other bindings. It’s a useful exercise. Hopefully Magento gets us official documentation soon.
The two extender
modules are Magento core extensions to KnockoutJS’s functionality.
The ./template/engine
module returns a customized version of KnockoutJS’s template engine, and is the first customization we’ll dive deeply into.
Magento KnockoutJS Templates
To review, in a stock KnockoutJS system, templates are chunks of pre-written DOM/KnockoutJS code that you can use by referencing their id
. These chunks are added to the HTML of the page via script tags, with a type of text/html
<script type="text/html" id="my_template">
<h1 data-bind="text:title"></h1>
</script>
This is a powerful feature, but presents a problem for a server side framework — how do you get the right templates rendered on a page? How can you be sure the template will be there without recreating it every-time? The KnockoutJS solution for this is to use the component binding with a library like RequireJS, but this means your templates are tied to a specific view model object.
Magento’s core engineers needed a better way to load KnockoutJS templates — and they did this by replacing the native KnockoutJS template engine with the engine loaded from the Magento_Ui/js/lib/ko/template/engine
RequireJS module.
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
'ko',
'./template/engine',
//...
], function (ko, templateEngine) {
'use strict';
//...
ko.setTemplateEngine(templateEngine);
//...
});
If we take a peek at the Magento_Ui/js/lib/ko/template/engine
RequireJS module
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/template/engine.js
/**
* Copyright © 2016 Magento. All rights reserved.
* See COPYING.txt for license details.
*/
define([
'ko',
'./observable_source',
'./renderer'
], function (ko, Source, Renderer) {
'use strict';
var RemoteTemplateEngine,
NativeTemplateEngine = ko.nativeTemplateEngine,
sources = {};
//...
RemoteTemplateEngine.prototype = new NativeTemplateEngine;
//...
RemoteTemplateEngine.prototype.makeTemplateSource = function (template)
{
//...
}
//...
return new RemoteTemplateEngine;
});
we see Magento makes a new object that prototypically inherits from the native KnockoutJS rendering engine, and then modifies a few methods to add custom behavior. If you’re not up on your javascript internals, this means Magento copies the stock KnockoutJS template system, changes it a bit, and then swaps its new template engine in for the stock one.
The implementation details of these modifications are beyond the scope of this article, but the end result is a KnockoutJS engine that can load templates via URLs from Magento modules.
If that didn’t make sense, an example should clear things up. Add the following to our content.phtml
file.
#File: app/code/Pulsestorm/KnockoutTutorial/view/frontend/templates/content.phtml
<div data-bind="template:'Pulsestorm_KnockoutTutorial/hello'"></div>
Here we’ve added a KnockoutJS template
binding and passed it the string Pulsestorm_KnockoutTutorial/hello
. If we reload our page with the above in place, you’ll see an error like the following in your javascript console
> GET http://magento-2-0-4.dev/static/frontend/Magento/luma/en\_US/Pulsestorm\_KnockoutTutorial/template/hello.html 404 (Not Found)
Magento has taken our string (Pulsestorm_KnockoutTutorial/hello
) and used the first portion (Pulsestorm_KnockoutTutorial
) to create a base URL to a view resource, and use the second portion (hello
) with a prepended template
and an appended .html
to finish the URL. If we add a KnockoutJS view to the following file
#File: app/code/Pulsestorm/KnockoutTutorial/view/frontend/web/template/hello.html
<p data-bind="style:{fontSize:'24px'}">Hello World</p>
and reload the page, we’ll see Magento has loaded our template from the above URL, and applied its KnockoutJS bindings.
This feature allows us to avoid littering our HTML page with <script type="text/html">
tags whenever we need a new template, and encourages template reuse between UI and UX features.
No View Model
Coming back to the initialize.js
module, after Magento sets the custom template engine, Magento calls KnockoutJS’s applyBindings
method. This kicks off rendering the current HTML page as a view. If we take a look at that code, something immediately pops out.
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
ko.setTemplateEngine(templateEngine);
ko.applyBindings();
Magento called applyBindings
without a view model. While this is a legal KnockoutJS call — telling KnockoutJS to apply bindings without data or view model logic seems pretty useless. What is a view without data going to be good for?
In a stock KnockoutJS system, this would be pretty useless. The key to understanding what Magento is doing here is back up in our KnockoutJS initialization
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
//...
'./bind/scope',
//...
],
Magento’s KnockoutJS team created a custom KnockoutJS binding named scope
. Here’s an example of using scope — lifted from the Magento 2 homepage.
<li class="greet welcome" data-bind="scope: 'customer'">
<span data-bind="text: customer().fullname ? $t('Welcome, %1!').replace('%1', customer().fullname) : 'Default welcome msg!'"></span>
</li>
When you invoke the scope element like this
data-bind="scope: 'customer'"
Magento will apply the customer view model to this tag and its descendants.
You’re probably wondering — what the heck’s the customer view model?! If you look a little further down in the home page’s source, you should see the following script tag
<script type="text/x-magento-init">
{
"*": {
"Magento_Ui/js/core/app": {
"components": {
"customer": {
"component": "Magento_Customer/js/view/customer"
}
}
}
}
}
</script>
As we know from the first article in this series, when Magento encounters a text/x-magento-init
script tag with an *
attribute, it will
- Initialize the specified RequireJS module (
Magento_Ui/js/core/app
) - Call the function returned by that module, passing in the data object
The Magento_Ui/js/core/app
RequireJS module is a module that instantiates KnockoutJS view models to use with the scope
attribute. Its full implementation is beyond the, um, “scope” of this article, but at a high level Magento will instantiate a new javascript object for each individual RequireJS module configured as a component
, and that new object becomes the view model.
If that didn’t make sense, lets run through an example with the above x-magento-init
. Magento looks in the components
key, and sees one key/object pair.
"customer": {
"component": "Magento_Customer/js/view/customer"
}
So, for the customer
key, Magento will run code that’s equivalent to the following.
//gross over simplification
var ViewModelConstructor = requirejs('Magento_Customer/js/view/customer');
var viewModel = new ViewModelConstructor;
viewModelRegistry.save('customer', viewModel);
If there’s extra data in a specific component object
"customer": {
"component": "Magento_Customer/js/view/customer",
"extra_data":"something"
}
Magento will add that data to the view model as well.
Once the above is done, the view model registry will have a view model named customer
. This is the view model Magento will apply for the data-bind="scope: 'customer'"
binding.
If we take a look at the implementation of the scope
custom binding
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/bind/scope.js
define([
'ko',
'uiRegistry',
'jquery',
'mage/translate'
], function (ko, registry, $) {
'use strict';
//...
update: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
var component = valueAccessor(),
apply = applyComponents.bind(this, el, bindingContext);
if (typeof component === 'string') {
registry.get(component, apply);
} else if (typeof component === 'function') {
component(apply);
}
}
//...
});
It’s the registry.get(component, apply);
line that fetches the named view model from the view model registry, and then the following code is what actually applies the object as a view model in KnockoutJS
#File: vendor/magento/module-ui/view/base/web/js/lib/ko/bind/scope.js
//the component variable is our viewModel
function applyComponents(el, bindingContext, component) {
component = bindingContext.createChildContext(component);
ko.utils.extend(component, {
$t: i18n
});
ko.utils.arrayForEach(el.childNodes, ko.cleanNode);
ko.applyBindingsToDescendants(component, el);
}
The registry
variable comes from the uiRegistry
module, which is an alias for the Magento_Ui/js/lib/registry/registry
RequireJS module.
vendor/magento/module-ui/view/base/requirejs-config.js
17: uiRegistry: 'Magento_Ui/js/lib/registry/registry',
If a lot of that flew over your head, don’t worry. If you want to peek at the data available in a particular scope’s binding, the following debugging code should steer you straight.
<li class="greet welcome" data-bind="scope: 'customer'">
<pre data-bind="text: ko.toJSON($data, null, 2)"></pre>
<!-- ... -->
</li>
If you’re one of the folks interested in diving into the real code that creates the view models (and not our simplified pseudo-code above), you can start out in the Magento_Ui/js/core/app
module.
#File: vendor/magento/module-ui/view/base/web/js/core/app.js
define([
'./renderer/types',
'./renderer/layout',
'Magento_Ui/js/lib/ko/initialize'
], function (types, layout) {
'use strict';
return function (data) {
types.set(data.types);
layout(data.components);
};
});
This module has a dependency named Magento_Ui/js/core/renderer/layout
. It’s in this dependency module that Magento initializes the view models, and adds them to the view model registry.
#File: vendor/magento/module-ui/view/base/web/js/core/renderer/layout.js
The code’s a little gnarly in there, but if you need to know how those view models are instantiated, that’s where you’ll find them.
A Component by Any Other Name
One sticky wicket in all this is the word component. This scope
binding + x-magento-init
system is basically a different take on the native KnockoutJS component system.
By using the same component terminology as KnockoutJS, Magento has opened up a new world of confusion. Even the official documentation seems a little confused on what a component is or isn’t. Such is life on a large software team where the left hand doesn’t know what the right hand is doing — and the rest of the body is freaking out about a third hand growing out of its back.
When discussing these features with colleagues, or asking questions on a Magento forum, it will be important to differentiate between KnockoutJS components, and a Magento components.
Changes in the 2.1 Release Candidate
To wrap up for today, we’re going to talk about a few changes to the above in the Magento 2.1 release candidates. Conceptually, the systems are still the same, but there’s a few changes to the details.
First off, KnockoutJS’s initialization now happens in the Magento_Ui/js/lib/knockout/bootstrap
RequireJS module
#File: vendor/magento/module-ui/view/base/web/js/lib/knockout/bootstrap.js
define([
'ko',
'./template/engine',
'knockoutjs/knockout-es5',
'./bindings/bootstrap',
'./extender/observable_array',
'./extender/bound-nodes',
'domReady!'
], function (ko, templateEngine) {
'use strict';
ko.uid = 0;
ko.setTemplateEngine(templateEngine);
ko.applyBindings();
});
Notice that Magento’s core developers moved all the binding loading to an individual module Magento_Ui/js/lib/knockout/bindings/bootstrap
, defined in
#File: vendor/magento/module-ui/view/base/web/js/lib/knockout/bindings/bootstrap.js
Finally, the “Magento Javascript Component” returned by Magento_Ui/js/core/app
has a changed method signature that includes a merge
parameter, and the arguments to the layout
function make it clear layout
‘s signature has changed as well.
#File: vendor/magento/module-ui/view/base/web/js/core/app.js
define([
'./renderer/types',
'./renderer/layout',
'../lib/knockout/bootstrap'
], function (types, layout) {
'use strict';
return function (data, merge) {
types.set(data.types);
layout(data.components, undefined, true, merge);
};
});
Beyond being interesting for folks who who are interested in implementation details, these changes point to the fact that Magento’s javascript modules and frameworks are changing rapidly, and unlike the PHP code, Magento’s RequireJS modules don’t have @api
markings to indicate stability.
Unless you absolutely need to, it’s probably best to steer clear of dynamically changing the behavior of these core modules, and keep your own javascript as separate as possible.