PikachuEXE
Posted on March 12, 2021
Background
GitHub has recently posted an article about view_component
:
https://github.blog/2020-12-15-encapsulating-ruby-on-rails-views/
Before it gets too popular I think I should share my experience with cells
So that developers can have another chance to re-think and pick what to use for "encapsulated view components".
Scope of this post
(1) Only "concept cells", not view_component
style cells.
The "default"/view_component
naming style makes it difficult to just copy & rename a folder to bootstrap a new view component
(2) File Structure, Inputs, Rendering, View Inheritance & Caching
Testing - I am too lazy to do it (rarely beneficial for me)
Layout - Never used it.
File Structure
(Modified from cells old README)
(Remember this is for concept cells)
app
├── concepts
│ ├── copyable_url_box
│ │ ├── cell.rb
│ │ ├── views
│ │ │ ├── show.haml
File content
class CopyableUrlBox::Cell < Cell::Concept
# ..
end
If you want to put these files into a non-default folder like app/resources
You need to update the view_paths
class CopyableUrlBox::Cell < Cell::Concept
self.view_paths = Array("app/resources") + view_paths
# ..
end
Inputs
The official doc suggests using this:
concept("comment/cell", @comment) #=> return a cell
This gives you access to an attribute model
(which is @comment
) inside the cell object by default
But I prefer passing a hash so I do this:
class ApplicationConceptCell < Cell::Concept
module Cells3HashInputBehaviourReplication
# Since in cells 3.x
# When a hash is passed in
# Everything in hash is injected as ivars
# So we want to replicate the behaviour here
#
# This override works on cells 4.1.5
def initialize(model, options={})
if model.is_a?(::Hash)
model.each_pair do |key, value|
instance_variable_set("@#{key}", value)
end
# To avoid ivar `@model` being set in super with the Hash
# We need to fetch value from hash and put it into `super`
# Even usually it's `nil`
super(model.fetch(:model) { nil }, options)
else
super
end
end
end
prepend Cells3HashInputBehaviourReplication
end
# Remember you have to inherit from the right cell!
class CopyableUrlBox::Cell < ApplicationConceptCell
private
# region Inputs
# Contract Something
# attr_reader :input_name
Contract And[::String, Send[:present?]]
attr_reader :url
# endregion Inputs
end
This gives me access to input values as long as I defined them via attr_reader
.
Oh what's the Contract XXX
above attr_reader
?
They are from contracts.ruby and completely optional and won't be explained in this post.
You can safely ignore those and maybe study that gem later.
I am also using another way to handle inputs which is to put input values into inputs
attribute via a value object.
require "contracted_value"
class ApplicationConceptCell2222 < Cell::Concept
module HashInputsAsImmutableInputs
def initialize(model, options={})
if model.is_a?(::Hash)
@inputs = self.class.inputs_class.new(model)
# To avoid ivar `@model` being set in super with the Hash
# We need to fetch value from hash and put it into `super`
# Even usually it's `nil`
super(model.fetch(:model) { nil }, options)
else
super
end
end
private
attr_reader :inputs
end
prepend HashInputsAsImmutableInputs
class_attribute(
:inputs_class,
instance_reader: false,
instance_writer: false,
)
class << self
def define_inputs(&block)
new_inputs_class = Class.new(::ContractedValue::Value)
new_inputs_class.include(::Contracts::Core)
new_inputs_class.include(::Contracts::Builtin)
new_inputs_class.class_eval(&block)
self.inputs_class = new_inputs_class
end
end
end
# Remember you have to inherit from the right cell!
class CopyableUrlBox2222::Cell < ApplicationConceptCell2222
private
# region Inputs
define_inputs do
attribute(
:url,
contract: And[::String, Send[:present?]],
)
end
# endregion Inputs
end
Above implemented via my own gem contracted_value
and feel free to swap it with any other value object implementation which can be gems or your own code.
Rendering
To use a cell, by default just invoke #call
And you can do it by:
- Get result string directly
- Create a cell, do something in the middle, get result string later (no need to invoke
#call
right after cell creation, and feel free to add new public methods as your own API)
# controller
render(
html: concept("copyable_url_box/cell, url: "...").call
)
Since I use cells-rails
, #call
returns HTML safe string.
If you don't use cells-rails
you might need to do extra work or include extra module to make that happen (check doc yourself).
In cell class, it looks like this:
class CopyableUrlBox::Cell < ApplicationConceptCell
# This is actually optional if you use >= 4.1
# BUT you can make this return something else
def show
render
end
# inputs and other stuff...
end
Why #show
? If you read the doc you will know that
concept_cell.call == concept_cell.call(:show)
You are free to use other names but not recommended.
The templates can access anything inside the cell.
Yes this includes
- the inputs (yes even they are private attributes)
- any public/protected/private methods
- Some helper methods are delegated to controller if
cells-rails
is used - Extra helper methods from helper modules included into the cell
No more "locals" when rendering. What you see (in cell objects) is what you get (in templates).
Having template file(s) is optional.
Returning a tag directly in #show
also works.
class WebsiteLogo::Cell < ApplicationConceptCell
def show
image_tag(
website_logo_image_path,
alt: "Logo",
class: "...",
)
end
private
def website_logo_image_path
case inputs.variant
when :small
"..."
else
"..."
end
end
# inputs and other stuff...
end
View Inheritance
I generally prefer composition over inheritance (using a group of other cells instead a cell) but sometimes being able to use templates from parent cell class is more convenient.
I use it when I have several cells for page content of several page types but the layout is similar (same number of sections but different content).
Just read the view inheritance document yourself.
Caching
Best part comes last.
Caching is easy, cache invalidation is not.
But let's talk about how to enable caching first.
Enabling caching is quite easy according to caching doc:
class CopyableUrlBox::Cell < ApplicationConceptCell
cache :show
end
Yup. The end.
Except when you pass different inputs into it, the cell will give you the same (cached) output. Which is why cache invalidation is the hard part.
First get some basic concept by looking at the caching doc
cache :show, expires_in: 10.minutes
This is just making the cached content expires after some time, nope not what we want.
cache :show { |model, options| "comment/#{model.id}/#{model.updated_at}" }
cache :show, :if => lambda { |*| has_changed? }
cache :show, :tags: lambda { |model, options| "comment-#{model.id}" }
Looks messy? Let me give you my version
cache(
:show,
:cache_key,
expires_in: :cache_valid_time_period_length,
if: :can_be_cached?,
)
def can_be_cached?
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].all?(&:can_be_cached?)
true
end
def cache_valid_time_period_length
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].map(&:cache_valid_time_period_length).min
# Long time
100.years
end
def cache_key
{
current_locale: ::I18n.config.locale,
url: Digest::MD5.hexdigest(url),
}
end
If language changed (::I18n.config.locale
) or input value different (url
), a different cache key would be used and the render result (cached or not) would be different
But hold on, what if I updated the logic/template and now I want it to return a result rendered with latest logic?
This is what I did:
class ApplicationConceptCell < Cell::Concept
DEFAULT_CLASS_LEVEL_CACHE_KEY = {
# ONLY change this when there are too many cells caching needs updated
# Also only affects cells that has `#cache_key` binding to `super`
# Yes I use cells since 2015
design: :v2015_12_22_1001,
}.freeze
private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY
def self.cache_key
DEFAULT_CLASS_LEVEL_CACHE_KEY
end
def cache_key
self.class.cache_key
end
end
class CopyableUrlBox::Cell < ApplicationConceptCell
cache(
:show,
:cache_key,
expires_in: :cache_valid_time_period_length,
if: :can_be_cached?,
)
def can_be_cached?
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].all?(&:can_be_cached?)
true
end
def cache_valid_time_period_length
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].map(&:cache_valid_time_period_length).min
# Long time
100.years
end
def self.cache_key
super.merge(
logic: :v2020_12_18_1727,
)
end
def cache_key
super.merge(
current_locale: ::I18n.config.locale,
url: Digest::MD5.hexdigest(url),
)
end
end
Notice that the above code all use cache_key
.
But Rails 5.2 introduced cache versioning and we should use that too.
Caching - With Cache Versioning
The following code enable cache versioning
class ApplicationConceptCell < Cell::Concept
DEFAULT_CLASS_LEVEL_CACHE_KEY = {
# Empty
}.freeze
private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY
DEFAULT_CLASS_LEVEL_CACHE_VERSION = {
# ONLY change this when there are too many cells caching needs updated
# Also only affects cells that has `#cache_key` binding to `super`
# Yes I use cells since 2015
design: :v2015_12_22_1001,
}.freeze
private_constant :DEFAULT_CLASS_LEVEL_CACHE_VERSION
def self.cache_key
DEFAULT_CLASS_LEVEL_CACHE_KEY
end
def cache_key
self.class.cache_key
end
def self.cache_version
DEFAULT_CLASS_LEVEL_CACHE_VERSION
end
def cache_version
self.class.cache_key
end
end
class ProductCardWide::Cell < ApplicationConceptCell
cache(
:show,
:cache_key,
expires_in: :cache_valid_time_period_length,
if: :can_be_cached?,
version: :cache_version,
)
def can_be_cached?
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].all?(&:can_be_cached?)
true
end
def cache_valid_time_period_length
# Use following code when child cell(s) is/are used
# [
# child_cell_1,
# ].map(&:cache_valid_time_period_length).min
# Long time
100.years
end
# def self.cache_key
# super
# end
def cache_key
super.merge(
current_locale: ::I18n.config.locale,
# Assume product is an active record object
product: product.id,
)
end
def self.cache_version
super.merge(
logic: :v2017_12_22_1124,
)
end
def cache_version
super.merge(
# Assume product is an active record object
product: product.updated_at,
)
end
end
Point is when calling .cache
in class, add option version
.
Logic/Template/Translation/Images updated?
You can of course manually update the class level cache key/version.
But let's see how I make them auto update themselves.
Caching - Class Level Cache Key/Version Auto Update
1. Template Updates
This assumes every template update should cause cache invalidation (so even a refactor would invalidate the cache).
class Some::Cell < ApplicationConceptCell
def self.cache_version
super.merge(
template: TEMPLATE_FILES_CONTENT_CACHE_KEY,
)
end
# This generates the cache key/version from the content of all direct templates for this cell (templates from parent cells or custom paths NOT included)
# Any change (even a refactor) would cause the value to change
TEMPLATE_FILES_CONTENT_CACHE_KEY = begin
view_folder_path = File.expand_path("views", __dir__)
file_paths = Dir.glob(File.join(view_folder_path, "**", "*"))
file_digests = file_paths.map do |file_path|
::Digest::MD5.hexdigest(File.read(file_path))
end
::Digest::MD5.hexdigest(file_digests.join(""))
end
private_constant :TEMPLATE_FILES_CONTENT_CACHE_KEY
end
2. Translation Updates
This requires you to have separate folders of translation files for different components.
class Some::Cell < ApplicationConceptCell
def self.cache_version
super.merge(
translations: TRANSLATION_FILES_CONTENT_CACHE_KEY,
)
end
# This generates the cache key/version from the content of all specified folder's files
# Any change (even a refactor) would cause the value to change
TRANSLATION_FILES_CONTENT_CACHE_KEY = begin
folder_path = ::Rails.root.join(
"config/locales/your/cell/translation/folder/path",
)
file_paths = Dir.glob(File.join(folder_path, "**", "*"))
file_digests = file_paths.map do |file_path|
next nil unless File.file?(file_path)
::Digest::MD5.hexdigest(File.read(file_path))
end
::Digest::MD5.hexdigest(file_digests.join(""))
end
private_constant :TRANSLATION_FILES_CONTENT_CACHE_KEY
end
3. Image Updates
class Some::Cell < ApplicationConceptCell
def self.cache_version
super.merge(
images: IMAGE_FILES_CONTENT_CACHE_KEY,
)
end
# This generates the cache key/version from the content of all specified folder's files
# Any change (even a refactor) would cause the value to change
IMAGE_FILES_CONTENT_CACHE_KEY = begin
folder_paths = [
# "app/assets/images/path/to/images",
]
asset_logical_paths = [
# "app/assets/images/path/to/images.jpg",
].map do |asset_logical_path|
::Rails.root.join(asset_logical_path)
end
file_paths = folder_paths.inject([]) do |paths, folder_path|
paths + ::Dir.glob(::File.join(::Rails.root.join(folder_path), "**", "*"))
end + asset_logical_paths
file_digests = file_paths.map do |file_path|
next nil unless File.file?(file_path)
::Digest::MD5.hexdigest(File.read(file_path))
end
::Digest::MD5.hexdigest(file_digests.join(""))
end
private_constant :ASSET_FILES_CONTENT_CACHE_KEY
end
4. Cell Logic Updates
Nope. You have to update cache key/version manually for this kind of updates.
The End
I still got some more to share about caching but let's save it for another post. (This post is for "beginners")
The code above is extracted from an existing project so let me know if there is any mistake.
Took me almost 3 months to finish this article (o.0)
Posted on March 12, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.