Magento 2 – How/Where is the `getTemplate` Knockout Function Bound?

knockoutjsmagento2

Many Magento backend pages contain the following in their source code

<!-- ko template: getTemplate() --><!-- /ko -->

I understand (or think I do?) that <!-- ko template is a KnockoutJS container-less template binding.

What's not clear to me is — what context is the getTemplate() function called in? In the examples I've see online, there's usually a javascript object after the template:. I'm assuming that getTemplate is a javascript function that returns an object — but there's no global javascript function named getTemplate.

Where is getTemplate bound? Or, possibly a better question, where does the KnockoutJS application binding happen on a Magento backend page?

I'm interested in this from a pure HTML/CSS/Javascript point of view. I know Magento 2 has many configuration abstractions so (in theory) developers don't need to worry about the implementation details. I'm interested in the implementation details.

Best Answer

The PHP code for a UI component renders a javascript initialization that looks like this

<script type="text/x-magento-init">
    {
        "*": {
            "Magento_Ui/js/core/app":{
                "types":{...},
                "components":{...},
            }
        }
    }
</script>       

This bit of code in the page means Magento will invoke the Magento_Ui/js/core/app RequireJS module to fetch a callback, and then call that that callback passing in the the {types:..., components:...} JSON object as an argument (data below)

#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);
    };
});

The data object contains all the data needed to render the UI component, as well as a configuration that links certain strings with certain Magento RequireJS modules. That mapping happens in the types and layout RequireJS modules. The application also loads the Magento_Ui/js/lib/ko/initialize RequireJS library. The initialize module kicks off Magento's KnockoutJS integration.

/**
 * Copyright © 2016 Magento. All rights reserved.
 * See COPYING.txt for license details.
 */
/** Loads all available knockout bindings, sets custom template engine, initializes knockout on page */

#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();
});

Each individual bind/... RequireJS module sets up a single custom binding for Knockout.

The extender/... RequireJS modules add some helper methods to native KnockoutJS objects.

Magento also extends the functionality of Knockout's javascript template engine in the ./template/engine RequireJS module.

Finally, Magento calls applyBindings() on the KnockoutJS object. This is normally where a Knockout program would bind a view model to the HTML page -- however, Magento calls applyBindings without a view model. This means Knockout will start processing the page as a view, but with no data bound.

In a stock Knockout setup, this would be a little silly. However, because of the previously mentioned custom Knockout bindings, there's plenty of opportunities for Knockout to do things.

We're interested in the scope binding. You can see that in this HTML, also rendered by the PHP UI Component system.

<div class="admin__data-grid-outer-wrap" data-bind="scope: 'customer_listing.customer_listing'">
    <div data-role="spinner" data-component="customer_listing.customer_listing.customer_columns" class="admin__data-grid-loading-mask">
        <div class="spinner">
            <span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
        </div>
    </div>
    <!-- ko template: getTemplate() --><!-- /ko -->
    <script type="text/x-magento-init">
    </script>
</div>

Specifically, the data-bind="scope: 'customer_listing.customer_listing'"> attribute. When Magento kicks off applyBindings, Knockout will see this custom scope binding, and invoke the ./bind/scope RequireJS module. The ability to apply a custom binding is pure KnockoutJS. The implementation of the scope binding is something Magento Inc. has done.

The implementation of the scope binding is at

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/bind/scope.js

The important bit in this file is here

var component = valueAccessor(),
    apply = applyComponents.bind(this, el, bindingContext);

if (typeof component === 'string') {
    registry.get(component, apply);
} else if (typeof component === 'function') {
    component(apply);
}

Without getting too into the details, the registry.get method will pull out an already generated object using the string in the component variable as an identifier, and pass it to the applyComponents method as the third parameter. The string identifier is the value of scope: (customer_listing.customer_listing above)

In applyComponents

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 call to createChildContext will create what is, essentially, a new viewModel object based on the already instantiated component object, and then apply it to all the descendant element of the original div that used data-bind=scope:.

So, what is the already instantiated component object? Remember the call to layout back in app.js?

#File: vendor/magento/module-ui/view/base/web/js/core/app.js

layout(data.components);

The layout function/module will descend into the passed in data.components (again, this data comes from the object passed in via text/x-magento-init). For each object it finds, it will look for a config object, and in that config object it will look for a component key. If it finds a component key, it will

  1. Use RequireJS to return a module instance -- as though the module were called in a requirejs/define dependency.

  2. Call that module instance as a javascript constructor

  3. Store the resulting object in the registry object/module

So, that's a lot to take in. Here's a quick review, using

<div class="admin__data-grid-outer-wrap" data-bind="scope: 'customer_listing.customer_listing'">
    <div data-role="spinner" data-component="customer_listing.customer_listing.customer_columns" class="admin__data-grid-loading-mask">
        <div class="spinner">
            <span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
        </div>
    </div>
    <!-- ko template: getTemplate() --><!-- /ko -->
    <script type="text/x-magento-init">
    </script>
</div>

as a starting point. The scope value is customer_listing.customer_listing.

If we look at the JSON object from the text/x-magento-init initialization

{
    "*": {
        "Magento_Ui/js/core/app": {
            /* snip */
            "components": {
                "customer_listing": {
                    "children": {
                        "customer_listing": {
                            "type": "customer_listing",
                            "name": "customer_listing",
                            "children": /* snip */
                            "config": {
                                "component": "uiComponent"
                            }
                        },
                        /* snip */
                    }
                }
            }
        }
    }
}

We see the components.customer_listing.customer_listing object has a config object, and that config object has a component object that's set to uiComponent. The uiComponent string is a RequireJS module. In fact, its a RequireJS alias that corresponds to the Magento_Ui/js/lib/core/collection module.

vendor/magento/module-ui/view/base/requirejs-config.js
14:            uiComponent:    'Magento_Ui/js/lib/core/collection',

In layout.js, Magento has run code that's equivalent to the following.

//The actual code is a bit more complicated because it
//involves jQuery's promises. This is already a complicated 
//enough explanation without heading down that path

require(['Magento_Ui/js/lib/core/collection'], function (collection) {    
    object = new collection({/*data from x-magento-init*/})
}

For the really curious, if you take a look in the collection model and follow its execution path, you'll discover that collection is a javascript object that's been enhanced both by the lib/core/element/element module and the lib/core/class module. Researching these customizations is beyond the scope of this answer.

Once instantiated, layout.js stores this object in the registry. This means when Knockout starts processing the bindings and encounters the custom scope binding

<div class="admin__data-grid-outer-wrap" data-bind="scope: 'customer_listing.customer_listing'">
    <!-- snip -->
    <!-- ko template: getTemplate() --><!-- /ko -->
    <!-- snip -->
</div>

Magento will fetch this object back out of the registry, and bind it as the view model for things inside the div. In other words, the getTemplate method that's called when Knockout invokes the tagless binding (<!-- ko template: getTemplate() --><!-- /ko -->) is the getTemplate method on the new collection object.