Software in 2014
Tim Bray: We’re at an inflection point in the practice of constructing software. Our tools are good, our server developers are happy, but when it comes to building client-side software, we really don’t know where we’re going or how to get there.
While I agree with much of this post, I really don’t think the conclusion is as bad as Tim portrays things. I agree that there are good server side frameworks, and doing things like MVC is the way to go.
I just happen to believe that this is true on the client too – including MVC. Not perfect, perhaps, but more than workable. And full disclosure, I’m firmly on the HTML5-rocks side of the fence.
For starters, while JavaScript is perfectly satisfactory language for many, it does seem to have accumulated some weird quirks. None of that, however, makes JavaScript any less desirable as a compilation target.
While I agree that jQuery reduces the pain of accessing the DOM, there is a future in sight where user authored jQuery will largely be a thing of the past. While I wouldn’t have believed it, I’ve seen it for myself, and I’ll describe it more below.
But first, let’s talk about MVC.
Client Side MVC
On the server, the model is data. On the client, the model can be data too – either from Web Storage or from the server. But it can also be accelerometers, cameras, and contacts. If you need some the these, take a peek at Apache Cordova.
For views, there are HTML fragments, mustache and mustache inspired syntaxes for templating, and yes, CSS. While Sass and its ilk reduce the pain here; a good alternative is to pick up a library like Bootstrap. You might need to tweak it a little, but there is a lot there and the peole who wrote it probably thought about a lot of problems that you may not have thought deeply about. For starters, their markup is responsive, which means that it automatically adjusts based on device characteristics.
Finally, we come to the controller. And by association, routing. There are good frameworks for this, too, on the client. Angular.js and Ember are two exemplars.
For the remainder of this, I’m going to focus on Angular.js. As near as I can tell, a very similar story could be told about Ember. I just happen to be writing an application using Angular.js, and can point to it.
Let me start by describing the application. I’m writing it partly to scratch an itch, and partly to learn a new framework. Before I started, I had never used Angular.js before. I will say that the learning curve for Angular.js (at least for me) is an “S” curve… easy to get started, then it gets harder to advance, then it gets easier again. I seem to have made it past the curve. Personally, I would put a lot of the blame on the documentation which, to my tastes, is a bit too academic; terms like transclude abound. `nuff said.
At the ASF we have a board with 9 Directors, and a couple of hundred officers, give or take. Every month, an agenda is created that 9 Directors review and 50 plus officers contribute to. The artifact is a single file, stored in subversion. Essentially, a superset of the files you can find posted here. The relevant additions include comments and pre-approvals.
As you might imagine, this many people updating a single file, many up to the end of the very end of deadline of the start of the meeting, we have conflicts. To cope, I wrote a tool that allowed me to review individual reports one at a time, collect up comments and pre-approvals, and apply them automatically all at once. In the “production” version of this application, the logic primarily resides on the server, and moving to the next report requires a round trip.
I’m currently rewriting it to move nearly all the logic to the client. You can see the work in progress here.
Guided walkthrough the code
The application starts by downloading the agenda serialized in a pre-processed JSON format, as well as all of the “pending” operations, also serialized in JSON. Both persist on the server as flat files. Once downloaded, traversing to the next report is as easy and as seemless as using most of the HTML5 slide scripts (my current favorite of which is reveal.js, but I digress).
As on the server, processing a page transition involves a router, and you can see my routing as a case statement at the top of app.js. The syntax is Ruby, with a dash of DSL, and sprinkling of JavaScript semantics, but I’ll come back to that.
I also currently include all of the controller logic in the same file. If you disapprove, blame me not Angular.js as you can break this out as you like. As it stands, I have a few large controllers, and several small controllers. As to the former, I intend to go back and refactor soonish.
This brings me to my primary beef with the Angular.js documentation. It would have been helpful to me to be made aware of sooner that while a typical server application would have a single controller serve a dozen or even dozens of pages, a typical angular.js page will have several, or perhaps even dozens of controllers active at any one time. But now you know this too, so you have a head start over where I was at but a few short weeks ago.
Back to the application.
A controller in Angular.js is associated with a DOM node, and therefore all of its children. A controller associated with one of those child nodes inherits instance variables and methods from all controllers associated higher in the tree. A common pattern, therefore, would be to have a controller that controls only one button. Such a controller would have access to everything in the enclosing form.
Let’s walk through an example. One the pages in my application shows comments. On that page is a button that toggles whether or not to show comments that you have seen before. First, here’s the HTML markup for the button itself:
<button class="btn btn-primary" ng-show="seen_comments" ng-controller="ToggleComments" ng-click="click()" > seen</button>
The
class
attribute references classes defined by Bootstrap.
The ng-show
attribute indicates that this button is only to be shown if the
value of seen_comments
is true
. That value is set by a controller.
As you might have figured out, the
ng-controller
attribute indicates which
controller is to handle this DOM Node.
Similarly,
ng-click
indicates which method on that controller to invoke when
the button is clicked.
Finally, the button contains a reference to a label
variable that toggles
between the values of hide
and show
. With that, lets move on to the
controller:
controller :ToggleComments do @label = 'show' def click broadcast! :toggleComments, (@label == 'show') @label = (@label == 'show' ? 'hide' : 'show') end end
This code starts out by setting the instance variable @label
to show
.
A single instance method named
click
is defined which, when run, broadcasts
to every active controller a message containing a
true
or
false
value, and
then proceeds to toggle the value of the label.
The important thing to be aware of by this point is that even if there were no other code in place, what you have seen is enough to toggle the text on the button.
Before proceeding, here is the corresponding JavaScript, exactly as it is produced verbatim, for those who are interested in such:
AsfBoardAgenda.controller("ToggleComments", function($scope, $rootScope) { $scope.label = "show"; $scope.click = function() { $rootScope.$broadcast("toggleComments", $scope.label == "show"); $scope.label = ($scope.label == "show" ? "hide" : "show") } });
The entire source for the comments
view is found in
partials/comments._html.
This contains not only the button you have seen already, but also another
button. Above these is text that shows up if there are no comments. And
above that, a loop that shows comments from selected agenda items. Let’s dive
into that selection:
ng_if: 'item | show : {seen: pending.seen, toggle: toggle}'
This code takes the item
and passes it through a filter called show
,
passing that filter a hash containing two values obtained from the relevant
controller. Here’s the definition of the filter itself:
filter :show do |item, args| return false unless item.comments return true if args.toggle return args.seen[item.attach] != item.comments end
Pretty straightforward stuff:
-
No comment? Return
false
(i.e., don’t show). -
Toggle on? Return
true
(i.e., do show). - Otherwise return a value based on whether the comment is the same as the one previously seen.
Now, let’s look at how values defined in the controller are set:
on :toggleComments do |event, state| @toggle = state end show = filter(:show) watch 'agenda.update + pending.update' do $rootScope.unseen_comments = @agenda.any? { |item| return show(item, seen: @pending.seen) } $rootScope.seen_comments = !Object.keys(@pending.seen).empty? end
The
on
statement responds to the broadcast that was shown above, and sets an
instance variable based on what was passed.
The
watch
statement watches for changes in the value of an expression, and
when it changes it will recompute
unseen_comments
and
seen_comments
using
the exact same filter used in the view. If you look closely, this code makes
use of
Object.keys
which is part of the JavaScript object model, in the midst of a
Ruby expression. There are
tradeoffs
involved here, but
the key point here is that seemless access to the full JavaScript programming model is available.
At this point, we’ve explored views and controllers (and routing and filters). Now let’s briefly touch on models.
A model in Angular.js is a simple class, and often a singleton in that all methods
are class methods and all variables are class variables. The Agenda
class,
for example, defines a self.refresh
method that does a $http.get
, and
calls Agenda.put
with the result it receives. The point is that such
classes can contain arbitrary logic.
Two more stops on this brief tour. First the entire controller for the other button on this page:
controller :MarkSeen do @disabled = false def click @disabled = true # gather up the comments seen = {} Agenda.get().forEach do |item| seen[item.attach] = item.comments if item.comments end data = { seen: seen, agenda: Data.get('agenda') } $http.post('json/markseen', data).success { |response| Pending.put response }.error { |data| $log.error data.exception + "" + data.backtrace.join("") alert data.exception }.finally { @disabled = false } end end
This code initially sets the button to
not disabled
(inexplicably, the
Angular.js core team
refuses
to define an
ng_enabled
attribute).
When the button is clicked, the button itself is initially disabled. Then
the seen comments are then gathered up from the client model. The name of the
agenda file is added, and the result is serialized and sent to the server via
a $http.post
.
If a successful response is received, it the pending values are updated with the values from the server. If an error is received, it is logged and an alert is shown. Either way, the button is re-enabled.
Note the complete lack of direct reference to jQuery in any part of this scenario. Angular.js will work well with jQuery if present, so that’s not an issue, but the point is that the framework will take care of the DOM manipulation so that you don’t have to.
The final stop on this brief, but wirlwhind, tour is the server side of this operation, namely markseen._json:
pending = Pending.get pending['agenda'] = @agenda pending['seen'] = @seen Pending.put(pending) _! pending
This logic fetches the Pending model, updates to entries in the hash based on data sent by the client, puts the model back, and then returns the updated model (which generally contains other values which weren’t updated by this specific operation) back to the client. For completeness, pending.rb contains the logic serializing and deserializing the server model (in this case, using YAML).
Recap
We have a model, view, and controller on the client, seemlessly interacting with the model, view, and controller on the server. Everything (except for a small stylesheet) is defined using Ruby syntax, and is converted to HTML, JavaScript, or directly executed as appropriate. While I chose Ruby, other choices could obviously be made. The Angular.js framework can also be used directly (and browing the generate JavaScript would help show you how to do this), at a cost of some additional learning curve (things like dependency injection, which are taken care of by my mapping filters for angular.js).
The point here being that there are good frameworks out there that do client side MVC. These frameworks (quoting directly from Tim’s original post):
embody[…] a lot of history and hard-won lessons. Crucially, for most of the things you’d want to put in a UI, there’s usually a single canonical solid well-debugged way to do it, which is the top result for the appropriate question on both Google and StackOverflow.
Finally, if you are interested in the Ruby code, you are encouraged to look into wunderbar, it’s associated tutorial, and ruby2js.