Demystifying the View
When we’re learning about MVC web application development, it often seems as if the Model and the Controller layers get all the attention. Views are often treated in a hand-wavey fashion: “drop a template into the right directory, and it will magically be converted to HTML”.
It’s important to understand every layer of the stack we’re working with. And the best way to de-mystify something that seems magical is to re-create it ourslves. So, let’s create our own view object!
Video transcript & code
Fundamentally, a view is about taking variable data, usually delivered by a controller object, and slotting that data into a static template of some kind. One way we could do that is with programmatic string-manipulation.
my_template = ->(users){
"Users:\n\n - " + users.map { |user| user }.join("\n - ")
}
But this is NOT how we want to work with text templates. Can you look at this code and see in your mind exactly what the output will look like?
my_template = -> (users) {
"Users:\n\n - " + users.map { |user| user }.join("\n - ")
}
puts my_template.call(users)
# >> Users:
# >>
# >> - chris
# >> - dave
# >> - matt
# >> - pete
# >> - jodie
And this is only a very tiny template. This approach is unmanageable for large, complex page rendering.
We could instead use Ruby's built-in interpolation and here-doc syntax.
puts <<~EOF Users: - #{users.map { |user| user }.join("\n - ") } EOF # >> Users:
# >>
# >> - chris
# >> - dave
# >> - matt
# >> - pete
# >> - jodie
This is a little better. But can you see the problem here?
As soon as we need to iterate through some dynamic data, the visual nature of the template breaks down. We're back to programmatic string-composition.
Instead of string munging or interpolation, let's use a technology purpose-built for text templating. Let's use Ruby's standard ERB library. ERB stands for "Embedded RuBy", because it enables us to embed Ruby code inside text.
Here's what we want to be able to do with our final view object:
We want to instantiate a new View
passing in an ERB
template. We also want to pass in some local variables. (We'll just call these "locals" from here on out.)
Then, we want to be able to use those locals in the template.
We also want to be able to use helpers, such as number_to_currency
. Since this class is not used for any real production code, we should be able to implement these helpers ourselves directly on the View
class.
Finally, we should be able to render the view.
User = Struct.new(:name, :email, :amount)
users = [
User.new('Luke', 'luke@example.com', 800.00),
User.new('Anakin', 'vader@example.com', -200.00),
User.new('Yoda', 'example@yoda.com', 5000.00),
]
view = View.new(<<~ERB, users: users)
Donations:
<% users.each do |user| %>
- <%= user.name %>: <%= number_to_currency user.amount %>
<% end %>
Grand total: <%= number_to_currency users.sum(&:amount)%>
ERB
end
puts view.render
# >> Donations:
# >>
# >> - Luke (luke@example.com): $800.00
# >> - Anakin (vader@example.com): -$200.00
# >> - Yoda (example@yoda.com): $5000.00
# >>
# >> Grand total: $5600.00
We'll start by creating the class and defining a constructor.
It will take a template string . We also want it to receive a hash of locals. We'll use a double splat in order to make them named parameters..
Then we assign the locals and template to instance variables and create attribute readers in order to access them.
class View
attr_reader :template
attr_reader :locals
def initialize(template, **locals)
@template = template
@locals = locals
end
end
Then, we'll write out the render
method. It will require the erb
library. We could do this at the beginning of the file, but since this class will be loaded and run once per example, there's no need for that.
Now we can instantiate a new ERB
object and pass it the template text as a parameter.
In order to actually render the template, we need to call the result
method.
class View
attr_reader :template
attr_reader :locals
def initialize(template, **locals)
@template = template
@locals = locals
end
def render
require "erb"
ERB.new(template).result
end
end
With this in place, we're ready to render a basic ERB
template.
We can assign a variable and interpolate it in the following line, we can iterate over a list.
But wait, when we render we can see there's a lot of unwanted white space.
view = View.new(<<~ERB)
<% time = Time.now%>
The current time is: <%= time %>
The colors are:
<% %w[red green blue yellow].each do |color| %>
- <%= color %>
<% end %>
ERB
end
puts view.render
# >>
# >> The current time is: 2019-02-26 13:55:01 -0300
# >> The colors are:
# >>
# >> - red
# >>
# >> - green
# >>
# >> - blue
# >>
# >> - yellow
# >>
In order to fix this, we can add a trim_mode
option to the ERB
constructor. We'll pass a string containing angle brackets, which is a special code to tell ERB to trim extra newlines around interpolation tags.
# ...
def render
require "erb"
ERB.new(template, trim_mode: "<>").result
end
# ...
Now when we render, we see the desired output without any extra white space.
view = View.new(<<~ERB)
<% time = Time.now%>
The current time is: <%= time %>
The colors are:
<% %w[red green blue yellow].each do |color| %>
- <%= color %>
<% end %>
ERB
end
puts view.render
# >> The current time is: 2019-02-26 13:57:15 -0300
# >> The colors are:
# >> - red
# >> - green
# >> - blue
# >> - yellow
Lets now try the example we used at the beginning of the video.
When we run it we see the next feature we need to add: helpers
view = View.new(<<~ERB, users: users)
Donations:
<% users.each do |user|%>
- <%= user.name %>: <%= number_to_currency(user.amount) %>
<% end %>
Grand total: <%= number_to_currency users.sum(&:amount)%>
ERB
end
puts view.render
# ~> NoMethodError
# ~> undefined method `number_to_currency' for main:Object
# ~>
# ~> (erb):4:in `block in
each' # ~> (erb):3:in
eval' # ~> /home/fedex/.rvm/rubies/ruby-2.5.1/lib/ruby/2.5.0/erb.rb:876:in
result' # ~> /tmp/seeing_is_believing_temp_dir20190226-19875-18unc2c/program.rb:13:in render' # ~> /tmp/seeing_is_believing_temp_dir20190226-19875-18unc2c/program.rb:49:in
Helpers are functionality that belongs to the view, so we'll write our
number_to_currency
method there.
class View
# ...
private
# Helpers
def number_to_currency(n)
"$%.2f" % [n/100.0]
end
end
But we still don't have access to it.
# ...
puts view.render
# ~> NoMethodError
# ~> undefined method `number_to_currency' for main:Object
# ~>
# ~> (erb):4:in `block in
each' # ~> (erb):3:in
eval' # ~> /home/fedex/.rvm/rubies/ruby-2.5.1/lib/ruby/2.5.0/erb.rb:876:in
result' # ~> /tmp/seeing_is_believing_temp_dir20190226-19875-18unc2c/program.rb:13:in render' # ~> /tmp/seeing_is_believing_temp_dir20190226-19875-18unc2c/program.rb:49:in
In order to get access, we need to provide the current binding to our ERB
object. This will give it access to all of our state, including methods, the render method's local variables and instance variables.
def render
require "erb"
ERB.new(template, trim_mode: '<>').result(binding)
end
And now, when we run it, the next problem arises, we need a way to provide the locals to the template.
puts view.render
# ~> NameError
# ~> undefined local variable or method `users' for #
# ~>
# ~> (erb):3:in `render'
# ~> /home/fedex/.rvm/rubies/ruby-2.5.1/lib/ruby/2.5.0/erb.rb:876:in `eval'
# ~> /home/fedex/.rvm/rubies/ruby-2.5.1/lib/ruby/2.5.0/erb.rb:876:in `result'
# ~> /tmp/seeing_is_believing_temp_dir20190226-21806-6c0pw4/program.rb:13:in `render'
# ~> /tmp/seeing_is_believing_temp_dir20190226-21806-6c0pw4/program.rb:49:in `
We'll do this by augmenting the current binding with our local variables, taking a lesson learned in Episode #596.
We start by saving a copy of the current binding to a local variable.
Then we iterate through our locals hash. And we set a local variable in the new binding using the local's name and value.
Finally, we update the binding we're passing in to the ERB
result method.
def render
new_binding = binding.dup
locals.each do |name, value|
new_binding.local_variable_set(name, value)
end
require "erb"
ERB.new(template, trim_mode: "<>").result(new_binding)
end
With this in place, we now have a working view.
User = Struct.new(:name, :email, :amount)
users = [
User.new('Luke', 'luke@example.com', 80000),
User.new('Anakin', 'vader@example.com', -20000),
User.new('Yoda', 'example@yoda.com', 500000),
]
view = View.new( users: users, <<~ERB)
Donations:
<% users.each do |user|%>
- <%= user.name %>: <%= number_to_currency(user.amount) %>
<% end %>
Grand total: <%= number_to_currency users.sum(&:amount)%>
ERB
end
puts view.render
# >> Donations:
# >>
# >> - Luke (luke@example.com) donated $800.00
# >> - Anakin (vader@example.com) donated $-200.00
# >> - Yoda (example@yoda.com) donated $5000.00
# >>
# >> Grand total: $5600.00
This code is not optimized at all, but it shows off the basics of how views work in modern web frameworks.
Happy hacking!
Responses