s2-engine

s2 is a template engine that can be easily embedded on any page, since it's toolless. It combines logic-less templates with reactive programming, and is a successor to Knockout or Ember with superior ergonomics and performance.

Import it directly via CDN:

import bind from "https://esm.run/s2-engine";

Get it from npm:

$ npm install s2-engine
  • 🛠️

    Toolless

    No build needed

  • 🤏

    Tiny

    ~6 KB min+br

  • 🦾

    Performant

    1.2x slowdown

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

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.

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

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.


parseMustache(html)

Parse a subset of Mustache that translates to the data- attributes used by s2.

Parameters

Return value

Element - the parsed template.

Properties

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

Caveats

Custom extensions (not in spec)


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

Return value

Proxy - a proxy for the input object.


computed(definition)

Creates a viewModel that updates when observables change.

Parameters

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

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.