Concepts
Reactive Templating
s2 is old school templating with a new twist: reactive programming. The template only updates what changed in data. There are no methods to call to update state or re-render, one can simply mutate data. This makes it handy for building Single Page Applications (SPA), or adding islands of interactivity, using mainly template literals and Plain Old JavaScript Objects (POJO).
// s2 embraces JS Proxy
proxy.count++; // will only update parts of a template
// later on in the template...
const template = html`<p>Count: {{count}}</p>`;
// contrived React example
setState((count) => count + 1); // will re-render entire component w/ children
Templates may not inline executable code, which inherently makes templates safe.
The View Model
There is a one-to-one binding from a view model instance to an element in a template. A consequence of this is that the view model resembles the shape of the actual DOM tree. An array of objects correspond to DOM elements ordered by index, a plain object can only correspond to one DOM element, and arrays of primitive values are invalid (but valid when using observable
in the next section).
For example, the following view model and template:
import bind, { html } from "https://esm.run/s2-engine";
const viewModel = {
items: [{ text: "A" }, { text: "B" }, { text: "C" }],
};
const template = html`
<ul>
{{#items}}
<li>{{text}}</li>
{{/items}}
</ul>
`;
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
Will produce the following output:
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
Computed Properties
Computed properties allow the view model to be derived from data. Computed properties are functions on a computed object that may re-run when observable data changes. Using observables allows the data to be decoupled from the view. For example, the same example above with computeds:
import bind, { observable, computed, html } from "https://esm.run/s2-engine";
// passing the second argument, `observable({ ... }, true)` will enable
// nested updates, such as `source.items[i]` to be reactive.
const source = observable({
items: ["A", "B", "C"],
});
const viewModel = computed({
items() { // will re-run when source.items is overwritten
return source.items.map((text) => {
return { text };
});
},
});
const template = html`
<ul>
{{#items}}
<li>{{text}}</li>
{{/items}}
</ul>
`;
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
A useful pattern of composition is to pass a computed as the input to an observable:
const source = observable({
number: 3,
});
const derived = observable(computed({
square: () => Math.pow(source.number, 2), // will be 9
}));
Computed objects can be composed by returning computed objects from computed properties. This can be done mostly as an optimization to minimize the amount of work done in a single computed property.
Server-side Rendering
A DOM implementation is recommended, for example, linkedom. One just needs to create a window object and assign it as bind.window = window
and also parseMustache.window = window
when using Mustache.
One can almost use Mustache.js as a drop-in renderer, with some caveats: Mustache will try to execute functions in data, so to avoid that, the data needs to be rid of functions first. Passing data into JSON.parse(JSON.stringify(...))
should remove all non-serializable data, but cloning the object with only serializable data is recommended. Also, the custom extensions in s2 will not work.
API reference
bind(viewModel, template)
Create a binding from a viewModel
to a template
. When data in the viewModel changes via its proxy, the corresponding DOM nodes update. This function is the default export of the module.
Parameters
viewModel
(object) - data that goes into the template.
template
(Element | string) - the template itself.
Return value
[proxy, fragment]
- An array with the first index as the proxy to the viewModel
, and second index as a DocumentFragment
.
Properties
These can be assigned as properties on the default function. They are mainly for development purposes, or enabling advanced use cases.
bind.shouldUnmountRoot
: disabled by default, but can be enabled. This will automatically call unmount when the DOM nodes mapped to an object are removed. This should be disabled if you need to keep updating nodes that may be removed and appended later.
bind.isDeferred
(experimental): this will defer setting proxy values as a microtask. This might be preferable if there is significant blocking in between updates. However, it can break functionality in case there are updates that depend on a previous update in the same tick. As long as the view model is not read for updates (i.e. if only observable/computed is used) then this should be safe.
bind.window
: set a different global object for server-side rendering.
bind.debug
: use comment nodes and turn on messages in the console. Warning: has a performance impact.
Example
import bind from "https://esm.run/s2-engine";
const template = document.createElement("template");
template.innerHTML = `
<span data-text="count"></span>
<button data-event-click="increment">+</button>
`;
const viewModel = {
count: 0,
increment(event) {
event.preventDefault();
this.count++;
}
};
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
// the viewModel can be interacted with using the proxy
window.proxy = proxy;
registerTemplate(name, template)
Register a template for re-use by name. This is only needed if you are using raw DOM templating.
Parameters
name
(string) - a name for the template.
template
(Element | string) - the template itself.
Return value
undefined
Example
import bind, { registerTemplate } from "https://esm.run/s2-engine";
const mainTemplate = document.createElement("template");
mainTemplate.innerHTML = `
<slot name="greeting" data-template="foo"></slot>
`;
const fooTemplate = document.createElement("template");
fooTemplate.innerHTML = `
<div data-text="message"></div>
`;
registerTemplate("foo", fooTemplate);
const viewModel = {
greeting: { message: "hello" },
};
const [proxy, fragment] = bind(viewModel, mainTemplate);
document.body.appendChild(fragment);
HTML template reference
By default, data-
attributes are used for templating. This section exists for reference.
data-key
, data-template
: for binding keys valued by objects to templates.
data-text
, data-unsafe-html
: for setting text and HTML.
data-class
: shorthand for setting class attribute.
data-classlist-*
: shorthand for toggling classes.
data-value
: for setting input values, also handles binding to input
event automatically.
data-event-*
: for adding event listeners.
data-attribute-*
: for setting arbitrary attributes.
data-style-*
: for setting CSS properties.
data-*
: for setting data attributes (reflection).
parseMustache(html)
Parse a subset of Mustache that translates to the data-
attributes used by s2.
Parameters
html
(string) - an HTML template with Mustache to parse.
Return value
Element
- the parsed template.
Properties
parseMustache.window
: when using server-side rendering, use this to set the window context.
Example
import bind, { parseMustache } from "https://esm.run/s2-engine";
const template = parseMustache(`
<p style="{{style}}">{{message}}</p>
`);
const viewModel = { message: "hello", style: "color:red" };
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
Mustache features
{{text}}
and {{{rawText}}}
inside of an element's body map to textContent
and innerHTML
respectively.
- Attributes such as
<p style="{{style}}">
will map to the element's attributes.
- Mustache sections
{{#section}}...{{/section}}
can contain inlined content, or exactly one partial: {{>partial}}
.
Caveats
- Inverted sections are not supported. This won't work:
{{^section}}{{/section}}
.
- Partials can only exist as the only child of a section. For example,
{{#section}}{{>partial}}{{/section}}
.
- String interpolation is not allowed in attributes. This won't work:
<p style="color: #{{hex}};">
, but this will: <p style="{{style}}">
.
- Dot notation is not supported. This includes:
{{.}}
and {{key.nestedKey}}
.
Custom extensions (not in spec)
- The custom
class:
attribute directive sets individual classes.
- The custom
style:
attribute directive sets individual CSS properties.
- Event listeners such as
<form onsubmit="{{action}}">
will add an event listener on the element.
- Fixing JSX-style self-closing tags (
<div />
) is opt-in functionality: parseMustache.selfClosing = true;
because it changes the behavior, browsers do not parse this as self-closing.
html
[tagged template literal]
Create a template out of a Mustache string. This is the preferred method, since it uses string interpolation to embed templates and does not require registering templates.
Return value
Element
- the parsed template.
Example
import bind, { html } from "https://esm.run/s2-engine";
const nestedTemplate = html`
<span>{{message}}</span>
`;
const template = html`
<p style="{{style}}">
{{#nest}}
${nestedTemplate}
{{/nest}}
</p>
`;
const viewModel = { nest: { message: "hello" }, style: "color:red" };
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
observable(obj, shouldPartiallyReplace)
Creates an observable proxy to the input object. By default, only top level key-values can be observed, unless one composes observables or uses the shouldPartiallyReplace
option. Partial replacement overwrites key-values on objects recursively. The final state may look identical, but the difference is that the same objects are re-used.
Parameters
obj
(object) - the object to proxy.
shouldPartiallyReplace
(boolean) - whether or not to use partial replacement. Default: false
Return value
Proxy
- a proxy for the input object.
computed(definition)
Creates a viewModel
that updates when observables change.
Parameters
definition
(object) - a viewModel
that can define functions that reference observables and re-run when those observables change.
Return value
Object
- a viewModel
object.
Example
import bind, { html, observable, computed } from "https://esm.run/s2-engine";
const template = html`
<p>Count: {{count}}</p>
<p>Square: {{square}}</p>
<button onclick="{{increment}}">+</button>
`;
const source = observable({ count: 0 });
const viewModel = computed({
count: () => source.count,
square: () => Math.pow(source.count, 2),
increment: (event) => {
event.preventDefault();
source.count++;
},
});
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
ref(obj)
When using an observable with partial replacement, a ref
will prevent partial replacement on that object.
Parameters
obj
(object) - an object to exclude from partial replacement.
Return value
Object
- the same object.
Example
import bind, { html, observable, computed, ref } from "https://esm.run/s2-engine";
const template = html`
Fibonacci:
{{#seq}}
<span>{{num}}</span>
{{/seq}}
<button onclick="{{add}}">+</button>
`;
const source = observable({ seq: [0, 1] }, true);
const viewModel = computed({
seq: () => {
return source.seq.map((num) => {
return { num };
});
},
add: (event) => {
event.preventDefault();
const s = source.seq;
source.seq = ref([...s, s[s.length - 2] + s[s.length - 1]]);
},
});
const [proxy, fragment] = bind(viewModel, template);
document.body.appendChild(fragment);
Note that the above code would not work as expected without the use of ref
, because the partial replacement would only set indices on the array, and not replace the array which is required for it to work.
root
[symbol]
Used to get a reference to the root of the current viewModel
, which exists as a property on every nested object.
target
[symbol]
Used to get a reference to the underlying object of the current viewModel
. This may be useful for changing data without triggering updates.
mount
[symbol]
A symbol to define a function to run when the local viewModel
is mounted. The mount function accepts one parameter: a DocumentFragment
before the fragment is inserted into the DOM.
unmount
[symbol]
A symbol to define a function to run when the local viewModel
is unmounted. The unmount function accepts one parameter: any top-level Node before the node is removed from the DOM.
move
[symbol]
A symbol to define a function to run when the local viewModel
has its index changed. The mount function accepts one parameter: a Element
that just moved.