Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Inertia.js によるSPA実装の効率化とその仕組み

サーバーサイドエンジニアの田実です!

SPAはMPAよりもユーザ体験が上がる可能性がある*1一方で、開発・運用が複雑になりがちです。APIスキーマの設計・管理、フロントエンドのルーティングの管理、認証認可・CORSなど、データを取得して表示するだけの簡単な画面でさえMPAよりもやるべきタスクが多く、コードを読み書きする際の認知負荷も高くなります。こういった煩雑さを回避するべく、フロントエンドの開発・運用負荷を減らすような仕組みを持ったサーバーサイドのフレームワークもあります。そこで、今回はLaravelなどのアプリケーションで利用できる Inertia.js というフロントエンドライブラリとその仕組みについて紹介したいと思います。Laravel以外にもRailsやDjangoなどが利用できるようなのですが、今回の記事ではバックエンドはLaravel、フロントエンドはReactのコードを使って紹介していきます。

Inertia.jsの使い方

Inertia.jsを使うと、MPAのテンプレートエンジンのようにフロントエンドのコンポーネントをビューとして利用することができます。ここでは、Inertia.jsの利用方法とサンプルコードを紹介していきます。

サーバーサイド

inertia.js関連のパッケージをインストールします。

$ composer require inertiajs/inertia-laravel

ミドルウェアの設定が必要なので以下のartisanコマンドでミドルウェアのクラスを生成します。

$ php artisan inertia:middleware

生成したミドルウェアを設定します。

<?php
// app/Http/Kernel.php

    protected $middlewareGroups = [
        'web' => [
            // ...
            \App\Http\Middleware\HandleInertiaRequests::class,
        ],

サーバー側のルーティングと処理内容を実装します。

<?php
// routes/web.php

Route::get('/hello', function () {
    return Inertia::render('HelloWorld', [
        'event' => [
            'id' => 123,
            'title' => 'test',
            'start_date' => '2022-01-02',
            'description' => 'hoge',
        ],
    ]);
});

フロントエンド

Inertia.js関連のパッケージをインストールします。Reactを使う場合は以下のコマンドを叩きます。

$ npm install @inertiajs/react

Inertia.jsのデフォルトのビューファイルである app.blade.php を作成します。

// resources/views/app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Laravel</title>

    @viteReactRefresh
    @vite(['resources/js/app.js'])
    @inertiaHead
</head>
<body class="antialiased">
@inertia
</body>
</html>

resources/js/app.js を以下のように作成します。

// resources/js/app.js
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'

createInertiaApp({
    resolve: name => {
        const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true })
        return pages[`./Pages/${name}.jsx`]
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />)
    },
})

画面ごとのコンポーネントを実装します。

// resources/js/Pages/HelloWorld.jsx
const HelloWorld = ({ event }) => {
    return <div>Hello World by Inertia.js</div>;
}
export default HelloWorld;

viteを起動して http://localhost:3000/hello といった感じでアクセスするとprops(今回はeventプロパティ)がバインドされた状態でコンポーネントがレンダリングされます。画面を追加する場合は、上記のようにLaravelでルーティングと処理を実装し、ビューをReactで実装していきます。

このようにテンプレートエンジンのBladeを利用するかのように、フロントエンドのコンポーネントをビューとして利用できるのが Inertia.js の特徴です。

おおまかな仕組み

初回アクセス時は X-Inertia ヘッダが付与されていない状態でHTTPリクエストされます。この場合、サーバー側では app.blade.php をベースにbodyの <div> タグがレンダリングされます。この <div> タグには data-page というプロパティにサーバーが返すレスポンスが埋め込まれています。初回アクセス時は data-page のJSONを解釈してフロントエンドでレンダリングします。

<div id="app" data-page="{"component":"HelloWorld","props":{"errors":{},"event":{"id":123,"title":"test","start_date":"2022-01-02","description":"hoge"}},"url":"\/hello","version":"4a736d6df57dc56a4691964a8fda8cb4"}">
</div>

data-pageは以下のようなJSON形式になっています。

{
    "component": "HelloWorld",
    "props": {
        "errors": {},
        "event": {
            "id": 123,
            "title": "test",
            "start_date": "2022-01-02",
            "description": "desc"
        }
    },
    "url": "/fuga",
    "version": "4a736d6df57dc56a4691964a8fda8cb4"
}

componentプロパティの値はコンポーネント名、propsはコンポーネントに入るプロパティを表しており、それぞれ Inertia::render() の第1引数、第2引数の値になっています。

