Skip to content

Instantly share code, notes, and snippets.

@shiftyp
Last active August 29, 2015 14:16
Show Gist options
  • Save shiftyp/425a7d40f2acdc9fc7cc to your computer and use it in GitHub Desktop.
Save shiftyp/425a7d40f2acdc9fc7cc to your computer and use it in GitHub Desktop.
Hello MV*

Model View Framework

So this is an example of where objects make a common appearance in front-end development. It's also an exercise in what's known as a model view pattern.

The basic idea is this. First, you have to deal with some data, maybe it came from the user or maybe it came from a call to an API. Second, you have to display this data to the user, and get input from the user.

These two concerns are apt to change as you develop your application, and they won't always change together. For example, changing from a mouseover to a click event has nothing to do with an API, and a change in the request to the server has little to do with what the user interacts with.

Because they change independently, it makes sense in most cases to divide the code up into two separate types of components that interact through a standard set of properties and methods to minimize the effect of changes in either the user interaction or the data / API. We call these two component types the "model" and the "view". If you're thinking interfaces, you're right. If you're thinking objects and prototypes, you're also right.

What I'm going to write is very similar to the Backbone MVC library and depends on prototypal inheritence, but the principle could be the same for any model view framework or inheritence pattern.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello MV*</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.2/css/bootstrap.min.css" rel="stylesheet">
<link href="./style.css" rel="stylesheet">
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js" type="text/javascript"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.2/js/bootstrap.min.js" type="text/javascript"></script>
<script src="./index.js" type="text/javascript"></script>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<form class="form form-inline" id="value-form">
<div class="form-group">
<input class="form-control" type="text"></input>
<button class="btn btn-primary" type="button">Add Value</button>
</div>
</form>
<ul class="list-group" id="value-list">
<!-- This is empty to begin with -->
</ul>
</div>
</div>
</div>
</body>
</html>
/*
Before we can even talk about the model and the view, we
have to talk about how they communicate with each other.
Emulating the built in DOM element event emmitter interface
allows the model and the view to communicate without having
to know how the other operates. This principle of decoupling
can make things frustrating sometimes, but there is a reason
why the browser uses this interface, and we're using it for
that same reason of insulating one piece of functionality
from another to reduce the cost of changes.
*/
function EventEmitter() {
// I'm using a convention here of naming properties with
// an underscore to indicate that this is not part of the
// public API of the class. In other words we're telling
// people not to use this property, and that it's used
// internally.
this._events = {};
}
EventEmitter.prototype.addEventListener = function(event, listener) {
// This says set listeners equal to this._events[event] if it
// exists, otherwise a new array. At the same time it checks
// whether or not this._events[event] exists, and if not sets
// it equal to the same array.
var listeners = this._events[event] = this._events[event] || [];
listeners.push(listener);
};
EventEmitter.prototype.removeEventListener = function(event, listener) {
var listeners = this._events[event];
if (listeners) {
listeners.splice(listeners.indexOf(listener), 1);
}
};
EventEmitter.prototype.triggerEvent = function(event) {
var listeners = this._events[event];
// This grabs the arguments passed to the function
// after the event argument. Since the arguments
// object isn't really an array, we have to use
// the Array prototype's slice method in this
// manner to achieve that.
var args = Array.prototype.slice.call(arguments, 1);
var listeners = this._events[event];
if (listeners) {
for (var i = 0; i < listeners.length; i++) {
listeners[i].apply(this, args);
}
}
};
function Model(data) {
// Applying the EventEmitter constructor function
// to our model object allows it to be configured
// as an event emitter as well as a model. This is
// part of how it "inherits" that objects functionality.
// The other part being the call to Object.create to
// set the Model function's prototype.
EventEmitter.apply(this, arguments);
// We'll initialize `this.data` to an empty object,
// which we'll use to store the data in our model.
this.data = {};
for (var key in data) {
if (data.hasOwnProperty(key)) {
// This set method is defined below, but it
// stores values in our model.
this.set(key, data[key]);
}
}
}
Model.prototype = Object.create(EventEmitter.prototype);
/*
OK, so that seems like the silliest constructor
function ever. Why would I want an object that
simply transfers data from one object to another
object? It turns out that primitive values like
numbers and strings are already wrapped in objects
for the same reason: to expose a set of methods
to interact with the values in that object. So
here we go:
*/
Model.prototype.get = function(key) {
return this.data[key];
};
Model.prototype.set = function(key, value) {
this.data[key] = value;
this.triggerEvent('change', key, value);
};
/*
So these functions might seem a little superfluous at
first glance. What if your API returned an object with
a key called "toString" though, and you still wanted
your data object to have a toString method? This solves
that issue by using one object to store the data, and
wrapping it in an object to expose an API.
So this class becomes a wrapper for our data, and we can
extend it if need be to add methods specific to the type
of data we're modeling. We could also add methods that do
our ajax requests if our data is coming or going to a server,
which is extremely convenient.
Also, because our model is an event emitter and a "change"
event is triggered when we set properties, we can "watch"
and react to changes in model values. This turns out to
be extremely useful.
Here's another type of model object, a collection. This
is basically an object that wraps an array of models. it
is also an event emitter and triggers 'add' and 'remove'
events when a model is added or removed.
*/
function Collection(models) {
// We also want our collection to behave like an event
// emitter, so we apply the EventEmitter function to
// `this` (the collection object) here as well.
EventEmitter.apply(this, arguments);
// We initialize `this.models` to an empty array which
// we'll use to store our models.
this.models = [];
if (models) {
// So what this says is "For each model, call my
// add function, and make it so that `this` in the
// add function is the same as `this` here in the
// constructor function.
models.forEach(this.add, this);
}
}
Collection.prototype = Object.create(EventEmitter.prototype);
Collection.prototype.add = function(model) {
// This line says "If the `model` variable is a plain
// object and not a `Model`, create a `Model` object
// out of it. This ensures that our collection is only
// storing models, and makes it more convenient to add
// data to our collection.
if (!(model instanceof Model)) model = new Model(model);
this.models.push(model);
this.triggerEvent('add', model);
};
Collection.prototype.remove = function(model) {
var modelIndex = this.models.indexOf(model);
if (modelIndex !== -1) {
this.models.splice(this.models.indexOf(model), 1);
this.triggerEvent('remove', model);
}
};
Collection.prototype.forEach = function(cb, context) {
// Here we are giving our collection it's own `forEach`
// method that acts just like the `Array` object's built
// in `forEach` method.
this.models.forEach(cb, context);
};
Object.defineProperty(Collection.prototype, 'length', {
// This is the ES5 syntax for creating a read only
// property called `length`. We want people to be
// able to access the number of models in our
// collection using `collection.length` just like
// a regular property, but we don't want them to
// be able to modify it.
get : function() {
return this.models.length;
}
});
/*
So this is our basic view. It takes either a collection or
a model It defines a single root dom element associated with
the view. If you pass one it wraps it in a jQuery object, and
if you don't it creates a new div or whatever tagname you
specify.
*/
function View(options) {
EventEmitter.apply(this, arguments);
if (options.model) this.model = options.model;
if (options.collection) this.collection = options.collection;
if (options.el) {
this.$el = $(options.el);
} else {
this.$el = $(document.createElement(this.tagName));
}
if (this.className || options.className) {
this.$el.attr('class', this.className || options.className);
}
}
View.prototype = Object.create(EventEmitter.prototype);
View.prototype.tagName = 'div';
View.prototype.render = function() {
// We haven't defined a tempalate function on the prototype,
// this is something that individual views can choose to
// implement based on thier needs.
if (this.template) {
this.$el.html(this.template());
}
};
/*
So now that we have a basic framework, we can get started
writing our practical application. Basically what this app
does is take the value from an input and put it into a list.
You could imagine though that the data from the input is
sent to the server instead, or the data in the list is
generated by an api call.
So this is our first view. It mediates click events on a button,
grabs some data from an input, puts the data into a model, and
adds it to a collection.
*/
function FormView() {
View.apply(this, arguments);
}
FormView.prototype = Object.create(View.prototype);
FormView.prototype.render = function() {
// Since this view doesn't have a template, this call to
// the View's render function does nothing, but it's a
// good practice to do it just the same in case the
// internal workings of the view change in the future.
View.prototype.render.apply(this, arguments);
this.$el.find('button').on('click', this.getInput.bind(this));
};
FormView.prototype.getInput = function() {
var value = this.$el.find('input').val();
var model = new Model({
value : value
});
this.collection.add(model);
};
/*
This view takes a collection and creates a bunch of
list item views using the models in the collection.
it also listens for changes in the collection and
adds ListItemViews for the new models.
*/
function ListItemView() {
View.apply(this, arguments);
}
ListItemView.prototype = Object.create(View.prototype);
ListItemView.prototype.tagName = 'li';
ListItemView.prototype.className = 'list-group-item';
ListItemView.prototype.template = function() {
// I've trivially wrapped the model value in a span to illustrate
// how you can use this function to generate HTML
return '<span>' + this.model.get('value') + '</span>';
};
function ListView() {
View.apply(this, arguments);
this.children = [];
this.collection.addEventListener('add', this.addListItem.bind(this));
}
ListView.prototype = Object.create(View.prototype);
ListView.prototype.render = function() {
View.prototype.render.apply(this, arguments);
this.collection.forEach(this.addListItem, this);
};
ListView.prototype.addListItem = function(model) {
this.addChild(new ListItemView({
model: model
}));
};
ListView.prototype.addChild = function(view) {
this.$el.append(view.$el);
this.children.push(view);
view.render();
};
ListView.prototype.removeChild = function(view) {
var viewIndex = this.children.indexOf(view);
if (viewIndex !== -1) {
view.$el.remove();
this.children.splice(viewIndex, 1);
}
};
/*
Here is how it all comes together:
*/
$(function() {
var valueCollection = new Collection([{
value: 'Try adding more items to the list...'
}]);
var formView = new FormView({
el: '#value-form',
collection: valueCollection
});
var listView = new ListView({
el: '#value-list',
collection: valueCollection
});
/*
That's the whole setup. Now we render our views:
*/
formView.render();
listView.render();
/*
And that's it! Now everything works, and because
we've divided the concerns into separate objects
we can easily add new functionality. If you want
to play around with it, try adding a button to the
list items and write some code that removes them
from the list.
*/
});
body {
padding-top: 50px;
}
ul {
margin-top: 50px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment