Reinventing Rails’ Components
Lately I’ve been hacking on some Rails code for http://eparklabs.com. Dan, the guy that runs it, is unhappy about the state of affairs with Rails’ views. Of course he’s not alone - many people out there are looking for more powerful view abstractions. Just the other night I was down at BDRG and listening to Bruce Williams talk about his new Stencil plugin for Rails. Basically it addes a little state, or context, to rendering.
Why is this good?
Because state + funcionality == objects! Now, I am not, for one second, saying that objects are a panacea, but they have proven to be an extremely useful paradigm for solving many computer programming problems. in Rails, we do not use objects to contruct our views. “What!?”, you say. Yes, it’s true. Ruby may be object oriented. Rails may even be object oriented. But Rails’s views are not themselves object oriented. In fact, they are almost purely procedural. Partials, helpers, and even componets simply allow us make layer upon layer of functions to call, each of which simply belches out a bunch of html. All html generation in Rails basically boils down to this:
def to_html
header + body + footer
end
def header
’<header/>’
end
def body
’<body/>’
end
end
def footer
’<footer/>’
end
end
Now, some people will object that, in fact, the situation is more like this:
def initialize
@header = ‘header’
@body = ‘body’
@footer = ‘footer’
end
def action
View.new.to_html self
end
end
class View
def to_html controller
controller.instance_eval{ header(@header) + body(@body) + footer(@footer) }
end
def header content
”<header>#{ content }</header>”
end
def body content
”<body>#{ content }</body>”
end
def footer content
”<footer>#{ content }</footer>”
end
end
And that this is clearly a case where state and functionality are being wrapped in an object. Sadly, this is only an illusion. The reason I say that is this: when a request comes into Rails a controller instance is created, this controller has state and executes logic before handing of a bit of munged state to a view for rendering. Seems pretty encapsulated right? Before you answer, what would you say about this program:
def foo
p $global
end
Probably that it’s creepy seeing that global variable sitting there right? That it’s clearly not OO? Now, how about this program:
def foo var
p var
end
Ok better. At least we’re procedural now. None of those freaky global variables. We can, of course, do better:
def initialize
@var = 42
end
def foo
p @foo
end
end
And this is starting to look pretty OO, pretty much like Rails. Here is the rub: there is only one controller in effect at a time in Rails and that controller’s data is, effectively, global for all rendering actions:
def initialize
@highlander = 42 # there can be only one!
end
def foo
render # all views will have access to @highlander - just like a global var
end
end
Now, if Rails had something that resembled an OO view we might be used to seeing code that looked vaugely like this:
def initialize
@var = 42
end
def foo
widget = Widget.new :var => @var
widget.width = ‘42%’
widget.height = ‘100%’
render widget.to_html
end
end
Ah ha! Components you say! Close, but no banana. While it’s quite true that we can parameterize a controller while reusing it’s logic and rendering abilities via:
def foo
render_component ‘bar’, :params => {4 => 2}
end
end
We can see right away that this too, is a simple procedural call. It is true that the call chain for render_component will construct an object and parameterize it along the way, but this a one shot deal whose end result is exactly like:
def foo
method_that_belches_html ‘bar’, :params => {4 => 2}
end
end
Which is to say we don’t get a handle on an object or two, have a go at munging their states, and letting them do the html belching themselves based those (altered) states.
DHH’s views on components, which, if you haven’t guessed, is what we are going to be talking about here, are well known and I tend to agree with him. I do, however think that there is a big difference between what we can call ‘fat’ components and ‘thin’ ones. The essential issues with components are precisely those we have in object oriented languages: it’s very, very hard to make good re-usable ones. However, I’ll take a language with objects over one without any day.
So the basic issue I see with components in Rails as they stand now - is that they are not components! The current implementation provides nothing more that a function call with state, although that state is contained an object rather than inside a closure and the rendering function may be broken up into many sub-functions via helpers, partials, etc. What if we could remedy that? What if we made good use of the ability for a controller to bind together state (via the model), logic, and view into a tidy bundle? If we did, we might be able to do something like this:
def foo
render :text => @foo
end
def bar
render :text => @bar
end
end
And then:
def bar
# get a handle on, and parameterize, a bar controller
bar_controller = component_for(:controller => ‘bar’) do
@foo = 42
@bar = ‘forty-two’
end
# call two actions on the bar controller, potentially reusing the entire model+view+controller stack
foo = bar_controller.content_for :action => ‘foo’
bar = bar_controller.content_for :action => ‘bar’
render :text => [foo, bar].inspect #=> [42, “forty-two”]
end
end
Wouldn’t that be nice?
Here is a sneak-peak at my soon to be released ‘componentry’ lib. There’s not too much to it, but it allows all this and more.
module Components
module InstanceMethods
# returns a child component with both initializaion and defered-rendering ability
def component_for(options, &block)
options.to_options!
klass = component_class(options)
parent_controller = options.delete(:parent_controller) || self
returning(klass.new) do |controller|
(class « controller; self; end).class_eval do
define_method(:parent_controller){ parent_controller }
define_method(:content_for) do |options|
options.to_options!
parent_controller.send(:component_logging, options) do
request = parent_controller.instance_eval do
request_for_component(klass.controller_name, options)
end
reuse_response = options.delete(:reuse_response)
response = reuse_response ? parent_controller.response : parent_controller.response.dup
response = process(request, response)
if redirected = response.redirected_to
content_for(redirected)
else
response.body
end
end
end
end
controller.parent_controller = parent_controller
controller.instance_eval(&block) if block
end
end
end
end
end
Enjoy.