LinkコンポーネントなどSPA的な遷移をした場合は X-Inertia ヘッダが true の状態でHTTPリクエストされます。このヘッダがついている場合は app.blade.php のHTMLを返すのではなく上記のJSONを直接返します。 コンポーネント名とデータを受け取ったら、フロントエンドがレンダリングすべきコンポーネントとpropsが確定、レンダリングされてSPA的な画面遷移を実現しています。

Inertia.jsを使う場合、使わない場合のシーケンスの違いは以下のようになります。

Inertia.jsを使わないSPAのフロー

Inertia.jsを使うSPAのフロー

コードリーディング

サーバーサイド

Laravelでは Inertia::render() が肝になっているのでこのメソッドが返すレスポンスを読んでいきます。

Inertia::render() は以下のように Inertia\Response クラスをインスタンス化して返します

<?php

    public function render(string $component, $props = []): Response
    {
        if ($props instanceof Arrayable) {
            $props = $props->toArray();
        }

        return new Response(
            $component,
            array_merge($this->sharedProps, $props),
            $this->rootView,
            $this->getVersion()
        );
    }

Inertia\Response クラスは toResponse() が定義されています。toResponse() で返しているレスポンスがLaravelのHTTPレスポンスになります。

<?php

    public function toResponse($request)
    {
        $only = array_filter(explode(',', $request->header('X-Inertia-Partial-Data', '')));

        $props = ($only && $request->header('X-Inertia-Partial-Component') === $this->component)
            ? Arr::only($this->props, $only)
            : array_filter($this->props, static function ($prop) {
                return ! ($prop instanceof LazyProp);
            });

        $props = $this->resolvePropertyInstances($props, $request);

        $page = [
            'component' => $this->component,
            'props' => $props,
            'url' => $request->getBaseUrl().$request->getRequestUri(),
            'version' => $this->version,
        ];

        if ($request->header('X-Inertia')) {
            return new JsonResponse($page, 200, ['X-Inertia' => 'true']);
        }

        return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]);
    }

下5行の部分が重要で、 X-Inertia ヘッダが設定されていれば $page のJSONレスポンスを返し、そうでなければ ResponseFactory::view() を返します。後者は Illuminate\Support\Facades\Response::view() で rootView には app、 バインドパラメータに ['page' => $page] が渡ります。

@inertiaHead ディレクティブは Directive::compile() を呼び出します。以下のようなコードになっていて、data-page にJSONエンコードした page パラメータが展開されることがわかります。

<?php

    public static function compile($expression = ''): string
    {
        $id = trim(trim($expression), "\'\"") ?: 'app';

        $template = '<?php
            if (!isset($__inertiaSsrDispatched)) {
                $__inertiaSsrDispatched = true;
                $__inertiaSsrResponse = app(\Inertia\Ssr\Gateway::class)->dispatch($page);
            }

            if ($__inertiaSsrResponse) {
                echo $__inertiaSsrResponse->body;
            } else {
                ?><div id="'.$id.'" data-page="{{ json_encode($page) }}"></div><?php
            }
        ?>';

        return implode(' ', array_map('trim', explode("\n", $template)));
    }

フロントエンド

createInertiaApp() から見ていきます。まず <div id="app" data-page="xxx"> タグの data-page のJSONをパースします。

const el = isServer ? null : document.getElementById(id)
const initialPage = page || JSON.parse(el.dataset.page)

パースした data-page の情報を引数に setup() を叩きます。

  // @ts-expect-error
  const resolveComponent = (name) => Promise.resolve(resolve(name)).then((module) => module.default || module)

  let head = []

  const reactApp = await resolveComponent(initialPage.component).then((initialComponent) => {
    return setup({
      // @ts-expect-error
      el,
      App,
      props: {
        initialPage,
        initialComponent,
        resolveComponent,
        titleCallback: title,
        onHeadUpdate: isServer ? (elements) => (head = elements) : null,
      },
    })
  })

setup() では以下のように Appコンポーネント をレンダリングします。

createRoot(el).render(<App {...props} />)

Appコンポーネントはコンポーネント名やプロパティをstate管理した上で、 createElement() を使って動的にコンポーネントをレンダリングしています。

export default function App({
  children,
  initialPage,
  initialComponent,
  resolveComponent,
  titleCallback,
  onHeadUpdate,
}) {
  const [current, setCurrent] = useState({
    component: initialComponent || null,
    page: initialPage,
    key: null,
  })
  // ...省略

  return createElement(
    HeadContext.Provider,
    { value: headManager },
    createElement(
      PageContext.Provider,
      { value: current.page },
      renderChildren({
        Component: current.component,
        key: current.key,
        props: current.page.props,
      }),
    ),
  )
}

