Inertia

Inertia

Inertiaは、モダンなSPAの複雑さを排除しつつ、フレームワークに依存しない方法でシングルページアプリケーションを作成するための手段です。

したがって、テンプレートエンジンを使用した従来のサーバーレンダリングアプリケーションと、クライアントサイドのルーティングと状態管理を備えたモダンなSPAの中間地点となります。

Inertiaを使用することで、お気に入りのフロントエンドフレームワーク(Vue.js、React、Svelte、またはSolid.js)でSPAを作成できますが、別個のAPIを作成する必要はありません。

import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async index({ inertia }: HttpContext) {
const users = await User.all()
return inertia.render('users/index', { users })
}
}

インストール

新しいプロジェクトを開始し、Inertiaを使用したい場合は、Inertiaスターターキットをチェックしてください。

npmレジストリからパッケージをインストールするには、次のコマンドを実行します。

npm i @adonisjs/inertia

完了したら、次のコマンドを実行してパッケージを設定します。

node ace configure @adonisjs/inertia
  1. adonisrc.tsファイル内に以下のサービスプロバイダとコマンドを登録します。

    {
    providers: [
    // ...other providers
    () => import('@adonisjs/inertia/inertia_provider')
    ]
    }
  2. start/kernel.tsファイル内に以下のミドルウェアを登録します。

    router.use([() => import('@adonisjs/inertia/inertia_middleware')])
  3. config/inertia.tsファイルを作成します。

  4. アプリケーションを素早く開始するために、いくつかのスタブファイルをアプリケーションにコピーします。コピーされる各ファイルは、事前に選択したフロントエンドフレームワークに適応されます。

  5. ./resources/views/inertia_layout.edgeファイルを作成し、Inertiaの起動に使用されるHTMLページをレンダリングします。

  6. ./inertia/css/app.cssファイルを作成し、inertia_layout.edgeビューのスタイルに必要なコンテンツを追加します。

  7. ./inertia/tsconfig.jsonファイルを作成し、サーバーサイドとクライアントサイドのTypeScriptの設定を区別します。

  8. Inertiaとフロントエンドフレームワークをブートストラップするための./inertia/app/app.tsを作成します。

  9. ./inertia/pages/home.{tsx|vue|svelte}ファイルを作成し、アプリケーションのホームページをレンダリングします。

  10. ./inertia/pages/server_error.{tsx|vue|svelte}および./inertia/pages/not_found.{tsx|vue|svelte}ファイルを作成し、エラーページをレンダリングします。

  11. vite.config.tsファイルに正しいViteプラグインを追加します。

  12. start/routes.tsファイルに/のダミールートを追加し、Inertiaを使用してホームページをレンダリングします。

  13. 選択したフロントエンドフレームワークに基づいてパッケージをインストールします。

これで、AdonisJSアプリケーションでInertiaを使用する準備が整いました。開発サーバーを起動し、localhost:3333にアクセスして、選択したフロントエンドフレームワークを使用してInertiaでレンダリングされたホームページを表示できます。

**Inertia公式ドキュメント**をお読みください。

Inertiaはバックエンドに依存しないライブラリです。AdonisJSで動作するようにアダプターを作成しました。このドキュメントでは、Inertiaの特定の部分について説明します。

クライアントサイドのエントリーポイント

configureコマンドまたはaddコマンドを使用した場合、パッケージはinertia/app/app.tsにエントリーポイントファイルを作成します。そのため、このステップはスキップできます。

基本的に、このファイルはInertiaアプリケーションを作成し、ページコンポーネントを解決するために使用されます。inertia.renderを使用するときに作成したページコンポーネントは、resolve関数に渡され、この関数の役割はレンダリングする必要のあるコンポーネントを返すことです。

