0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Turbo Mount でつくるフロントエンド on Rails

Last updated at Posted at 2026-01-01

Turbo Mountベースの仕組みを作成してみたので、紹介します。

Vue.js/React/Svelte(5)で作成しています。

下記ページからインスピレーションを得て、作成しました。

Turbo Mount

Rails7以降のHotwireにVue.js / React / Svelte のコンポーネントを組み込み、連動させることを可能にするライブラリです。

Turbo Mount をそのまま利用すると、

JS側での(Turbo Mountへの)コンポーネント登録→(バックエンド・)ビュー(ERB)での登録したコンポーネントのマウント

の手順がフロントエンド・コンポーネントの数分、必要になります。

(バックエンド・)ビュー(ERB)での登録したコンポーネントのマウントには、用意されているヘルパーを用います。

(バックエンド・)ビュー(ERB)でのマウントは、厳密には、JS側でのコンポーネント登録時に付けた名前とTurbo Mountのコントローラの紐付けを(バックエンド・)ビューで設定し、実際のマウントは、フロントエンドで(常駐させている)Turbo Mountのコントローラ(Stimulusベース)が行います。

構成

  • ルート・コンポーネント
  • バックエンド・コンポーネント
    ルート・コンポーネントのバックエンド・ビューでのマウントを担当します
  • エントリーポイント
    ルート・コンポーネントのTurbo Mountへの登録を担います

の構成です。

バックエンド・コンポーネントは、View Component gemを利用して作成しました。

生Turbo Mountとの違い

(Turbo Mountへの)コンポーネントの登録はルート・コンポーネントの登録の1回のみです。

Turbo Mountを隠蔽できるので、Turbo Mount を意識する必要はありません。

エントリー・ポイントとルート・コンポーネントは、一度、書いて配置してしまえば、(個別の)フロントエンド・コンポーネントの開発時に触ることは(ほぼ)ありません。

詳細

ルートコンポーネントに下記の2つのプロパティを渡すと、相対パスに対応したフロントエンド・コンポーネントをpropsにデータをセットして描画します。

  • 相対パス: フロントエンド・コンポーネントのコンポーネント・ルートからの相対パス
  • データ: バックエンドからフロントエンドに渡すデータ

(個別の)フロントエンド・コンポーネントはコンポーネント・ルート下に作成します。

ルートコンポーネントへのデータのセットは、バックエンド・コンポーネントのnew(初期化)時引数のセットの形で行います。

propsのデータは、バックエンド・コンポーネント→ルート・コンポーネント→フロントエンド・コンポーネントへと渡されます。

ルート・コンポーネント

index.ts

<script lang="ts" setup>
import { defineAsyncComponent } from 'vue'

const { path, viewData: data } = defineProps<{ path: string, viewData?: string }>()

const viewDataMap = JSON.parse(data ?? '{}')
const viewData = viewDataMap && Object.keys(viewDataMap).length !== 0 ? viewDataMap : null

const AsyncComponent = defineAsyncComponent(() => import(/* @vite-ignore */`./${path}.vue`))
</script>

<template>
  <Suspense>
    <AsyncComponent v-bind="viewData && { viewData }" />
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

propsから相対パスとデータ(JSON形式)を受け取り、データをオブジェクトに変換、相対パスに対応したフロントエンド・コンポーネントをインポートし、変換したデータをpropsにセットしてセットしています。

ダイナミック・インポートを利用しているので、相対パスの深さは1(1つ下の階層)まで、となっています。

バックエンド・コンポーネント

front/component.html.erb

<div data-turbo="false">
  <%= helpers.turbo_mount("index", props: { path: @path, "view-data" => @view_data.to_json }) %>
</div>

front/component.rb

 frozen_string_literal: true

# front
module Front
  # Component
  class Component < ViewComponent::Base
    def initialize(path:, view_data: {})
      @path = path
      @view_data = view_data || {}
    end
  end
end

引数

  • path: コンポーネント・ルートからの相対パス
    (深さは1(1つ下の階層)までby実装上の制約)
  • view_data: バックエンドからフロントエンドに渡すデータ(ハッシュ)

呼び出しコード例

frames/show.html.erb

<%= render Front::Component.new(path: "frame/Comments") %>

SSR結果

<div data-controller="turbo-mount-index" data-turbo-mount-index-component-value="index" data-turbo-mount-index-props-value="{&quot;path&quot;:&quot;frame/Comments&quot;,&quot;view-data&quot;:&quot;{}&quot;}"></div>

エントリー・ポイント(JS)

entrypoints/components.js

