Knockout Templates
Where checkout's html templates live, how Magento's attribute bindings desugar, and how a template finds its view model.
template: 'Vendor_Module/note-hint' -> view/frontend/web/template/note-hint.htmlA 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
<!-- 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.
<!-- 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.
// 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.
<?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