Linkコンポーネント を使って画面遷移する場合は、 router.visit() が叩かれます。router.visit()ではAxiosで X-Inertia ヘッダを入れてAPIを叩きます。前述の通り、X-Inertiaヘッダが入っている場合はHTMLではなくJSONでコンポーネント名やプロパティを返すようになっています。

Axios({
      method,
      url: urlWithoutHash(url).href,
      data: method === 'get' ? {} : data,
      params: method === 'get' ? data : {},
      signal: this.activeVisit.cancelToken.signal,
      headers: {
        ...headers,
        Accept: 'text/html, application/xhtml+xml',
        'X-Requested-With': 'XMLHttpRequest',
        'X-Inertia': true,
        // ... 省略
      },
      // ...省略
    })
    .then((response) => {
        if (!this.isInertiaResponse(response)) {
          return Promise.reject({ response })
        }

        const pageResponse: Page = response.data

        // ... 省略

        return this.setPage(pageResponse, { visitId, replace, preserveScroll, preserveState })
      })

データを取得したら setPage() 経由で swapComponent() を呼び出します。React の場合は以下のように setCurrent() によって current の state を変更することでレンダリングされるコンポーネントが切り替わり、画面遷移を実現しています。

      swapComponent: async ({ component, page, preserveState }) => {
        setCurrent((current) => ({
          component,
          page,
          key: preserveState ? current.key : Date.now(),
        }))
      },

所感

Inertia.jsのメリット

Inertia.jsを使うとサーバーサイドのビューファイルを書くような感じでフロントエンドを実装できます。MPAのアプリケーションにおいてコントローラでビューファイル名とバインドするパラメータを指定するように、Inertia.jsではコンポーネント名とバインドするパラメータを指定してビューのレンダリングを行います。Inertia.jsを使わない場合、Reactで初期データを取得する際は useEffect() フックでAPIリクエストを行い、 useState() フックでステートを更新する必要があります。Inertia.jsではコンポーネントにpropsとしてすでにバインドされた状態になるため、初期データ取得においてこれらのフックを使う必要がなくなります。フロントエンドからAPIでデータを取得してレンダリングするのではなく、サーバーからデータが渡ってきてレンダリングする、というような開発体験になります。フロントエンドはReact・Vue・Svelteが利用でき、既存のフロントエンド資産も活用できます。

また、サーバーから暗黙的にpropsとしてデータやエラーが渡ってくるため、HTTPレベルのAPI設計をする必要がありません。APIの認証認可も、Webアプリケーションフレームワークのcookie/sessionを利用できるため簡単に対応できます。同一ドメイン・同一サーバーでホスティングすることを前提としているので、CORSの考慮やビルドしたHTMLのホスティングについても考える必要がありません。そのため、ビジネスロジックの実装により集中できます。

さらにルーティングがMPAの画面とSPAの画面で一元化されるので管理性がよく php artisan route:list などで一覧表示することもできます。 ルーティングが一元化されることによる認知負荷の減少も期待できます。

Inertia.jsを使わない場合は、以下のようなステップがコードを読んでいくと思います。

フロントエンドのルーティング(at ルーティングが定義されているJS)
=> コンポーネント(at コンポーネントのJS)
=> コンポーネントが利用しているAPIのルーティング(at routes/xxx.php)
=> APIの実装(at app/Http/Controller)

一方、Inertia.jsを使う場合はMPAと同等のフロー・ステップ数でコードをたどることができます。

サーバーのルーティングを確認する(at routes/xxx.php)
=> コントローラーの実装(at app/Http/Controller)
=> レンダリングするコンポーネント(at コンポーネントのJS)

フレームワークに依存せず利用できるのもメリットで Laravel, Rails, Django以外にもGoのアダプターがあったりします。

github.com

仕組みは前述の通りシンプルなものになっているのでサポートされていないフレームワークにも比較的少ないコードで対応できます。密結合と言われることもありますが、バックエンドやフロントエンドが比較的容易に代替可能という点ではそこまで密結合ではないのではと思っています。

また、Inertia.jsはCSRだけではなくSSRを使って初期表示のユーザ体験を上げることが可能です。SSRを使う場合は、Node.jsのサーバーを立てて、そのサーバーにレンダリングしてもらいHTTPレスポンスとして返却する、というようなフローになります。

Inertia.jsのデメリット

一方、フロントエンドとバックエンドの作業者が違う場合はAPIという接合点がなくなるため開発効率的に悪くなる可能性があります。具体的にはフロントエンドエンジニアもバックエンドのサーバーを動かしてもらいながら開発を進める必要があり、フロントエンドエンジニアの学習コストが増えます。

