-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial
HotDrink is a JavaScript framework for building web user interfaces.
Here we give a quick introduction to HotDrink with the newcomer in mind. For the nitty gritty details, you'll want to take the library tour.
We want to acknowledge and thank the creators of Knockout whose API has heavily influenced our own.
- HotDrink was written for the latest version of JavaScript (ECMAScript 5). Since it might not be available on older browsers, you may want to include a shim.
- HotDrink depends on jQuery.
- You may link directly to hotdrinkjs.com for the latest stable version of the library.
For convenience, copy the snippet below into your page's <head>:
<script src="https://raw.github.com/kriskowal/es5-shim/master/es5-shim.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
<script src="http://hotdrinkjs.com/hotdrink.js"></script>We begin with a primitive example and gradually introduce more concepts and features in subsequent sections.
A HotDrink application consists of three major components: the model, the view, and the bindings.
In our first example, the model is a string:
var model = "James";... and the view is a single DOM element:
Howdy, <span id="view"></span>!The bindings connect the model and view and keep them synchronized. Essentially, they are event handlers that move data back and forth between the model and view in response to changes in either. They are constructed by calling a binder with the view and the model. Binding must wait until the view exists; in our examples, that means when the DOM is ready:
[NOTE] For those unfamiliar with jQuery, it adds a global variable named
$which provides the entry point for the jQuery API. In the example below,$is used in two ways:
$(function () { ... })binds a function to be executed when the DOM is ready, i.e., when it has finished loading.$("#view")searches the DOM for the element with id "view" and returns it wrapped in a jQuery object (which provides a richer interface for elements).
$(function () {
hd.binders["text"]($("#view"), model);
});The model can be a value of any type. Above, it was a string, but it could have been an array or an object:
var model = {
name: "James"
};Views can have any type, but each binder is written for only one view type.
The only common trait among the standard binders
is that the view must be a (jQuery wrapped) DOM element. Some of them
restrict the view type even further, e.g., textbox accepts only textboxes
(<input type="text" />).
A binder for a third-party widget toolkit, on the other hand, might expect the
view to be a JavaScript controller object.
In the quick start, we established bindings with an explicit call to the
text binder. Hotdrink offers a more convenient alternative for binding
DOM elements: its automatic binding algorithm.
By sprinkling the DOM with binding specifications in data-bind
attributes, we can let HotDrink call binders for us:
Howdy, <span data-bind="text: name"></span>!$(function () {
hd.bind(model);
});We have spoken in terms of "the" model and "the" view. Those familiar with the Model-View-Controller pattern may picture those terms referring to solitary, monolithic structures. We use the terms a little differently.
Models and views can be nested. The collections of all models and all views
in an application are rooted trees. A binder will connect a single model
with a single view from the respective trees. It may recurse into them (e.g.,
foreach) or it may not (e.g. text).
Until now, we have used "the" model and "the" view to refer to the roots of the respective trees to match the understanding of (what we believe is) most readers. As we discuss models and bindings in the rest of this tutorial, we will generally speak in terms of "a" model and "a" view to refer to any subtree in the respective trees. Only when speaking from the perspective of a single call to a single binder (i.e. a single binding) will we revert to "the" model or "the" view.
HotDrink models are multi-way, multi-output, hierarchical data-flow constraint systems. Even though we have tried our best to insulate the programmer from the details of the system, we recommend everyone read the primer to acquaint themselves with the concepts we reference below.
Regular JavaScript values in a model are treated like constants. Since we assume they never change, binders will read them only once (at binding time).
Model values that change should be HotDrink variables. Then, binders will create the appropriate event handlers to reflect their changes in the view and vice versa:
var model = {
name: hd.variable("James")
};<p>
Name: <input type="text" data-bind="textbox: name" />
</p>
<p>
Howdy, <span data-bind="text: name"></span>!
</p>The value returned by hd.variable and other variable constructors is a proxy. In general, proxies are functions that provide an interface tailored to the kind of variable they wrap. hd.variable, in particular, creates a variable whose proxy returns or assigns its value when called with zero or one arguments, respectively:
var name = hd.variable("James");
var realName = name(); // read the variable
name("007"); // write the variableHotDrink provides a type-inspection function to identify proxies:
hd.isProxy(name) // true
hd.isProxy(realName) // falseGlobal variables may be fine for toy examples, but in real applications we'll want to package our variables into reusable components. With HotDrink, we can create a model class by wrapping a constructor in a call to hd.model:
var Model = hd.model(function (name) {
this.name = hd.variable(name);
});
$(function () {
hd.bind(new Model("James"));
});The simplest connection among variables is a one-way constraint. If variables are the cells in a spreadsheet, then one-way constraints are the formulas.
A constraint represents a relationship among variables. It is defined by a set of methods, each of which can satisfy the constraint by computing new values for its output variables from the values of its input variables. Methods in HotDrink are black boxes; HotDrink only assumes each method is a pure function that satisfies its constraint.
HotDrink grants a convenient way to define a variable written by a single-output method within a one-way constraint: the computed variable. To construct one, pass the method to hd.computed:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.computed(function () {
// Compute area from length and width.
return this.length() * this.width();
});
});
$(function () {
hd.bind(new Model);
});<p>
Length: <input type="text" data-bind="number: length" />
<br />
Width: <input type="text" data-bind="number: width" />
<br />
Area: <span data-bind="text: area"></span>
</p>Whenever the variable's dependencies (i.e., the method's inputs) change, it will be recomputed.
A computed variable can never be written by anything except the method used to declare it.
One of the most distinguishing features of HotDrink is that it supports generalized multi-way constraints
while most JavaScript frameworks stop at one-way constraints. We can start a constraint by calling hd.constraint and then add methods by chaining calls to method. Each method should be prefaced by a declaration of its output variable:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.variable();
hd.constraint()
.method(this.length, function () {
return this.area() / this.width();
})
.method(this.width, function () {
return this.area() / this.length();
})
.method(this.area, function () {
return this.length() * this.width();
});
});<p>
Length: <input type="text" data-bind="number: length" />
<br />
Width: <input type="text" data-bind="number: width" />
<br />
Area: <input type="text" data-bind="number: area" />
</p>In the presence of multi-way constraints, HotDrink will prefer to write the least recently edited variables. We say these variables have the lowest priority. We believe this behavior follows the Principle of Least Surprise for user interfaces. The initial priority order is the same as the declaration order, i.e., variables declared first have highest priority.
Variables can be used in multiple constraints as long as the system remains solvable. There is no general criteria for determining solvability that does not amount to solving, but there is a good rule of thumb: for each pair of constraints that share variables, at least one of them must have a method that does not write to any of the other constraint's variables. In the next example, two constraints share length and width, but one can write to area and the other to perimeter:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.variable();
this.perimeter = hd.variable();
hd.constraint()
.method(this.length, function () {
return this.area() / this.width();
})
.method(this.width, function () {
return this.area() / this.length();
})
.method(this.area, function () {
return this.length() * this.width();
});
hd.constraint()
.method(this.length, function () {
return (this.perimeter() / 2) - this.width();
})
.method(this.width, function () {
return (this.perimeter() / 2) - this.length();
})
.method(this.perimeter, function () {
return 2 * (this.length() + this.width());
});
});<p>
Length: <input type="text" data-bind="number: length" />
<br />
Width: <input type="text" data-bind="number: width" />
<br />
Area: <input type="text" data-bind="number: area" />
<br />
Perimeter: <input type="text" data-bind="number: perimeter" />
</p>Be careful that no method in a multi-way constraint writes a computed variable.
The set of variables of a constraint sum over all of its method's outputs and inputs. HotDrink tries to infer the variables of a constraint from the declared outputs of its methods, but it will miss variables that the methods read but never write. These situations can lead to cycles in the solution.
To deal with this caveat, we can declare a constraint's variables. We only need to declare the variables that HotDrink would miss, but if we want to be on the safe side, we can declare the whole set:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.variable();
this.perimeter = hd.variable();
hd.constraint(this.width)
.method(this.length, function () {
return this.area() / this.width();
})
.method(this.area, function () {
return this.length() * this.width();
});
hd.constraint([this.length, this.width, this.perimeter])
.method(this.width, function () {
return (this.perimeter() / 2) - this.length();
})
.method(this.perimeter, function () {
return 2 * (this.length() + this.width());
});
});
$(function () {
var model = new Model;
hd.bind(model);
model.area(20);
model.perimeter(18);
// If we had not declared each constraint's variables, a cycle would appear
// here because HotDrink would choose the two methods that write the least
// recently edited variables (length and width).
});A method can directly write its output instead of returning the value:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.variable();
hd.constraint()
.method(this.length, function () {
this.length(this.area() / this.width());
})
.method(this.width, function () {
this.width(this.area() / this.length());
})
.method(this.area, function () {
this.area(this.length() * this.width());
});
});A method can have multiple outputs; they must be declared in an array. If we choose to return values for the outputs instead of writing them directly, we must be careful to return them in a matching array, i.e., the values must be in the same order that the outputs were declared:
var Model = hd.model(function () {
this.length = hd.variable(3);
this.width = hd.variable(4);
this.area = hd.variable();
this.perimeter = hd.variable();
hd.constraint()
.method([this.area, this.perimeter], function () {
return [
this.length() * this.width(), // first comes area,
2 * (this.length() + this.width()) // then perimeter
];
})
.method([this.length, this.width], function () {
var negb = this.perimeter() / 2;
var discriminant = (negb * negb) - (4 * this.area());
if (discriminant < 0) return [0, 0]; // no real roots :(
this.length((negb + Math.sqrt(discriminant)) / 2);
this.width((negb - Math.sqrt(discriminant)) / 2);
});
});In methods, the keyword this refers to the innermost hd.model, but it can be overriden. The general convention is to pass the context as the last argument after a method:
var model = {
length: hd.variable(3),
width: hd.variable(4),
area: hd.variable()
};
hd.constraint()
.method(model.length, function () {
// No hd.model here! Need to override "this".
return this.area() / this.width();
}, model)
.method(model.width, function () {
return this.area() / this.length();
}, model)
.method(model.area, function () {
return this.length() * this.width();
}, model);
model.report = hd.computed(function () {
var isRound = Math.floor(this.area()) === this.area();
return "That area is " + (isRound ? "" : "not ") + "a nice round number!";
}, model);<p>
Length: <input type="text" data-bind="number: length" />
<br />
Width: <input type="text" data-bind="number: width" />
<br />
Area: <input type="text" data-bind="number: area" />
<br />
<span data-bind="text: report"></span>
</p>Remember that bindings are constructed by binders. Declarative bindings, using binding specifications and HotDrink's automatic binding algorithm, simply offer a convenient interface to the binders.
Up until now, we've said that binders are called with a view and a model:
hd.binders["text"]($("#view"), model);In fact, the second parameter is called the options. Each binder determines for itself the type of this parameter, but often it is just a model. When it isn't, it is usually a dictionary:
hd.binders["attr"]($("#view"), { title: "007" });The simplest binding specification associates a binder name with some options:
Howdy, <span data-bind="text: 'James'"></span>!When it encounters this specification, the binding algorithm will call the named binder (text) with the view upon which the specification was found (<span>) and the options ('James').
In a binding specification, the options is just a JavaScript expression:
Howdy, <span data-bind="text: ['J', 'a', 'm', 'e', 's'].join('')"></span>!Identifiers used in the options expression must exist in the binding context. The context is an object providing an interface to the model that was passed to the binding algorithm. It holds a reference to the model named $this. When the model is not an object, $this is the only way to get its value:
var model = "James";Howdy, <span data-bind="text: $this"></span>!If the model is an object, then the context has each of its properties:
var model = { name: "James" };Howdy, <span data-bind="text: name"></span>!Considering that options are expressions, the last example is just a shorter way to write this:
Howdy, <span data-bind="text: $this.name"></span>!Multiple bindings can be specified for the same view:
var model = {
name: "James",
codename: "007"
};Howdy, <span data-bind="text: name, attr: { title: codename }"></span>!Views can be nested. The binding algorithm will descend into each view's children, hunting for binding specifications. We've seen it already: calling hd.bind(model) starts the algorithm at the page's <body> element.
Models can be nested as well. Some binders (e.g., foreach) will iterate
over sub-models and manage by themselves the recursion of the binding
algorithm over sub-views.
Often, such a binder will construct a new context, nested within the current
context, for each sub-model. Names in the new context will shadow names in
outer, or ancestor, contexts:
var model = {
name: "James Bond",
actors: [
{ name: "Sean Connery" },
{ name: "Pierce Brosnan" },
{ name: "Daniel Craig", latest: true }
]
};<p>
<span data-bind="text: name"></span> has been played by:
</p>
<ul data-bind="foreach: actors">
<li>
<span data-bind="text: name"></span><span data-bind="ifnot: latest">, and</span>
</li>
</ul>To help reach ancestors, contexts have a few more special properties:
-
$root: The outer-most context. -
$parent: The next ancestor context on the path towards$root.undefinedon$root. -
$parents: An array of ancestor contexts in order from$parentto$root. Empty on$root.
Up to now, our binding options have been constant expressions or HotDrink variables. Occasionally, we may want to give an expression that uses a variable. To make the binding change with respect to the value of the expression, we must encapsulate it within a computed variable in our model. To avoid littering our model with a bunch of computed variables for simple bindings, we can use dynamic expressions, delimited by backquotes (`), in our binding specifications:
var model = {
name: hd.variable("James")
};<p>
Name: <input type="text" data-bind="textbox: name" />
</p>
<p>
HOWDY, <span data-bind="text: ` name().toUpperCase() `"></span>!
</p>Similarly, we may want to define functions for event bindings without going through the verbose JavaScript syntax. In those cases, we can use action expressions delimited by at-symbols (@):
<p>
Name: <input type="text" data-bind="textbox: name" />
</p>
<p>
<button type="button" data-bind="click: @ alert('Howdy, ' + name() + '!') @">
Howdy!
</button>
</p>Here we deviate from the progression of examples to discuss binders in general terms.
HotDrink includes a standard library of binders, but custom binders can be defined to meet an application's needs.
There are many different types of views (e.g., label, textbox, checkbox, dropdown, etc.) and models (e.g., String, Number, Boolean, Array, etc.). Some of these types may be abstractions over several other types. For example, the "truthy" type encompasses the set of types that can be evaluated in a Boolean context (i.e., all types).
Each binder is written for a specific combination of one view type and one model type. (Using abstract types, they may connect multiple view types with multiple model types, but it's easier to think of the relationship as one-to-one.) For example, we've seen the text binder supply <span>s with text from string constants and variables. In general, however, the text binder connects
- a jQuery wrapper around a DOM element that can hold text, e.g., a paragraph (
<p>) or span (<span>), and - a constant or variable whose value is convertible to string, e.g., a
Stringor an object that can be passed toJSON.stringify.
Further, each binder fits within one of four categories, depending on the kind of binding(s) it creates:
- HTML: Sets attributes or text within the DOM based on values in the model.
- Value: Connects interactive views with variables to let users see and edit the model.
- Event: Translates events in the DOM to function calls in the model.
- DOM: Manages the structure of the DOM based on the structure of the model.