Knockout Templates

Where checkout's html templates live, how Magento's attribute bindings desugar, and how a template finds its view model.

Syntaxtemplate: 'Vendor_Module/note-hint' -> view/frontend/web/template/note-hint.html

A uiComponent renders an .html file, not a .phtml. The file holds markup plus Knockout bindings and no PHP, because it is fetched by the browser and rendered there.

Finding the file

A template name such as Vendor_DeliveryNote/note-hint is resolved by inserting template after the module name and appending .html, then loading it with the RequireJS text plugin. So the file belongs at app/code/Vendor/DeliveryNote/view/frontend/web/template/note-hint.html and is served from pub/static.

Bindings, short and long

Plain Knockout puts bindings in a data-bind attribute. Magento preprocesses every template through a renderer that turns a recognised attribute into the matching binding, so text="label" becomes data-bind="text: label", and translate="'Total'" becomes the i18n binding. It also accepts element forms: <if args="...">, <each args="..." render=""> and <render args="..."> stand in for the <!-- ko ... --> virtual comments. They are the same bindings; core templates mix both forms, so learn to read each.

Finding the view model

Bindings are evaluated against the current Knockout context. The scope binding sets that context: data-bind="scope: 'checkout.steps.billing-step.payment'" looks that name up in the registry and makes the component $data for everything inside. Inside a component's own template, getTemplate() simply returns its template property.

What trips people up

Templates are static files. In production mode an edited .html changes nothing until bin/magento setup:static-content:deploy -f runs; in developer mode, clear the browser cache and run bin/magento cache:flush.

Example

Example · html
<!-- app/code/Vendor/DeliveryNote/view/frontend/web/template/note-hint.html -->
<div class="field delivery-note" if="isEnabled">
    <label class="label" attr="for: 'delivery-note'">
        <span translate="'Delivery note'"></span>
    </label>

    <div class="control">
        <textarea id="delivery-note"
                  rows="3"
                  ko-value="note"
                  attr="maxlength: maxLength"></textarea>
        <span class="note" text="remaining()"></span>
    </div>

    <each args="getRegion('extra')" render=""></each>
</div>

When to use it

  • Override a core checkout template to change its markup.
  • Read a third-party module's template that uses attribute bindings instead of data-bind.
  • Put a Knockout component on a non-checkout page.
  • Work out why an edited template has no effect on a production site.

More examples

The same template in longhand

Byte for byte a different file, identical behaviour: this is what the renderer produces from the shorthand version. Attribute shorthand reads better in long templates, but the data-bind form is what Knockout's own documentation and error messages use, and some bindings have no attribute shorthand, so you will write both. Note the nested virtual comments replacing <each ... render>: the outer loop walks the region, the inner one renders each child's own template.

Example · html
<!-- app/code/Vendor/DeliveryNote/view/frontend/web/template/note-hint.html -->
<!-- ko if: isEnabled -->
<div class="field delivery-note">
    <label class="label" data-bind="attr: {for: 'delivery-note'}">
        <span data-bind="i18n: 'Delivery note'"></span>
    </label>

    <div class="control">
        <textarea id="delivery-note"
                  rows="3"
                  data-bind="value: note, attr: {maxlength: maxLength}"></textarea>
        <span class="note" data-bind="text: remaining()"></span>
    </div>

    <!-- ko foreach: getRegion('extra') -->
        <!-- ko template: getTemplate() --><!-- /ko -->
    <!-- /ko -->
</div>
<!-- /ko -->

The view model behind it

`tracks` declares note as an ES5 accessor backed by an observable, so the template reads it as a plain property while still updating. That is the alternative to observe(), which exposes it as a function instead. The template name in defaults is what getTemplate() returns, and changing it is the one-line way to point a core component at your own markup.

Example · javascript
// app/code/Vendor/DeliveryNote/view/frontend/web/js/view/note-hint.js
define(['uiComponent'], function (Component) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_DeliveryNote/note-hint',
            maxLength: 255,
            note: '',
            tracks: {
                note: true
            }
        },

        remaining: function () {
            return this.maxLength - (this.note || '').length;
        }
    });
});

Attaching a component outside checkout

Checkout gets its component tree from a block argument, but anywhere else you bootstrap one yourself: a container with a scope binding, a virtual template binding inside it, and an x-magento-init block handing a components object to Magento_Ui/js/core/app. This is the pattern to copy when a client wants a Knockout widget on the cart or product page. The scope name must match the key in components, or the page renders an empty div with no error.

Example · php
<?php
// app/code/Vendor/DeliveryNote/view/frontend/templates/hint.phtml
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
?>
<div id="delivery-note-hint" data-bind="scope: 'deliveryNoteHint'">
    <!-- ko template: getTemplate() --><!-- /ko -->
</div>
<script type="text/x-magento-init">
{
    "#delivery-note-hint": {
        "Magento_Ui/js/core/app": {
            "components": {
                "deliveryNoteHint": {
                    "component": "Vendor_DeliveryNote/js/view/note-hint",
                    "maxLength": 255
                }
            }
        }
    }
}
</script>

Discussion

  • Be the first to comment on this lesson.