import { createApp, h } from 'vue'
import type { DefineComponent } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'
const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS'
createInertiaApp({
title: (title) => {{ `${title} - ${appName}` }},
resolve: (name) => {
return resolvePageComponent(
`./pages/${name}.vue`,
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
)
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})

The role of this file is to create an Inertia app and to resolve the page component. The page component you write when using inertia.render will be passed down the the resolve function and the role of this function is to return the component that need to be rendered.

ページのレンダリング

パッケージの設定中に、start/kernel.tsファイル内にinertia_middlewareが登録されています。このミドルウェアは、HttpContext上のinertiaオブジェクトを設定するための役割を果たします。

Inertiaを使用してビューをレンダリングするには、inertia.renderメソッドを使用します。このメソッドは、ビュー名とコンポーネントに渡すデータ(プロップ)を受け取ります。

app/controllers/home_controller.ts
export default class HomeController {
async index({ inertia }: HttpContext) {
return inertia.render('home', { user: { name: 'julien' } })
}
}

inertia.renderメソッドに渡されるhomeは、inertia/pagesディレクトリに対するコンポーネントファイルのパスである必要があります。ここでは、inertia/pages/home.(vue,tsx)ファイルをレンダリングしています。

フロントエンドコンポーネントは、userオブジェクトをプロップとして受け取ります。

<script setup lang="ts">
defineProps<{
user: { name: string }
}>()
</script>
<template>
<p>Hello {{ user.name }}</p>
</template>

これで完了です。

フロントエンドにデータを渡す際、すべてのデータはJSONにシリアライズされます。モデルのインスタンス、日付、その他の複雑なオブジェクトを渡すことはできません。

ルートEdgeテンプレート

ルートテンプレートは、通常のEdgeテンプレートであり、最初のページ訪問時に読み込まれます。CSSやJavaScriptファイルを含める場所であり、@inertiaタグも含める場所です。典型的なルートテンプレートは次のようになります。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>AdonisJS x Inertia</title>
@inertiaHead()
@vite(['inertia/app/app.ts', `inertia/pages/${page.component}.vue`])
</head>
<body>
@inertia()
</body>
</html>

config/inertia.tsファイルでルートテンプレートのパスを設定する必要があります。デフォルトでは、テンプレートはresources/views/inertia_layout.edgeにあると想定されています。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
// The path to the root template relative
// to the `resources/views` directory
rootView: 'app_root',
})

必要に応じて、rootViewプロパティに関数を渡して、動的に使用するルートテンプレートを決定することもできます。

import { defineConfig } from '@adonisjs/inertia'
import type { HttpContext } from '@adonisjs/core/http'
export default defineConfig({
rootView: ({ request }: HttpContext) => {
if (request.url().startsWith('/admin')) {
return 'admin_root'
}
return 'app_root'
}
})

ルートテンプレートデータ

ルートEdgeテンプレートとデータを共有する場合は、次のように行います。メタタイトルやオープングラフタグを追加するために、ルートテンプレートとデータを共有する必要がある場合があります。これを行うには、inertia.renderメソッドの3番目の引数を使用します。

app/controllers/posts_controller.ts
export default class PostsController {
async index({ inertia }: HttpContext) {
return inertia.render('posts/details', post, {
title: post.title,
description: post.description
})
}
}

titledescriptionは、ルートEdgeテンプレートで使用できるようになります。

resources/views/root.edge
<html>
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
<body>
@inertia()
</body>
</html

リダイレクト

リダイレクトを行う場合は、次のようにします。

export default class UsersController {
async store({ response }: HttpContext) {
await User.create(request.body())
// 👇 標準のAdonisJSのリダイレクトを使用できます
return response.redirect().toRoute('users.index')
}
async externalRedirect({ inertia }: HttpContext) {
// 👇 または、inertia.locationを使用して外部リダイレクトを行うこともできます
return inertia.location('https://adonisjs.com')
}
}

詳細については、公式ドキュメントを参照してください。

すべてのビューでデータを共有する

複数のビューで同じデータを共有する必要がある場合があります。たとえば、現在のユーザー情報をすべてのビューで共有する必要がある場合があります。各コントローラでこれを行うのは手間がかかる場合があります。幸いなことに、この問題に対してはいくつかの解決策があります。

sharedData

config/inertia.tsファイルでsharedDataオブジェクトを定義できます。このオブジェクトは、すべてのビューで共有するデータを定義するために使用できます。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
sharedData: {
// 👇 すべてのビューで使用できます
appName: 'My App' ,
// 👇 現在のリクエストに対してスコープが限定されます
user: (ctx) => ctx.auth?.user,
// 👇 現在のリクエストに対してスコープが限定されます
errors: (ctx) => ctx.session.flashMessages.get('errors'),
},
})

ミドルウェアから共有

ミドルウェアからデータを共有する方が便利な場合もあります。inertia.shareメソッドを使用してデータを共有できます。

import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class MyMiddleware {
async handle({ inertia, auth }: HttpContext, next: NextFn) {
inertia.share({
appName: 'My App',
user: (ctx) => ctx.auth?.user
})
}
}

部分的な再読み込みと遅延データ評価

部分的な再読み込みとは何か、そしてそれがどのように機能するかを理解するために、公式ドキュメントを最初に読んでください。

AdonisJSにおける遅延データ評価については、次のように機能します:

export default class UsersController {
async index({ inertia }: HttpContext) {
return inertia.render('users/index', {
// 最初の訪問時には常に含まれます。
// 部分的な再読み込み時にオプションで含まれます。
// 常に評価されます。
users: await User.all(),
// 最初の訪問時には常に含まれます。
// 部分的な再読み込み時にオプションで含まれます。
// 必要な時にのみ評価されます。
users: () => User.all(),
// 最初の訪問時には含まれません。
// 部分的な再読み込み時にオプションで含まれます。
// 必要な時にのみ評価されます。
users: inertia.lazy(() => User.all())
}),
}
}

タイプの共有

通常、フロントエンドのページコンポーネントに渡すデータのタイプを共有したいと思うでしょう。これを行うための簡単な方法は、InferPageProps型を使用することです。

export class UsersController {
index() {
return inertia.render('users/index', {
users: [
{ id: 1, name: 'julien' },
{ id: 2, name: 'virk' },
{ id: 3, name: 'romain' },
]
})
}
}

Vueを使用している場合、definePropsで各プロパティを手動で定義する必要があります。これはVueの面倒な制限です。詳細については、このissueを参照してください。

<script setup lang="ts">
import { InferPageProps } from '@adonisjs/inertia'
defineProps<{
// 👇 各プロパティを手動で定義する必要があります
users: InferPageProps<UsersController, 'index'>['users'],
posts: InferPageProps<PostsController, 'index'>['posts'],
}>()
</script>

リファレンスディレクティブ

Inertiaアプリケーションは、独自のTypeScriptプロジェクト(独自のtsconfig.jsonを持つ)であるため、TypeScriptに特定の型を理解させるためにリファレンスディレクティブを使用する必要があります。公式パッケージの多くは、モジュール拡張を使用してAdonisJSプロジェクトに特定の型を追加します。

たとえば、HttpContextauthプロパティとその型は、@adonisjs/auth/initialize_auth_middlewareをプロジェクトにインポートすることでのみ利用できます。ただし、Inertiaプロジェクトではこのモジュールをインポートしていないため、authを使用するコントローラからページプロップスを推論しようとすると、TypeScriptエラーまたは無効な型が返される可能性があります。

この問題を解決するには、リファレンスディレクティブを使用してTypeScriptに特定の型を理解させる必要があります。これを行うには、inertia/app/app.tsファイルに次の行を追加します。

/// <reference path="../../adonisrc.ts" />

使用する型に応じて、モジュール拡張を使用する他の参照ディレクティブを追加する必要がある場合もあります。これには、モジュール拡張を使用する特定の設定ファイルへの参照も含まれます。

/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/ally.ts" />
/// <reference path="../../config/auth.ts" />

型レベルのシリアライズ

InferPagePropsについて重要なことは、渡したデータが型レベルでシリアライズされるということです。たとえば、Dateオブジェクトをinertia.renderに渡すと、InferPagePropsからの結果の型はstringになります。

export default class UsersController {
async index({ inertia }: HttpContext) {
const users = [
{ id: 1, name: 'John Doe', createdAt: new Date() }
]
return inertia.render('users/index', { users })
}
}

これは、日付がJSONでネットワーク経由で送信される際に文字列にシリアライズされるため、完全に理にかなっています。

モデルのシリアライズ

前述のポイントを念頭に置いて、もう1つ重要なことは、AdonisJSモデルをinertia.renderに渡すと、InferPagePropsからの結果の型がModelObjectになることです。これは、ほとんど情報を含まない型です。これは問題です。この問題を解決するためには、いくつかのオプションがあります。

  • inertia.renderに渡す前にモデルを単純なオブジェクトにキャストする。
  • モデルを単純なオブジェクトに変換するためのDTO(データ転送オブジェクト)システムを使用する。
class UsersController {
async edit({ inertia, params }: HttpContext) {
const user = users.serialize() as {
id: number
name: string
}
return inertia.render('user/edit', { user })
}
}

これで、フロントエンドコンポーネントで正確な型を使用できるようになります。

共有プロパティ

コンポーネント内で共有データの型を使用するには、config/inertia.tsファイルでモジュール拡張を行っていることを確認してください。

// file: config/inertia.ts
const inertiaConfig = defineConfig({
sharedData: {
appName: 'My App',
},
});
export default inertiaConfig;
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {
// 必要に応じて、ミドルウェアから共有されるプロパティなど、いくつかの共有プロパティを手動で追加することもできます
propsSharedFromAMiddleware: number;
}
}

また、inertia/app/app.tsファイルにこのリファレンスディレクティブを追加することも忘れないでください。