また、iOS/Androidなどアプリ用のAPIを作る必要がある場合はコントローラー側のコードを使い回すことができず、同じようなルーティング・処理を再度作成する必要があります。そのためアプリ用のAPIも兼用することがわかっている場合はInertia.jsは適していないかもしれません。

他のフロントエンド技術との比較

Laravelは Livewire というフロントエンド技術も持っています。LivewireはJavaScriptを書かなくてもSPAを実装できる技術で、PHPとBladeだけでステート管理やインタラクティブなページを実装できます。例えば以下のようにHTMLタグに属性を設定してPHPのコンポーネントを実装するだけでインクリメンタルサーチが実装できます。

<div>
    <input wire:model="search" type="text" placeholder="Search users..."/>
 
    <ul>
        @foreach($users as $user)
            <li>{{ $user->username }}</li>
        @endforeach
    </ul>
</div>

SEOフレンドリーなどメリットはありつつも、フロントエンドでできる計算をサーバーサイドで行うため、一般的なSPAと異なるフローになり、認知負荷が上がることが懸念されます。React・Vue・Svelteなどのフロントエンド技術と全く異なるアプローチのため、それらを併用して使うことやフロントエンド技術の移行が難しいと思われます。計算処理をPHPで書く必要があり、フロントエンドエンジニアとの分業ができなくなるかもしれません。

Hotwire もJavaScriptの記述量を減らす技術も1つです。Turbo はリンクやフォーム送信による画面遷移をSPAのようにシームレスに行う仕組み、Stimulus はSPAを実現する軽量JavaScriptフレームワークです。Turboは画面遷移・リロード・フレーム単位でのレンダリングなどを簡単に実現できますが*2、ステート管理やJavaScriptを使ったロジックの実装はサポートされていません。Stimulusは他のフロントエンド技術と共存が難しく React・Vueなどの資産を活かせないことや、技術の独自性が高さがゆえに学習コストも高くなる可能性があります。また、データ取得はフロントエンドから行う必要があるため、API設計・実装のペインは解消していません。

このように解決する課題やデメリットは様々ですが、これらと比べてInertia.jsでは独自記法がほぼ無く、フロントエンド技術を活かせる柔軟性が高い技術です。 Inertia.jsを利用しなくなった場合でも大枠を変えることなく、比較的少ない工数で移行できるのではないかと思います。*3

余談:最近のフロントエンド

フロントエンド領域ではフロントエンドとサーバーサイドとシームレスに記述できるようなライブラリが多数公開されています。Remix では loaderaction を使ってデータ取得・バインド・データ更新をフロントエンドとサーバーサイドの区別なく同一のファイルに記述することができます。Next.jsServer Components を使うとサーバーのコンポーネントからDBに直接アクセスしてそれをバインドしたり*4Server Actions を使ってサーバーサイドで定義した関数をフォームPOSTで呼ぶことができます。Blitz.js はサーバーサイドの処理を関数として定義し、それをフロントエンドでインポートして利用することで、APIを意識することなく一連の処理を記述することができます。これらの技術を使う際はサーバーサイドの言語がNode.jsになり、別の言語のアプリケーションを利用できません。RemixやNext.jsをBFF的に扱ってAPIを別の言語で書くようなアプローチもあると思いますが、アーキテクチャの複雑度が増して認知負荷が上がりそうな感じもします。

まとめ

Inertia.jsとその仕組みについて紹介しました。一昔前はMPA + jQueryで作られるアプリケーションがほとんどでしたが、今ではReactやVueなどのフロントエンド技術を使ったリッチなSPAを構築することが増えてきており、アプリケーションがより複雑になってきています。Inertia.jsを使うとフロントエンド技術を活かしながら、MPAのようにバックエンドのビューとしてフロントエンドを実装することができます。より高い品質とスピードで価値を提供できるように、こういった当たり前のようにある認知負荷を下げていくことは非常に重要です。

フロントエンド開発の効率化に取り組む際に、今回の記事が参考になれば幸いです!

*1:上がるとは言っていない。SPAよりMPAの方が体験良いんじゃないかと思うような画面ちょいちょいあるんですよね…w

*2:pjaxと呼ばれていた技術です

*3:Reactの場合はフロントのルーターを設定して useEffect() useState() でデータ取得・ステート管理しつつ、サーバーサイドはJSONレスポンスを返す、という対応になると思います

*4:ExperimentalですがPrismaも対応していそうです: https://www.prisma.io/react-server-components