Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: Viewを作らずにControllerからVue.jsのSFCを指定してrenderできないか試したMEMO

はじめに

RailsでVue.jsを採用している場合、Vue SFCscoped cssを利用してCSS周りの統制と取りたくなりますが、RailsのView層に色々要素を書いてしまうとそれも難しくなってきます。

vue-loader-v14.vuejs.org

なので、RailsのControllerから直接Vue.jsのSFCを指定してrenderするような仕組みができないか色々試してみたのでメモしておく📝

基本方針

以下のようにrender_vue_componentの引数にコンポーネントpropsを渡すと<index-post-page />< show-post-page :post-id="id">みたいなVueコンポーネントの要素を差し込んだhtmlを返却するような仕組みを検討します。

class PostsController < ApplicationController
  def index
    render_vue_component('index-post-page')
  end

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

このようなケースだとinertiajs.jsを使えば良さそうにも思いましたが、単純にscoped cssCSSの統制を取りたいという要求に対して少し過剰な印象を持ったので、Railsの仕組みに乗りつつ直接Vue SFC指定してcontrollerからviewをrenderするような感覚で実現できないか検討してみる。

inertiajs.com

github.com

実装したコード

render_vue_componentでVue SFCを直接指定できるように以下のようなtemplateとconcernを実装してみました。

render_vue_componentコンポーネント名とpropsmount_element_idを渡すとmount_element_id(default: 'vue-root')をidとして指定したdivタグの中にhelpers.content_tag(@component, '', @props)で生成したタグ(ex. <show-post-page :post-id="id" >)をcomponent/template.erbに差し込んで返却します。

simpacker等を採用しておりjavascript_pack_tagでentryのjs、stylesheet_pack_tagでentryのcssを読み込むことが前提になってますがjavascript_entry_tagstylesheet_entry_tagをoverrideすれば任意のタグに差し替えることができます。

entryはデフォルトでcontroller名/action名(ex. PostsController#show -> posts/show)が指定されるようにしてみました。

<div id="<%= mount_element_id %>">
  <%= component_tag %>
</div>
<% content_for :stylesheet do %>
  <%= stylesheet_entry_tag %>
<% end %>
<% content_for :javascript do %>
  <%= javascript_entry_tag %>
<% end %>
# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, props: {}, mount_element_id: nil)
    @component = to_kebab_case(component)
    @props = props
    @entry = entry.presence || default_entry
    mount_element_id = mount_element_id.presence || default_mount_element_id
    render defalt_template_path,
           locals: { mount_element_id: mount_element_id, component: @component, entry: entry, props: @props }
  end

  def component_tag
    helpers.content_tag(@component, '', @props)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def default_mount_element_id
    'vue-root'
  end

  def defalt_template_path
    'component/template'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

(追記) 以下のようなrender inlineを使うとcomponent/temaplete.erb消せるのでよりシンプルに実現できそうだった。

# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, mount_element_id: nil, props: {})
    @component = to_kebab_case(component)
    @options = props
    @entry = entry.presence || default_entry
    @mount_element_id = mount_element_id.presence || default_mount_element_id
    render inline: template, # rubocop:disable Rails/RenderInline
           locals: { mount_element_id: @mount_element_id, component: @component, entry: entry, options: @options },
           layout: true
  end

  def component_tag
    helpers.content_tag(@component, '', @options)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def template
    <<~HTML
      <div id="<%= mount_element_id %>">
        <%= component_tag %>
      </div>
      <% content_for :stylesheet do %>
        <%= stylesheet_entry_tag %>
      <% end %>
      <% content_for :javascript do %>
        <%= javascript_entry_tag %>
      <% end %>
    HTML
  end

  def default_mount_element_id
    'vue-root'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

利用例

以下のようなentryを実装してある前提で、

import { createApp } from "vue";
import ShowPostPage from "@js/components/pages/ShowPostPage.vue";

const app = createApp(ShowPostPage);
app.mount("#vue-root");

以下のようにcontroller内でrender_vue_componentを利用することで、

class PostsController < ApplicationController
  include ComponentRenderable

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

以下のようなhtmlが返却され、Vueインスタンスのmount及び指定したpropsを指定してComponentがレンダリングされます。

<div id="vue-root">
  <show-post-page :post-id="9"></show-post-page>
</div>

おわりに

とりあえず、そこまで多くのコードを書かずにViewを作らずControllerからVue.jsのSFCを指定してrenderするようなことは出来そうだなと思いましたが、多少なりとも独自の仕組みを作るとトレンドに合わせて独自の仕組みをメンテナンスするコストが発生するのが難しいところ。。。

CSSの統制とりたいというニーズにおいてはTailwind CSSをしたり、必ずしも独自の仕組みを用意する必要はないかもしれないのとView Componentのようなメジャーなライブラリでもscoped cssののような機能が議論されたりしてるので、こちらを期待するのも良いかもしれない。

github.com