Background
GitHub has recently posted an article aboutview_component
:
https://github.blog/2020-12-15-encapsulating-ruby-on-rails-views/
Before it gets too popular I think I should share my experience withcells
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", notview_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 fromcells old README)
(Remember this is for concept cells)
app├── concepts│ ├── copyable_url_box│ │ ├── cell.rb│ │ ├── views│ │ │ ├── show.haml
File content
classCopyableUrlBox::Cell<Cell::Concept# ..end
If you want to put these files into a non-default folder likeapp/resources
You need to update theview_paths
classCopyableUrlBox::Cell<Cell::Conceptself.view_paths=Array("app/resources")+view_paths# ..end
Inputs
Theofficial doc suggests using this:
concept("comment/cell",@comment)#=> return a cell
This gives you access to an attributemodel
(which is@comment
) inside the cell object by default
But I prefer passing a hash so I do this:
classApplicationConceptCell<Cell::ConceptmoduleCells3HashInputBehaviourReplication# 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.5definitialize(model,options={})ifmodel.is_a?(::Hash)model.each_pairdo|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)elsesuperendendendprependCells3HashInputBehaviourReplicationend# Remember you have to inherit from the right cell!classCopyableUrlBox::Cell<ApplicationConceptCellprivate# region Inputs# Contract Something# attr_reader :input_nameContractAnd[::String,Send[:present?]]attr_reader:url# endregion Inputsend
This gives me access to input values as long as I defined them viaattr_reader
.
Oh what's theContract XXX
aboveattr_reader
?
They are fromcontracts.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 intoinputs
attribute via a value object.
require"contracted_value"classApplicationConceptCell2222<Cell::ConceptmoduleHashInputsAsImmutableInputsdefinitialize(model,options={})ifmodel.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)elsesuperendendprivateattr_reader:inputsendprependHashInputsAsImmutableInputsclass_attribute(:inputs_class,instance_reader:false,instance_writer:false,)class<<selfdefdefine_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_classendendend# Remember you have to inherit from the right cell!classCopyableUrlBox2222::Cell<ApplicationConceptCell2222private# region Inputsdefine_inputsdoattribute(:url,contract:And[::String,Send[:present?]],)end# endregion Inputsend
Above implemented via my own gemcontracted_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)
# controllerrender(html:concept("copyable_url_box/cell, url: "...").call)
Since I usecells-rails
,#call
returns HTML safe string.
If you don't usecells-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:
classCopyableUrlBox::Cell<ApplicationConceptCell# This is actually optional if you use >= 4.1# BUT you can make this return something elsedefshowrenderend# inputs and other stuff...end
Why#show
? If you read the doc you will know thatconcept_cell.call == concept_cell.call(:show)
You are free to use other names but not recommended.
The templates can accessanything 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.
classWebsiteLogo::Cell<ApplicationConceptCelldefshowimage_tag(website_logo_image_path,alt:"Logo",class:"...",)endprivatedefwebsite_logo_image_pathcaseinputs.variantwhen:small"..."else"..."endend# 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 theview 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 tocaching doc:
classCopyableUrlBox::Cell<ApplicationConceptCellcache:showend
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 thecaching 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?,)defcan_be_cached?# Use following code when child cell(s) is/are used# [# child_cell_1,# ].all?(&:can_be_cached?)trueenddefcache_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 time100.yearsenddefcache_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 Idid:
classApplicationConceptCell<Cell::ConceptDEFAULT_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 2015design: :v2015_12_22_1001,}.freezeprivate_constant:DEFAULT_CLASS_LEVEL_CACHE_KEYdefself.cache_keyDEFAULT_CLASS_LEVEL_CACHE_KEYenddefcache_keyself.class.cache_keyendendclassCopyableUrlBox::Cell<ApplicationConceptCellcache(:show,:cache_key,expires_in: :cache_valid_time_period_length,if: :can_be_cached?,)defcan_be_cached?# Use following code when child cell(s) is/are used# [# child_cell_1,# ].all?(&:can_be_cached?)trueenddefcache_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 time100.yearsenddefself.cache_keysuper.merge(logic: :v2020_12_18_1727,)enddefcache_keysuper.merge(current_locale:::I18n.config.locale,url:Digest::MD5.hexdigest(url),)endend
Notice that the above code all usecache_key
.
ButRails 5.2 introducedcache versioning and we should use that too.
Caching - With Cache Versioning
The following code enablecache versioning
classApplicationConceptCell<Cell::ConceptDEFAULT_CLASS_LEVEL_CACHE_KEY={# Empty}.freezeprivate_constant:DEFAULT_CLASS_LEVEL_CACHE_KEYDEFAULT_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 2015design: :v2015_12_22_1001,}.freezeprivate_constant:DEFAULT_CLASS_LEVEL_CACHE_VERSIONdefself.cache_keyDEFAULT_CLASS_LEVEL_CACHE_KEYenddefcache_keyself.class.cache_keyenddefself.cache_versionDEFAULT_CLASS_LEVEL_CACHE_VERSIONenddefcache_versionself.class.cache_keyendendclassProductCardWide::Cell<ApplicationConceptCellcache(:show,:cache_key,expires_in: :cache_valid_time_period_length,if: :can_be_cached?,version: :cache_version,)defcan_be_cached?# Use following code when child cell(s) is/are used# [# child_cell_1,# ].all?(&:can_be_cached?)trueenddefcache_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 time100.yearsend# def self.cache_key# super# enddefcache_keysuper.merge(current_locale:::I18n.config.locale,# Assume product is an active record objectproduct:product.id,)enddefself.cache_versionsuper.merge(logic: :v2017_12_22_1124,)enddefcache_versionsuper.merge(# Assume product is an active record objectproduct:product.updated_at,)endend
Point is when calling.cache
in class, add optionversion
.
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).
classSome::Cell<ApplicationConceptCelldefself.cache_versionsuper.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 changeTEMPLATE_FILES_CONTENT_CACHE_KEY=beginview_folder_path=File.expand_path("views",__dir__)file_paths=Dir.glob(File.join(view_folder_path,"**","*"))file_digests=file_paths.mapdo|file_path|::Digest::MD5.hexdigest(File.read(file_path))end::Digest::MD5.hexdigest(file_digests.join(""))endprivate_constant:TEMPLATE_FILES_CONTENT_CACHE_KEYend
2. Translation Updates
This requires you to have separate folders of translation files for different components.
classSome::Cell<ApplicationConceptCelldefself.cache_versionsuper.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 changeTRANSLATION_FILES_CONTENT_CACHE_KEY=beginfolder_path=::Rails.root.join("config/locales/your/cell/translation/folder/path",)file_paths=Dir.glob(File.join(folder_path,"**","*"))file_digests=file_paths.mapdo|file_path|nextnilunlessFile.file?(file_path)::Digest::MD5.hexdigest(File.read(file_path))end::Digest::MD5.hexdigest(file_digests.join(""))endprivate_constant:TRANSLATION_FILES_CONTENT_CACHE_KEYend
3. Image Updates
classSome::Cell<ApplicationConceptCelldefself.cache_versionsuper.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 changeIMAGE_FILES_CONTENT_CACHE_KEY=beginfolder_paths=[# "app/assets/images/path/to/images",]asset_logical_paths=[# "app/assets/images/path/to/images.jpg",].mapdo|asset_logical_path|::Rails.root.join(asset_logical_path)endfile_paths=folder_paths.inject([])do|paths,folder_path|paths+::Dir.glob(::File.join(::Rails.root.join(folder_path),"**","*"))end+asset_logical_pathsfile_digests=file_paths.mapdo|file_path|nextnilunlessFile.file?(file_path)::Digest::MD5.hexdigest(File.read(file_path))end::Digest::MD5.hexdigest(file_digests.join(""))endprivate_constant:ASSET_FILES_CONTENT_CACHE_KEYend
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)
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse