Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?

A future version of Ember will come with a new templating engine known as HTMLBars.

The original motivation for HTMLBars was to allow helpers and properties to have better contextual information about what they were bound to.

So for example, consider a template like this:

<a href="{{url}}">{{link}}</a>

In the current version of Ember's Handlebars, this can't work, because we need to emit a string placeholder into the DOM in order to update a bound value later. The above template would compile down to something like this:

var output = "";
output.push("<a href=\"");
output.push("<script type='text/x-placeholder' id='start-1'></script>");
// insert the value of url if known
output.push("<script type='text/x-placeholder' id='end-1'></script>");
output.push("\">");
output.push("<script type='text/x-placeholder' id='start-2'></script>");
// insert the value of link if known
output.push("<script type='text/x-placeholder' id='end-2'></script>");
output.push("</a>");

Inside of content, we can do document.getElementById('start-1') to update the value when it changes, but that of course won't work inside of an attribute. Because the compiler has no HTML context, it can't do anything smarter.

HTMLBars

HTMLBars solves that problem by using a mixed HTML/Handlebars parser and compiler. Because the compiler understands HTML, it can do more contextually-aware tricks for data binding.

Instead of the above output, the HTMLBars compiler emits something like this (dom below is just an abstraction that can be used for server-side rendering and other uses).:

var output = dom.createDocumentFragment();
var a = dom.createElement('a');
dom.RESOLVE_ATTR(context, a, 'href', 'url');
var text = dom.createTextNode();
dom.RESOLVE(context, text, 'textContent', 'link');
a.appendChild(text);
output.appendChild(a);

A couple of things:

  • The above code is only roughly equivalent to what the actual compiled code will look like, for expository purposes
  • The RESOLVE and RESOLVE_ATTR helpers are actually low-level APIs that are used to build higher-level APIs more like NodeBind.
  • I didn't show it above, but if necessary, we can use empty text nodes to wrap groups of DOM (like those produced by {{{unescaped}}}). In most cases, though, we can just bind directly to a single text node.

An Unexpected Delight

When working on building up the higher level abstraction, I spent some time thinking about the philosophy of Handlebars and data bindings a bit more.

For some background, the reason I built Handlebars in the first place wasn't an attempt to create a "logicless" template engine. Even Mustache, the consummate "logicless" template engine, supports conditionals, iterations, and will execute lambdas for you.

I build Handlebars so that I could start from zero and build up syntactic structures that would support data binding from the get-go. Instead of having to figure out how foo in bar is data-bound, I could define an extremely limited syntax and then define how data bindings worked for each piece of the syntax.

For the uninitiated, Handlebars 1.1 has only the following syntax:

  1. Numbers (1)
  2. Strings ("hello")
  3. Booleans (true, false)
  4. Paths (foo.bar.baz)
  5. Mustache call expressions, which have a: a. helper name (identifier) b. params, made up of 1-4 above c. hash params, whose keys are identifier and whose values are 1-4 above

With this restricted syntax, we were able to build simple abstractions in Ember that worked pretty well.

Unfortunately, with the first version of Ember's data binding support (the ones that shipped with Ember 1.0), every helper that comes with Ember needs to manually manage data binding. Because of the restricted syntax, it works out, but I've been wanting a nicer set of abstractions at the lower level for a while.

Streams!

Disclaimer: The inspiration for much of the API design I have fallen in love with comes from .Net Reactive Extensions.

So let's say you have a simple template that looks like this:

{{join foo bar with=" "}}

Currently, Ember provides an abstraction that allows you do define that join helper:

Ember.Handlebars.helper('join', function(first, second, options) {
  return first + options.hash.with + second;
});

As a high-level abstraction, this is pretty nice, and Ember handles keeping the result up to date whenever foo or bar changes. Under the hood, however, Ember is doing some pretty low-level stuff to manage the data binding.

When working on HTMLBars, I had what turned out to be a very powerful idea:

  • Every {{...}} consumes a stream of values. Whenever the stream produces a new value, HTMLBars (not a higher level abstraction like Ember) is responsible for updating the contents of the {{...}}.
  • Recall that the Handlebars syntax is very simple. In HTMLBars, make every parameter a stream:
    • String, numbers and booleans are streams that produce a single value and immediately complete
    • Higher level abstractions like Ember implement a hook that converts a path into a stream of values that updates as the value changes.

