Fluid.js is a JavaScript Model View Controller (MVC) designed to make it easier for webapps to have smooth transitions and animations.
Fluid.js takes heavy inspiration from Backbone.js, and is similar in usage. However, whereas MVCs like Backbone.js discard old content whenever the models change, Fluid.js simply updates the old content. This allows CSS transitions and animations to be incorporated more easily.
- Lightweight (just under 10K minified, 3.5K if also zipped, only dependency is jQuery)
- Easy to use (feels similar to Backbone.js)
- Highly extensible
- Fast (automatically memoizes view updates)
The animations and smooth transitions of native apps are one of the major factors that give them their fun, playful feel. Webapps, in contrast, generally lack animations and have jarring, instantaneous transitions. This is a major reason why webapps, even ones which are well designed and look beautiful in screen shots, still feel clunky and low-tech when you actually use them.
This is totally unacceptable. The WebKit team introduced CSS transitions
7 years ago. More complex
animations using keyframes
date back five years. These CSS features are
actually perfectly designed to give webapps the missing dynamic feel of a
native app. Yet, these features are barely used. The reason has to do with
the way that most of our webapps are rendered.
Generally, when our models change, the MVC will discard all the stale content from the DOM, and replace it with fresh content. While this certainly works, and is easy to think about from a programing POV, it also makes transitions incredibly difficult. This is because the browser cannot smooth out the transition from old content to new when the old content is simply being replaced. Instead, for these CSS features to be truly effective, the old DOM content needs to be updated instead of replaced.
The goal of this project is to build an MVC that works by updating content instead of replacing it, but from a programing POV still feels like you're creating the content from scratch.
Fluid.js follows the MVC structure:
However, much like Backbone.js, the line between its views and its controllers is blurry. A more complete diagram of how Fluid.js works might look like this:
The fill()
function takes information which was passed to the model and
uses it to fill in the template (more on this later). ctrlFuns()
doesn't
actually refer to any specific function, but instead refers to the fact that
a variety of functions might be used to attach events to the DOM elements in
the view (more on that later as well).
Information in Fluid.js follows the following pattern:
As you can see, models pass their information into some Root View, and that Root View then passes some information along to its children, which repeat the process recursively. The Root View is attached directly to the DOM, where as the child views are just attached to their parent.
While Fluid.js is similar to Backbone.js once you understand it, it is fairly opaque and can be difficult to learn in the abstract. As such, we have provided an example which you can look at regularly when reading this document.
Models in Fluid.js are (by default) dumb containers. More complex functionality can be added through extensions, though even so they will probably never be as sophisticated as models in something like Backbone.js. A new model is declared as follows:
var model = Fluid.newModel(initialValue);
Once this has been done, model
will have the following methods:
model.get(); //Gets the current value of the model
model.set(val); //Sets & returns the value of the model
model.listen(fun); //Sets up fun to be called whenever the model changes
model.alert(); //Calls all listening functions
model.sub(prop); //Creates a submodel. See details below
It is worth noting that the listeners are called in the order they are installed. So if you want to add some sort of post-processing to an model, simply add a listener directly after declaring the model.
Let's say you create a model person
for a person
var person = Fluid.newModel({name: 'Joe', age: 21});
But now suppose you want a model for just the person's age. You could do so as follows:
var age = person.sub("age");
age
is now a submodel of person
. It has all the same methods as a
regular model, but they're all linked to the age
attribute of person
's
underlying value. So age.get()
is the same as person.get().age
.
Submodels work nearly the same as regular models, except for one caveat:
submodels share a set of listeners with their parent. In some cases this
makes sense:
var person = Fluid.newModel({name: 'Joe', age: 21});
person.listen(updatePersonDisplay);
var age = person.sub("age");
age.set(22);//Calls updatePersonDisplay
In some cases it makes less sense:
var person = Fluid.newModel({name: 'Joe', age: 21});
var name = person.sub("name");
name.listen(updateNameDisplay);
var age = person.sub("age");
age.set(22);//Calls updateNameDisplay
This may change in the future to make more sense.
Typing model.get()
and model.set(newVal)
can be annoying. Especially if
it's really more like
models.familyName.set(models.dad.get().lastName);
Ugh, so ugly, right? Luckily, we've created two sets of alternate syntax.
Use model()
or model.val
instead of model.get()
, and model(newVal)
or
model.val = newVal
instead of model.set(newVal)
. So we could rewrite the
above line as:
models.familyName(models.dad().lastName);
or:
models.familyName.val = models.dad.val.lastName;
Look, it's an improvement, alright?
Note that the .val
syntax uses Object.defineProperty
, and therefore only
works on browsers where Object.defineProperty
is supported on non-DOM
objects. What's more, val
is a non-enumerable property, meaning that it
will not show up in a for...in
loop.
Fluid.js is tightly coupled to a templating engine. The reason for this is that more so than other MVCs Fluid.js needs to really understand how a template works so that in can update the product dynamically instead of having to re-run the template from scratch. Additionally, the templating language for Fluid.js needs to be able to express concepts like Child Views instead of just raw HTML injections. Finally, Fluid.js needs to have explicit and limited syntax in its templating language so that content can be quickly understood and updated.
In that vein, there are four ways to inject values/views in Fluid.js' templating language:
-
<tag attr={{varName}}>
The above will link the attribute
attr
with the variablevarName
-
<tag attr="(some text){{varName}}(more text)">
The above will concatenate raw strings and variable names. For instance, you might want to use
href="{{domain}}.com"
. You can use more than one variable as well (e.g."{{domain}}.{{tld}}"
).There is exactly one restriction placed on raw strings inside the quotes: they cannot contain the same quote character as is used to start/end the string. So
greet="Hello, my name is \"{{name}}\""
is invalid. Instead, you must usegreet='Hello, my name is "{{name}}"'
. Note thatHello, my name's "{{name}}"
is impossible to do with this command, because it uses both types of quotes. This restriction is needed to make this command easily detectable using regular expressions.Because of the above restriction, and because it is generally less efficient than method 1 (including some optimizations behind the scenes), method 1 is the prefered, though only slightly. Commands like
attr="{{varName}}"
will be automatically rewritten toattr={{varName}}
. -
<tag>{{varName}}</tag>
The above will link the inner text of a tag with the variable
varName
. Note that this command cannot be used to set HTML content. It sets text. -
[[viewName]]
The above will inject the view or collection of views in the variable named
viewName
. Note that this command can only be used to create children of some other tag. So templates like<h1>Header</h1> [[content]]
Are not allowed. Instead, please use
<h1>Header</h1> <div>[[content]]</div>
WARNING: Some browsers have weird ways of parsing tags like <body>
, so
such tags are not recommended.
Main Template:
<div>[[header]]</div>
<ul class="id-cards">
[[idCards]]
</ul>
Header Template:
<h1>ID Card List</h1>
ID Card Template:
<li class="id-card" style={{style}}>
<span class="name">{{name}}</span>
<span class="dob">{{dob}}</span>
</li>
Please keep the following diagram (from the overview) in mind while you read this section:
New classes of views are declared as follows:
var ViewClass = Fluid.compileView({
template: /* String */,
fill: /* Function */,
addControls: /* Function */,
updateControls: /* Function */,
noMemoize: /* boolean */
});
All the properties in the above code are optional.
The template
property is the template for the view. The default template
is ""
.
The fill
property is the function which computes the values that are used
to fill in the template. This job includes passing the relevant information
along to Child Views. Once these values are computed, they are returned in
the form of an object, where the key names in the object line up with the
variable names in the template.
The addControls
and updateControls
functions are in charge of attaching
all the relevant events to a view. addControls
is only called when the
view is initialized, updateControls
is called on every update.
The noMemoize
property says that the MVC should always rerun the rendering
code, even if it seems like the view is being passed the same values twice.
This flag is particularly important if one of the arguments is an opaque
object (e.g. instance of a class with private variables), because then the
MVC may not be able to detect changes in the internal state of the arguments.
This flag defaults to false
.
The state of a view is simply the information which it will be rendered based off of. It is an array of values. Root Views and Child Views will be described below, but briefly:
- In a Root View, the state is simply an array containing the values of the models it is based off of
- In a Child View, the state is the information passed to it by its parent
There are two types of views: Root Views and Child Views.
Root Views are not the child of any other view. They are attached directly to the DOM, and are rendered according to information coming directly from models. Child Views on the other hand, have a parent view. They are linked to the DOM only through their parent view, and are rendered based solely on the information that their parents provide them. Thus, information percolates from the models, to the Root Views, through the Child Views, down to the leaf views (views with no children).
Root Views are attached to the DOM and their models as follows:
Fluid.attachView($elem, ViewClass[, model1[, model2[, ...]]])
Where $elem
is an object which will be replaced by the Root View,
ViewClass
is the class of the view, model1, model2, ...
are the models
which the view will be based off of, and [model1.get(), models2.get(), ...]
is the state of the view. Note that Fluid.attachView()
takes a view class,
not a view instance, as its second parameter.
Child Views are attached to their parent during the fill()
function through
commands like the following:
ret.childView = new ViewClass([param1[, param2[, ...]]]);
Where ret
is the object which fill()
will return, childView
is the name
of the Child View in the template, ViewClass
is the class of the child
view, and [param1, param2, ...]
is the state of the view. Note that a new
view is being instantiated every time fill()
is called. This is keeping
with the idea that it should feel like you're creating the view anew on
every update. However, behind the scenes, Fluid.js automatically transfers
the information from this new instance to the old one so that the content can
be updated in place.
To see this all in action, check out the example.
The parameters of the fill()
function are the state of the view. The
function then returns an object which is used to fill in the template. The
key names in this object match the variable names in the template.
By default, fill()
is set to function() {return new Object();}
Recall that the [[viewName]]
syntax in the templating engine can be used to
inject either a single view of a collection of views. In the case of an
individual view, you would simply write:
ret.viewName = new ViewClass([param1[, param2[, ...]]]);
A collection of views can either be an array or an object. In the case of an array, you would write:
ret.viewName = [
new ViewClass1([param1[, param2[, ...]]]),
new ViewClass2([param1[, param2[, ...]]]),
...
]
In the case of an object, you would write:
ret.viewName = {
key1: new ViewClass1([param1[, param2[, ...]]]),
key2: new ViewClass2([param1[, param2[, ...]]]),
...
}
Using an array has the advantage that it's easy to use and the resulting order is explicit. However, there is a potential problem with using arrays. Suppose I had the following code:
ret.users = [new User("Alice"), new User("Bob"), new User("Carol")];
And then on some future call of fill()
I decided to delete Bob
:
ret.users = [new User("Alice"), new User("Carol")];
While our intent was to have Bob
removed from the DOM, Fluid would
misinterpret this as "the third user should be removed, and the second user's
name should be changed to 'Carol'". The way around this problem is to use
objects. We would have started off with:
ret.users = {
a: new User("Alice"),
b: new User("Bob"),
c: new User("Carol")
};
And then changed to:
ret.users = {
a: new User("Alice"),
c: new User("Carol")
};
Fluid would be able to tell that Bob
should be removed because his key name
was removed from the object.
By default, if a collection is an object, the order in which the views are
inserted into the parent view is not well defined, and may even change over
time as the parent view is updated. If you would like the order to be
consistent, you can specify the __SORT__
property, which will sort the
object's keys before using them. You can use any truthy value for
__SORT__
, but if you use a function then that function will be used as the
compare function for the sort. Otherwise, keys are sorted by each
character's Unicode code point value, according to the string conversion of
the key.
If the returned object is missing a property needed to fill in the template,
then that part of the template will not be updated. For instance, if the
template says an input box should be filled in by the val
property, but the
val
property is missing from the object returned by fill()
, then whatever
the user has typed into the input box will be left alone. Note that there is
a difference between a property being set to undefined
and the property
not being specified.
Primarily, the properties of the object returned by the fill()
function are
used directly to fill in the template. However, there may be special
properties in the future.
The parameters of updateControls
are:
true
iff the view has already been initialized- The jQuery object representing representing this view
- The first element of the state
- The second element of the state
- The third element of the state
- Etc.
setControls
has the same parameters, except for the first one, which is
omitted (since setControls
is only called during initialization). Both
functions default to function(){}
Fluid.js allows for deeply integrated extensions. These are done through two
functions: Fluid.extendViews
and Fluid.extendModels
.
The command is used as follows:
Fluid.extendViews({
compile: /* Function */,
modifyTemplate: /* Function */,
init: /* Function */,
control: /* Function */,
preprocessValue: /* Function */,
postValueProcessing: /* Function */
});
All properties are optional.
compile
is run at the start ofFluid.compileView()
, and is passed the same parameters. Note that even thoughFluid.compileView()
only uses one argument, if more are passed to it anyway, those will be seen bycompile
. What's more, since the argument whichFluid.compileView()
uses is an object, andcompile
is passed the same object,compile
can modify the argument whichFluid.compileView()
sees.modifyTemplate
is also run at compile time. It is passed exactly one argument, the view's raw template (as a string). It should then modify the template and return the result.init
is called when the view is initialized. That is, it is called when an instance of the view is first being added to the document.control
is called at the same time as theupdateControls
function.preprocessValue
is called directly before Fluid sets the value of an element in a view. It is passed the value and the element (as a jQuery object) and should modify the value and then return the result.postValueProcessing
is run after Fluid has modified the value of an element in a view. It is also passed the value and the element.
All functions are run with a special this
object, sandboxed from the this
object used by Fluid and the this
objects of other extensions. Each
instance of a view is given its own this
object, making the this
object
an ideal place to put information specific to one instance of a view.
The this
object also has some special helper functions to expose internal
information:
this.getState()
returns the list of arguments which were passed tonew ViewType()
.this.find(sel)
finds all elements in the view which match a selectorsel
. An empty string is considered to match the root elements.
On a technical note, compile
and modifyTemplate
are actually run with a
this
object which is the prototype of the this
object used in the other
functions, with each view type getting its own prototype. The helper
functions mentioned above are attached to the instance, not the prototype.
I am open to adding more hooks into the system. These are just the hooks I needed. Feel free of contact me if you need more for your own extension!
We have included an example of Fluid.extendViews
in fluid-forms.js
,
documented below. The extension does two things: allows
the programmer to create custom input types, and creates easy syntax for
adding listeners to elements of a view. Here's an overview of how it works:
- A new function,
Fluid.defineInputType
, is added by simply usingFluid.defineInputType = function(...) {...}
- In
compile
, the list of listeners is extracted. We'll call this listl
. - In
modifyTemplate
, any custom types are detected. These custom types are replaced with more traditional types (so the template will work properly) and the elements with custom types are marked up with special tags so they can be located later by the template. - In
init
, a lot of variables are initialized, and listeners are added to the elements with custom types to ensure that the formatting is maintained. - In
control
, listeners are added to the elements inl
. - In
preprocessValue
, and formatting for elements of custom types is inserted. - In
postValueProcessing
, the position of the cursor is restored.
The command is used as follows:
Fluid.extendModels({
compile: /* Function */,
init: /* Function */,
alert: /* Function */,
});
All properties are optional.
compile
is run at the start ofFluid.newModel()
, and is passed the same parameters. Just as inFluid.extendViews()
,compile
receives the full argument list, not just the first one. However, unlike inFluid.compileView()
, the argument ofFluid.newModel()
is generally not an object. Thus, thiscompile
function is expected to return a value, and that value will be used to initialize the model.init
is called when the model is initialized, at the end ofFluid.newModel()
. It takes in one argument, which is the model itself.alert
is called whenever the model'sset()
oralert()
function is called, after the listeners have been invoked. It also takes one argument, which is the model itself.
Just as in Fluid.extendViews()
, these functions are run with special,
sandboxed, per-instance this
objects. Unlike Fluid.extendViews()
these
this
objects don't have any special functions or prototypes. They are just
containers for some internal variables.
Again, I am open to adding more hooks into the system. Just contact me!
The fluid-forms.js
file is an extension for Fluid.js that has some features
to eliminate boilerplate code when writing forms and transferring the data
from those forms to models.
listeners
is an optional property of the argument to Fluid.compileView
which is used to add a listener to an element so that whenever that element's
value changes that value is passed on along to a model.
listeners
should be an object, with keys corresponding to selectors for
the relevant elements, and values corresponding to where to push the values
to (e.g. models). For instance, you might see the following:
listeners: {
"input.age": dadModels.age,
"input.firstName": dadModels.firstName,
"input.lastName": [dadModels.lastName, sonModels.lastName]
}
As you can see, one selector can set the value of multiple models if desired. What's more, values don't need to be pushed to models. They can also be pushed to functions:
listeners: {
"input.name": function(name) {console.log(name);}
}
Also, listeners
can be a function returning an object rather than an
object directly. In that case, the parameters to the function are the same
as they are for the fill()
function.
The default value for listeners
is {}
. As a special case, the empty
string ""
is interpreted as the selector for the root of the template.
Allows the programmer to define new types for <input>
tags (or create
fallback implementations for HTML5 types). For instance, if you wanted
to define a type for inputting integers:
Fluid.defineInputType("integer", {
typeAttr: ["integer", "number"],
validate: /^-?\d*$/
});
When Fluid.js was parsing a template, if it encountered an input tag with
type="integer"
, it would do the following:
- See if the input type
"integer"
is supported. If not, try"number"
. If that isn't supported either, default to"text"
. - Add a listener to the element so that every time the value changes, it is
checked against the regex
/^-?\d*$/
. If it does not match the regex, the user's most recent change will be reverted. So, for instance, if the user the characters1
,2
,3
,.
,4
in order, Fluid.js will be fine with the first three, but revert the.
, and then allow the4
, making the final result"1234"
. However, if the user pastes the string"123.4"
from the clipboard directly into an empty input box, Fluid.js will revert back to the empty input box, making the result""
.
In general, the syntax for the command is as follows:
Fluid.defineInputType("type-name", {
typeAttr: /* Array of strings */,
validate: /* Function or RegExp or Object of the two */,
format: /* Function or Object of Functions */,
formatChars: /* Function or RegExp or Object or the two */
});
All properties are optional.
typeAttr
is a list of values to use for the tag's type
property. Values
to the front of the list will be tried first. If none of the values in the
list are supported, "text"
is used. By default, this property is an array
containing just the type-name
. if validate
, format
and/or
formatChars
object, then that object will be used to select a validator/
formatter/format character set based on which type attribute is actually used.
validate
is used to check if an input is valid. If it is a regex, then
the input much match that regex. If it is a function, then that input, when
passed into the function, must cause the function to return a truthy value.
By default, this property is function() {return true;}
format
is used to format an input so that it will be displayed in a better
format. formatChars
is used to determine which characters are just being
used for formatting vs being part of the element's value. For instance, you
might define a fallback implementation for telephone numbers as follows:
Fluid.defineInputType("tel", {
validate: /^\d*$/,
format: function(val, type) {
//If browser supports "tel" type, it will handle the formatting
if(type == "tel")
return val;
//We will assume that it's a US phone number
if(!val || val.length == 0)
return "";
else if(val.slice(0,1) == "1") {
return "1" +(val.length <= 1 ? "" : " ("+val.slice(1,3)+")" +
(val.length <= 4 ? "" : " " +val.slice(4,3) +
(val.length <= 7 ? "" : "-" +val.slice(7) )));
} else if(val.length <= 3)
return val;
else if(val.length <= 7)
return val.slice(0,3)+" "+val.slice(3);
else
return "("+val.slice(0,3)+") "+val.slice(3,3)+"-"+val.slice(7);
},
formatChars: /[() -]/
});
What will happen here is that when the user changes the input box, and tel
is not supported by the browser, then Fluid.js will first strip out any
characters matched by formatChars
, then check this stripped value against
validate
, and then finally run format
on the stripped value and put the
reformatted result back into the input box.
- Extensions should be better explained, with some sort of overview
- Make submodel alerts make more sense
- Create a
sync
extension for models similar toBackbone.sync
. - Give option for parallelized computation of subviews
- Give "debug" and "product" versions of the code
- Switch to a better parser for the templates, instead of trying to use regular expressions
- Allow for pre-compiled templates
Also feel free to contact me through any of the normal GitHub methods.
See the LICENSE file for license rights and limitations.