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="{"path":"frame/Comments","view-data":"{}"}"></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)