/*
import { createApp } from 'vue'
import plugin from 'turbo-mount/vue'
*/

import { TurboMount } from 'turbo-mount'
import { registerComponent } from 'turbo-mount/vue'
import index from '../components/index.vue'

/*
plugin.mountComponent = (mountProps) => {
  const { el, Component, props } = mountProps
  const app = createApp(Component, props)
  app.mount(el)
  return () => {
    app.unmount()
  }
}
*/

const turboMount = new TurboMount()

registerComponent(turboMount, 'index', index)

実装例

One more thing

zaikoのアプローチの別実装

上記のコードをベースに、zaikoのアプローチの仕組みを再現してみたので、紹介します。

バックエンドと紐づくフロントエンド・コンポーネントはページ単位(ページ・コンポーネント)となっています。

コンポーネント・ルート下に「コントローラ名/アクション名.vue」でページ・コンポーネントを作成します。

リクエストすると、対応する「コントローラ名/アクション名」のページ・コンポーネントを(view_dataを渡して)描画します。

Controller

controllers/application_controller.rb

class ApplicationController < ActionController::Base
  ...
  include Frontend
  ...
end

Controller/Concern

controller/concerns/frontend.rb

module Frontend
  extend ActiveSupport::Concern

  included do
    layout "frontend"
  end

  def default_render(*args)
    respond_to do |format|
      format.html {
        render(inline: "", layout: "frontend")
      }
    end
  end
end

Railsはデフォルトではコントローラのアクション毎にビュー(ERB)をレンダリングするので、default_renderを上書きし、アクション毎のビューを不要にしています。

ビュー(ERB)

views/layouts/frontend.html.erb

<!DOCTYPE html>
<html lang="ja">
  <head>
    ...
  </head>
  <body ...>
    ...
    <%= render Front::Component.new(path: "#{controller_name}/#{action_name}", view_data: @view_data) %>
    ...
  </body>
</html>

yieldの代わりに、バック・コンポーネントをセットしています。

One more thing

React/Svelte(5) 版を作ってみたので、紹介します。

  • ルート・コンポーネント
  • バックエンド・コンポーネント(ERB)
  • エントリー・ポイント

以外は共通です。

React

ルート・コンポーネント

index.tsx

'use client';

import { Suspense, lazy } from 'react'

interface IndexProps { path: string, viewData?: string }

const index = ({ path, viewData: data }: IndexProps) => {
  const viewDataMap = JSON.parse(data ?? '{}')
  const viewData = viewDataMap && Object.keys(viewDataMap).length !== 0 ? viewDataMap : null

  const AsyncComponent = lazy(() => import(/* @vite-ignore */`./${path}`))

  return (
    <Suspense fallback={loading()}>
      <AsyncComponent { ...(viewData && { viewData }) } />
    </Suspense>
  )
}

function loading() {
  return <div>Loading...</div>
}

export default index

バックエンド・コンポーネント(ERB)

front/component.html.erb

<div data-turbo="false">
  <%= helpers.turbo_mount("index", props: { path: @path, "viewData" => @view_data.to_json }) %>
</div>

エントリー・ポイント(JS)

entrypoints/components.js

import { TurboMount } from 'turbo-mount'
import { registerComponent } from 'turbo-mount/react'
import index from '../components/index'

const turboMount = new TurboMount()

registerComponent(turboMount, 'index', index)

Svelte(5)

ルート・コンポーネント

index.svelte

<script lang="ts">
import { onMount } from "svelte";

let { path, viewData: data } = $props<{ path: string, viewData?: string }>()

const viewDataMap = $derived(JSON.parse(data ?? '{}'))
const viewData = $derived(viewDataMap && Object.keys(viewDataMap).length !== 0 ? viewDataMap : null)

let AsyncComponent: any = $state(null);

onMount(async () => {
  AsyncComponent = (await import(/* @vite-ignore */`./${path}.svelte`)).default
})
</script>

{#if AsyncComponent }
  <AsyncComponent viewData={viewData} />
{:else}
  <div>Loading...</div>
{/if}

バックエンド・コンポーネント(ERB)

front/component.html.erb

<div data-turbo="false">
  <%= helpers.turbo_mount("index", props: { path: @path, "viewData" => @view_data.to_json }) %>
</div>

エントリー・ポイント(JS)

entrypoints/components.js

import { TurboMount } from 'turbo-mount'
import { registerComponent } from 'turbo-mount/svelte'
import index from '../components/index.svelte'

const turboMount = new TurboMount()

registerComponent(turboMount, 'index', index)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?