/// <reference path="../../config/inertia.ts" />

これが完了すると、コンポーネント内で共有プロパティにアクセスできるようになります。InferPagePropsには、共有プロパティの型とinertia.renderで渡されるプロパティの型が含まれます。

// file: inertia/pages/users/index.tsx
import type { InferPageProps } from '@adonisjs/inertia/types'
export function UsersPage(
props: InferPageProps<UsersController, 'index'>
) {
props.appName
// ^? string
props.propsSharedFromAMiddleware
// ^? number
}

必要に応じて、SharedProps型を使用して共有プロパティの型のみにアクセスすることもできます。

import type { SharedProps } from '@adonisjs/inertia/types'
const page = usePage<SharedProps>()

CSRF

アプリケーションでCSRF保護を有効にした場合は、config/shield.tsファイルでenableXsrfCookieオプションを有効にする必要があります。

このオプションを有効にすると、XSRF-TOKENクッキーがクライアント側に設定され、すべてのリクエストと共にサーバーに送信されるようになります。

InertiaとCSRF保護を連携させるためには、追加の設定は必要ありません。

アセットのバージョニング

アプリケーションを再デプロイする際に、ユーザーは常に最新バージョンのクライアントサイドアセットを取得する必要があります。これは、InertiaプロトコルとAdonisJSでデフォルトでサポートされている機能です。

デフォルトでは、@adonisjs/inertiaパッケージはpublic/assets/manifest.jsonファイルのハッシュを計算し、それをアセットのバージョンとして使用します。

この動作をカスタマイズする場合は、config/inertia.tsファイルを編集します。versionプロパティはアセットのバージョンを定義し、文字列または関数のいずれかを指定できます。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
version: 'v1'
})

詳細については、公式ドキュメントを参照してください。

SSR

SSRの有効化

Inertia Starter Kitには、サーバーサイドレンダリング(SSR)のサポートがデフォルトで組み込まれています。したがって、アプリケーションでSSRを有効にする場合は、それを使用するようにしてください。

SSRを有効にしていないでアプリケーションを開始した場合でも、以下の手順にしたがって後から有効にできます。

サーバーエントリーポイントの追加

まず、クライアントエントリーポイントと非常に似たサーバーエントリーポイントを追加する必要があります。このエントリーポイントは、最初のページ訪問をサーバー上でレンダリングし、ブラウザではなくサーバー上で行います。

inertia/app/ssr.tsというファイルを作成し、次のような関数をデフォルトエクスポートしてください。

import { createInertiaApp } from '@inertiajs/vue3'
import { renderToString } from '@vue/server-renderer'
import { createSSRApp, h, type DefineComponent } from 'vue'
export default function render(page) {
return createInertiaApp({
page,
render: renderToString,
resolve: (name) => {
const pages = import.meta.glob<DefineComponent>('./pages/**/*.vue')
return pages[`./pages/${name}.vue`]()
},
setup({ App, props, plugin }) {
return createSSRApp({ render: () => h(App, props) }).use(plugin)
},
})
}

設定ファイルの更新

config/inertia.tsファイルに移動し、ssrプロパティを更新して有効にします。また、別のパスを使用している場合は、サーバーエントリーポイントへのパスを指定してください。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
// ...
ssr: {
enabled: true,
entrypoint: 'inertia/app/ssr.tsx'
}
})

Viteの設定の更新

まず、inertia viteプラグインを登録していることを確認してください。次に、vite.config.tsファイルでサーバーエントリーポイントへのパスを更新します(別のパスを使用している場合)。

import { defineConfig } from 'vite'
import inertia from '@adonisjs/inertia/client'
export default defineConfig({
plugins: [
inertia({
ssr: {
enabled: true,
entrypoint: 'inertia/app/ssr.tsx'
}
})
]
})

サーバー上で最初のページ訪問をレンダリングし、その後クライアントサイドのレンダリングを続けることができます。

SSRの許可リスト

SSRを使用する場合、すべてのコンポーネントをサーバーサイドでレンダリングする必要はありません。たとえば、認証によって制限された管理ダッシュボードを構築している場合、これらのルートはサーバーでレンダリングする理由はありません。ただし、同じアプリケーションでは、SEOを向上させるためにSSRを活用できるランディングページがあるかもしれません。

したがって、サーバーでレンダリングする必要があるページをconfig/inertia.tsファイルに追加できます。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
ssr: {
enabled: true,
pages: ['home']
}
})

また、pagesプロパティに関数を渡すこともできます。これにより、動的にサーバーでレンダリングするページを決定できます。