For the purpose of HTMLBars, a stream's API is very simple:

stream.subscribe(function next(value) {
  // update the attribute, text node or range represented by this value
}, function error(err) {
  // error handling in data binding TBD
}, function complete() {
  // tear down no-longer-necessary bookkeeping
});

The really cool thing about streams is that it's really easy to build higher level operations that work on any stream (like map, filter, zip, firstOnly and many many more).

So let's revisit that join helper, using only streams:

// Just a regular helper here
HTMLBars.helper('join', function(first, second, options) {
  // first and second are streams

  return first.zip(second).map(function([ a, b ]) {
    return a + options.hash.with + b;
  });
});

In this example zip is an operation on a stream that combines it with another stream and produces an output stream. The output stream will get a new value any time either the first or second stream gets a new value.

With this in mind, we can see that {{name}} and {{join firstName lastName with=" "}} work the same way. When HTMLBars sees {{name}}, it generates a path stream and subscribes directly. Because join is just a call made up of the same small number of primitives, and we now have a runtime primitive that represents the syntax, we can build up abstractions pretty easily.

Coming Soon: Sub-Expressions

For a long time, people have been asking Handlebars for arbitrary expressions inside of #if, etc. For example:

{{#if foo == bar}}

{{/if}}

Remember that I never set out to build a "logicless" template engine, so I'm not opposed to arbitrary expressions for that reason. However, I do care very much about keeping the syntax of Handlebars small so that it can be easily understood and host data binding implementations.

I've resisted arbitrary expressions because they open up a huge pandora's box of questions about how to bind to things like foo in bar, and because I didn't have a nice way of modelling an alternate expression syntax that could easily be used to build up data-bindable abstractions.

But now, with HTMLBars and streams, I finally have it! (or so I think).

Let's take a look at the simple equality test above, slightly changed to use an alternate expression syntax.

{{#if (equal foo bar)}}

{{/if}}

It's easy enough to build equal as a helper in normal Handlebars:

Handlebars.helper('equal', function(a, b) {
  return a === b;
});

But what about in the data binding case? Again, I've resisted adding a syntax like this unless there was a nice, elegant solution for data binding as well. As it turns out, we now have the elegant solution:

HTMLBars.helper('equal', function(a, b) {
  return a.zip(b).map(function(a, b) {
    return a === b;
  });
});

And the data binding semantics actually works equally well for more complex expressions:

{{#if (even-chars (join firstName (exclaim lastName) with=" "))}}

{{/if}}

The stream primitive makes all of this take care of itself:

HTMLBars.helper('exclaim', function(value) {
  return value.map(function(value) { return value + "!"; });
});

HTMLBars.helper('join', function(first, second, options) {
  first.zip(second).zip(options.with).map(function([ a, b, with ])
});

HTMLBars.helper('even-chars', function(value) {
  return value.map(function(value) {
    return value.length % 2 === 0;
  });
});

The cool thing about this is that we haven't actually expanded the primitive syntax at all here. All we've done is provided a way to take the existing primitive values and compose them into higher level values. And by making each primitive representable as streams, we can build up libraries of abstractions that are data bound without worrying about the pitfalls of binding to arbitrary expressions.

And no, the similarity to Lisp and FRP is not lost on me.

Neat! Looking forward to sans metamorph

trek commented Dec 25, 2013

It looks weirdly like Cloju....

And no, the similarity to Lisp and FRP is not lost on me.

you already noticed.

Why not take something like Bacon.js or RxJS instead of re-implementing that functionality?

kelonye commented Dec 25, 2013

really like reactive,

Wow! Looks like HTMLBars will let me simplify a lot some templates and let me get rid of some helpers.
Thanks for this!

Really, really like the direction this is going.

This looks great! Since Ember already embraces a philosophy of microlibs, do you think you'll do the same for streams? Like mentioned above, Bacon, RxJS or Reactive are a few options. I also have brook.js, a very tiny stream implementation. It'd be great to see ember embrace another library rather than reimplement this again.

I can't wait for HTMLBars for no other reason than to get rid of all the text/x-placeholder clutter in the DOM.

I would still resist allowing arbitrary expressions in the templates. Forcing all the logic to be int he backing controllers really makes for clean markup.

And thank you for finally getting rid of those metamorph tags. You're taking a hint from angular and running an html compiler ;)

This is sounding grt !!
Hope it does what saying

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment