Perspectives
Render views on the client OR on the server. Perspectives breaks traditional Rails views into a logic-less Mustache template and a "Perspective", which allows you to render views either on the client or on the server. Building up a thick client that shares the rendering stack with the server allows sites to be SEO friendly and render HTML from deep links on the server for a great client experience, while also incrementally rendering parts of the page if the user already has the site loaded in a browser.
Perspectives was debuted at RailsConf 2014.
Getting Started
In your Gemfile:
gem 'perspectives'
Run the installer:
$ rails generate perspectives:install
Scaffold a resource if you want an example:
$ rails generate scaffold post title:string body:text
Usage
Vanilla perspectives
Perspectives live in app/perspectives
. If you have a perspective called app/perspectives/users/show.rb
,
then it will render the corresponding template from app/mustaches/users/show.mustache
. For example, the following
perspective:
# app/perspectives/users/show.rb
class Users::Show < Perspectives::Base
property(:name) { 'Andrew' }
end
and template
<!-- app/mustaches/users/show.mustache -->
Hello, {{name}}!
would render "Hello, Andrew!
". To render it yourself, you could write:
Users::Show.new.to_html
Users::Show.new.to_json
In order for a property to be available in the Mustache template, you have to explicitly mark it as a property in a perspective. For example:
class Users::Show < Perspectives::Base
property(:name) { 'Andrew' } # declare a property
def another_property
'something else'
end
property :another_property # mark a method as a property
end
If you expect certain inputs, you define those are "params". For example:
class Users::Show < Perspectives::Base
param :user # expects to be passed a "user" object, available as "user"
param :admin, allow_nil: true # can be optionally passed an "admin" param
end
All perspectives also get passed a "context" object when being created. For example, to initialize the above object, we might write:
user = User.find(params[:id])
context = {current_user: current_user}
Users::Show.new(context, user: user)
Which would make a current_user
method available in the perspective. (any key in the context
hash automatically becomes a method on the perspective)
When you render a perspective in a controller, the easiest way is to write:
class UsersController < ApplicationController
perspectives_actions only: :show # sets up the responder
def show
user = User.find(param[:id])
respond_with(perspective('users/show', user: user))
end
end
The respond_with
call is what figures out whether we want to return JSON or HTML to the client.
The default context is just an empty hash; if you want to change that, you can override the
default_context
method in any controller, e.g.:
class ApplicationController < ActionController::Base
def default_context
{current_user: current_user}
end
end
Nested Perspectives
If you want to render a perspective from another perspective, it's simple! For example:
class Users::Show < Perspectives::Base
param :user
property(:name) { user.name }
nested 'avatar', user: :user
# will render Users::Avatar, passing "user" as a parameter, and make an "avatar"
# method availabe in the mustache template
end
<div class='user'>
{{{avatar}}} <!-- use triple braces to print raw HTML -->
<span>{{name}}</span>
</div>
What about rendering a collection? Also simple!
class Projects::Show < Perspectives::Base
property :project
property(:title) { project.title }
nested_collection 'tasks/show',
collection: proc { project.tasks },
property: :tasks
# makes a "tasks" property available which is the list of tasks
end
<h1>{{title}}</h1>
{{{tasks}}} <!-- renders all the tasks -->
Macros
Perspectives also provides some nice macros to remove repeat code. For example,
delegate_property
exposes a method from an object as a property:
class Users::Show < Perspectives::Base
param :user
delegate_property :name, :email, to: :user
# makes name, email properties available
end
Caching
Since Perspectives know about their dependent Perspectives via the nested
and
nested_collection
macros above, russian doll caching is trivial. To set that up,
just write:
class Users::Show < Perspectives::Base
cache { user } # uses "user" as the cache key
end
The Perspective cache will expire if the user
changes, OR if the users.mustache
template
changes, OR if the Users::Show
perspective changes. (or if any nested
Perspective changes)
Client javascript
Perspectives has basically the same javascript API as PJAX,
and adds this line automatically to application.js if you use the rails g perspectives:install
:
$(function() { $(document).perspectives('a', 'body') })
That line says "intercept every click on 'a' tags", and request Perspectives JSON from
the server. Then render the resulting template, and replace the content of $('body')
with the
result of rendering. If you want to use a different container, you could do something like:
$(function() { $(document).perspectives('a', '#mycontainer') })
which would replace $('#mycontainer')
instead of $('body')
. If you did that, you would probably
also want a line like this in your application_controller.rb
:
layout lambda { |controller| !controller.request.xhr? && 'application' }
which will not render the layout at all if the request is made via xhr.
Render into different containers
If you want to render a response into a container other than the default you set up, you can set
'data-perspectives-target'
on an 'a' tag or a form. For example:
<a href="/users/1" data-perspectives-target="#viewing-user">Andrew Warner</a>
Which will render the response into the $('#viewing-user')
element. This might remind you of the PJAX
API.
You can also set data-perspectives-target
on a form, which will render the response from the server
into the target element on ajax:success
.
Events
More events TK, but, when perspectives receives a JSON response from the server, it triggers an event on the element (usually an anchor tag) which triggered the request, called "perspectives:response"
You can listen to this event and handle it as follows:
$('a').on('perspectives:response', function(e, options) {
// options has keys:
// json: (the json response)
// status: (response status)
// xhr: (the xhr),
// href: (requested href)
// container: (the rendering container)
// the default behavior of this event is
Perspectives.renderResponse(options)
// but you can do whatever you want
// (don't forget to stopPropagation if you don't want the
// default behavior to occur)
})
Perspectives also listens to the 'ajax:success'
event on forms, and renders the response from
the server.
Assets version
Just like PJAX, Perspectives should re-render the entire page if the assets have changed in some material way. If you just deployed your site, for example, we want to force everyone to reload the entire page!
To configure asset checking, just add the following to your application layout:
<%= assets_meta_tag %>
If you're using Rails, Perspectives will set a response header which is the mtime of the most recently updated asset file. Perspectives will do a full page reload if the assets have changed.
More examples please!
For a full example app, check out Rails Genius, an app that I built to demonstrate Perspectives for Rails Conf. Rails Genius allows you to read and write inline annotations on RailsConf talk abstracts.
(just like it's older sibling, Rap Genius)
Ruby version/framework support
Right now, the easiest way to use perspectives is with Rails 3.2+ / Rails 4, and Ruby 1.9.3+.
In theory, it should work with Rails 2, although that's not tested, and you have to do some more
work to set everything up. For setup stuff, check out lib/perspectives/railtie.rb
to see what gets
set up in later version of Rails. The other big different is that, in Rails 2, you'll want to use
respond_to
instead of respond_with
(although that part should "just work")
Other benefits
Besides shared rendering environments between the client and server, and easy-to-implement russian doll caching, Perspectives also force you to write views "the right way." Views in Perspectives world have a nice separation of concerns, where Mustache templates deal simply with laying out data in markup, and Perspectives deal with your business logic.
This separation of concerns makes testing a whole lot easier than testing in ERB land, since Perspectives are just ruby objects. While you'd have to render an ERB template and inspect its output in order to testing it, Perspectives can just be created and individual logic sections tested.
Philosophy
The core idea behind perspectives if that, if we use Mustache templates for templating, we can render them either on the client or on the server. We can break the typical Rails ERB/HAML views into one template, written in Mustache, which doesn't allow arbitrary code, and a "perspective" object, written in Ruby, which holds the logic needed to generate a hash which can be used to render a Mustache template.
Since the Mustache template never communicates directly with the perspective, when a client makes a request to our site, we can build the hash of properties with a perspective, and then either render it on the server in the case that the client is a web crawler or a user visiting the page for the first time, OR, in the case that the client already has a browser instance loaded up, we can simply return the JSON hash to the client and let them render or update the page however they want.
If the user already has a page on the site loaded, then serving the JSON necessary to render a template is much better than rendering it on the server and sending back an HTML fragment. If the server sends back HTML, and the client wants to do something besides immediately render, then it would have to inspect the HTML fragment from the response and yank out the information it wants. HTML is too brittle to rely upon for those purposes! Instead, forcing the separation between the data needed to render a template and the layout/markup in the template itself means that we're automatically building a JSON API as we're building out our site.
TODO
There are some key things that are needed in order to make this library TRULY shine. The main thing is an easy-to-use javascript library on the client that can be used to create client-only behavior. (such as transitioning between pages, client-only behavior, etc) The ideally integration would be with some kind of existing library like backbone.js, ember.js, or angular.js. With a front end "shell" over the client-side rendering side of this, we could easily add client-only features without duplicating views and other business logic in the browser.
License
MIT