import { defineConfig } from '@adonisjs/inertia'
export default defineConfig({
ssr: {
enabled: true,
pages: (ctx, page) => page.startsWith('admin')
}
})

テスト

フロントエンドコードをテストするためには、いくつかの方法があります。

  • E2Eテスト。Browser Clientを使用して、JapaとPlaywrightをシームレスに統合できます。
  • ユニットテスト。フロントエンドエコシステムに適したテストツールを使用することをおすすめします。特にVitestがあります。

さらに、正しいデータが返されることを確認するためにInertiaエンドポイントをテストすることもできます。そのためには、Japaで使用できるいくつかのテストヘルパーがあります。

まず、test/bootstrap.tsファイルでinertiaApiClientapiClientプラグインを設定していることを確認してください。

tests/bootstrap.ts
import { assert } from '@japa/assert'
import app from '@adonisjs/core/services/app'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { apiClient } from '@japa/api-client'
import { inertiaApiClient } from '@adonisjs/inertia/plugins/api_client'
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
apiClient(),
inertiaApiClient(app)
]

次に、withInertia()を使用してInertiaエンドポイントをリクエストし、データが正しくJSON形式で返されることを確認します。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertStatus(200)
response.assertInertiaComponent('home/main')
response.assertInertiaProps({ user: { name: 'julien' } })
})

エンドポイントをテストするために利用できるさまざまなアサーションを見てみましょう。

withInertia()

リクエストにX-Inertiaヘッダーを追加します。データが正しくJSON形式で返されることを保証します。

assertInertiaComponent()

サーバーが返すコンポーネントが予想どおりであることを確認します。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaComponent('home/main')
})

assertInertiaProps()

サーバーが返すプロパティが、パラメータとして渡されたものと完全に一致することを確認します。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaProps({ user: { name: 'julien' } })
})

assertInertiaPropsContains()

サーバーが返すプロパティが、パラメータとして渡されたものの一部を含んでいることを確認します。

test('returns correct data', async ({ client }) => {
const response = await client.get('/home').withInertia()
response.assertInertiaPropsContains({ user: { name: 'julien' } })
})

追加のプロパティ

これらのアサーションに加えて、ApiResponseオブジェクトで以下のプロパティにアクセスできます。

test('returns correct data', async ({ client }) => {
const { body } = await client.get('/home').withInertia()
// サーバーが返すコンポーネント
console.log(response.inertiaComponent)
// サーバーが返すプロパティ
console.log(response.inertiaProps)
})

FAQ

フロントエンドコードを更新すると、サーバーが常にリロードされるのはなぜですか?

Reactを使用していると仮定しましょう。フロントエンドコードを更新するたびに、サーバーがリロードされ、ブラウザがリフレッシュされます。ホットモジュールリプレースメント(HMR)の機能を活用できていません。

これを解決するには、ルートのtsconfig.jsonファイルからinertia/**/*を除外する必要があります。

{
"compilerOptions": {
// ...
},
"exclude": ["inertia/**/*"]
}

なぜなら、サーバーの再起動を担当するAdonisJSプロセスは、tsconfig.jsonファイルに含まれるファイルを監視しているからです。

プロダクションビルドが機能しないのはなぜですか?

次のようなエラーが発生している場合:

X [ERROR] Failed to load url inertia/app/ssr.ts (resolved id: inertia/app/ssr.ts). Does the file exist?

一般的な問題は、プロダクションビルドを実行する際にNODE_ENV=productionを設定し忘れていることです。

NODE_ENV=production node build/server.js

Top-level await is not available...というエラーが発生します。

次のようなエラーが発生している場合:

X [ERROR] Top-level await is not available in the configured target environment ("chrome87", "edge88", "es2020", "firefox78", "safari14" + 2 overrides)
node_modules/@adonisjs/core/build/services/hash.js:15:0:
15 │ await app.booted(async () => {
╵ ~~~~~

おそらく、バックエンドのコードをフロントエンドにインポートしているためです。エラーメッセージをよく見ると、Viteによって生成されたエラーであることがわかります。Viteは、node_modules/@adonisjs/coreからコードをコンパイルしようとしています。したがって、バックエンドのコードがフロントエンドのバンドルに含まれることになります。これはおそらく望ましくない状況です。

一般的に、このエラーは、フロントエンドで型を共有しようとしている場合に発生します。これを実現したい場合は、常にimport typeを使用してこの型をインポートするようにしてください。

// ✅ 正しい
import type { User } from '#models/user'
// ❌ 間違っている
import { User } from '#models/user'