クイック エンジニアリングブログ

株式会社クイック Web事業企画開発本部のエンジニアリングチームが運営する技術ブログです。

現環境に近いSSRとしてLaravel×React×Inertia×Vite

こんにちは王子です。 最近は恐竜が闊歩するオープンワールドで過酷なサバイバルに挑むアクションゲーム「ARK: Survival Ascended」にハマっています。 旧Arkもひたすら遊んだので目新しさはありませんが、最初からIslandでプレイしてテリジノサウルスやアルゲンタヴィスをテイム&ライドするくらいまで進みました。

打倒!ブルードマザー!!

さて、ここ数日、SSR(Server-Side-Rendering)方式の実現方法について調査する機会がありました。 SPA/SSR/SSGについては本記事の主題ではありませんので、先に簡略な表現で記載すると下記のようになるでしょうか。

SPA(Single-Page-Application)

簡素なhtmlを先に受け取り、アプリ全体をscriptとして受け取る。アプリ構成は初回に受け取り済みのため、ページ遷移そのものはデータ通信を伴わない。SEOに弱い。ツール・業務システム向け。

SSR(Server-Side-Rendering)

httpのレスポンスとしてサーバー側で描画済みのhtmlを返す。画面で利用するJavaScriptは後追いで渡され(hyderateされ)、受け取り以降に利用できるようになる。SEOに強い。webサイト向け。

SSG(Server-Side-Generation)

事前にhtmlを出力しておく。他はSSRと変わらずか(hyderate時の挙動や適用範囲を調べたことがない)。私はこれをMovableTypeとつい呼んでしまう悪い癖があり、年齢がバレる気がしています。

背景

クイックではシステムの標準frameworkにLaravelを利用しています。

出来上がるシステムからすれば開発環境がなんであるかはあまり重要ではありませんが、開発で利用する様々な機能群(Migration/Seeder/Middlewareやテスト)やproduction環境におけるログ・死活監視などを考慮すると、どのようなものを作るせよ、弊害がない限り基本の開発環境をLaravelに寄せて、利用環境も似たような状況に寄せていきたいと考えています。

そうすることで新しい何かを作るときの準備時間や開発メンバーが異動するようなタイミングで一から色々と覚えなおす時間などのコストを低減できる見込みがあるからです。

SSR方式で開発を行う場合のframeworkを簡単に調べるとNext.js(or Nuxt)やSvelteなどが真っ先に出てくるでしょうか、少なくともLaravelは出てきません。そしてこういったframeworkではSSR時の描画にnodeでの待ち受けを前提としているため、開発環境だけでなくproductionの環境もnode前提の構成に変更していく必要があり、やや全体のコストが高い想定となりました。

そこで、LaravelやReact/Typescriptを前提としていくつかの環境を比較し、移行コストの低そうなSSR環境を探してみたのでその内容をブログ記事にしたいと思います。

やったこと

  • 各環境の比較
    • Laravel + Next.js
    • Laravel + Vite
    • Laravel + Inertia.js + Vite
  • Laravel + Inertia.js + Viteでの環境構築

各環境の比較

  • 同じ性質のものを横並びにしていないため、雰囲気での判定はご容赦ください
  • 対応コストが高いものは暫定で不可と判定していますが、技術的には可能です
  • 一般的に「技術的には可能」=「無理」と理解しています
Laravel + Next.js Laravel + Vite Laravel + Inertia.js + Vite
DBのmigration Laravel利用可 Laravel利用可 Laravel利用可
Seeder利用 Laravel利用可 Laravel利用可 Laravel利用可
待ち受け node node Laravel
Middleware利用 API待ち受けをLaravelにすれば、API側では可 API待ち受けをLaravelにすれば、API側では可
所感 Laravelにこだわる理由がない環境になる 依存が少ない分、開発時の考慮が多い フロント描画を除いてほぼ従来のLaravel利用と変わらない
Laravel + Next.js

最も有名と思われるため、この選択で間違いないだろうと事前予想していましたが、環境を構築してみたところ、Laravelの介在価値が非常に低くなりました。

ネットの情報量は最も多く、開発メンバーがキャッチアップを行いやすいという魅力はありますし、複数の方式に対応していて優れたframeworkであると思うのですが、現時点ではクイックにとってproduction環境の構築コストが高いという無視できないネックがあります。

Laravelのことは忘れて、production環境をnodeを軸に構成し…と進めるならこちらでしょう。

Laravel + Vite

Laravel9あたりからビルドは基本Viteの初期設定となっています。このViteがビルド時に--ssrのオプションを利用することでSSR方式にも対応するようになっており、公式ドキュメントに記載とサンプルがあります。

ただし待ち受けがnodeのためLaravelの介在価値はNext.jsと同様に低く、上記のとおり最小構成のため、開発側で対応すべき項目がより多くなる結果となりました。

Laravel + Inertia.js + Vite

公式ドキュメントによるとクラシックなMVCとReact/Vueなどを結びつけるアダプターと記載があり、実際にそのような挙動になるので背景を考慮するとこれが最も適していそうです。

基本のweb待ち受けまでをLaravelで担当し、view表示タイミングでInertiaを介してhtmlの描画を行います。このタイミングでデータの受け渡しも行えるため、APIの作成も最低限で済みそうな気配です。従来のMVC開発の経験しかないメンバーにも親和性が高い構成になるとも思えました。

これら調査結果から、Laravel + Inertia.js + Viteに絞り、環境構築&色々な実装テストをおこなってみることにしました。

Laravel×React×Inertia×Viteの環境構築

公式ドキュメントにはBreeze/Jetstreamのスターターキットの案内がありますが、変更差分が追いにくくなるのでコンテナにマニュアルでセットアップしていきます。

ファイル構成
※laravelのインストールで出来るフォルダは省略
[root] 
 [laravel-inertia-template-app]
    [docker]
      [web]
        apache-virtualhost.conf
        Dockerfile
        php.ini
        server.sh
    [routes]
      web.php
    [resources]
      [js]
        [Pages]
          First.tsx
        app.tsx
        ssr.js
      [views]
        app.blade.php
    .env
    package.json
    tsconfig.json
    vite.config.js
docker-compose.yml
  • 他環境と衝突しないように、localhost:9190を80ポートとして利用
###########################
# DBなどは無視して最低限の環境で

services:
  ###########################
  # template_web : php + Apache + Composer + npm
  # webサーバー
  template_web:
    container_name: template_web
    build: ./laravel-inertia-template-app/docker/web
    env_file:
      - ./laravel-inertia-template-app/.env
    environment:
      - APP_TIMEOUT=60
      - PHP_MEMORY_LIMIT=256M
      - PHP_MAX_UPLOAD=20M
    working_dir: /var/www/html/laravel-inertia-template-app
    ports:
      - "9190:80"   # Laravel用
      - "5173:5173" # Vite用
    networks:
      - template_local
    volumes:
      - ./:/var/www/html

networks:
  template_local:
    name: template_local
    driver: bridge
    driver_opts:
      com.docker.network.enable_ipv6: "false"
    ipam:
      driver: default
      config:
        - subnet: 172.16.246.0/24 # 他と衝突しなければなんでも良い認識
DockerFile & server.sh
  • DockerFile
# use PHP official image
FROM php:8.3.3-apache-bullseye

# install pecl extensions
RUN apt-get update \
 && apt-get install -y libzip-dev unzip \
 && docker-php-ext-install zip pdo mysqli pdo_mysql \
 && docker-php-ext-install opcache \
 && yes '' | pecl install redis

# install composer
RUN curl https://getcomposer.org/download/2.7.1/composer.phar -o /usr/local/bin/composer -s \
 && chmod 755 /usr/local/bin/composer

# install node.js
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash -\
 && apt-get install -y nodejs

# install git for local
RUN apt-get update && \
    apt-get install -y git

# set configuration for apache and php
COPY apache-virtualhost.conf /etc/apache2/sites-enabled/000-default.conf
COPY php.ini /usr/local/etc/php/php.ini
COPY server.sh /usr/local/bin/server.sh
RUN chmod 755 /usr/local/bin/server.sh
RUN ln -s /etc/apache2/mods-available/rewrite.load /etc/apache2/mods-enabled/rewrite.load

# set entrypoint
ENTRYPOINT ["/usr/local/bin/server.sh"]
server.sh
#!/bin/sh

# get current user setting.
USER=`grep apache /etc/passwd`

# set user if user is not exist.
if [ -z "$USER" ] && [ ! -z "$APACHE_UID" ]; then
  useradd -u $APACHE_UID  apache
  mkdir -m 755 /home/apache
  chown apache /home/apache
  export APACHE_RUN_USER=apache
fi

# start apache.
apache2-foreground

wait

ここまででコンテナが立ち上がるように調整する .envやconfは用意しておかないと正常に起動しない

composer
  • Laravelを入れたらInertiaだけ入れればOK
# コンテナ上で実施

# フォルダを一度空にする必要があるのでファイルを退避させてから
composer create-project laravel/laravel .

# inertiaのインストール
composer require inertiajs/inertia-laravel

正常にインストールできていればlocalhost:9190へのアクセスでlaravelのwelcome画面が表示される

npm
  • React/TypeScript関係とViteに関連するモノ
# コンテナ上で実施

# typescript/react関係
npm install --save-dev typescript react react-dom @types/react @types/react-dom @inertiajs/react

# Vite関連
npm install --save-dev @vitejs/plugin-react
コード準備
  • app.blade.php / メインの呼び出し元になるblade
  • SPAのようなbladeになるが、これを元にSSRされる
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
    @vite('resources/js/app.tsx')
    @inertiaHead
</head>
<body>
@inertia
</body>
</html>
  • middlewareの準備
# コンテナ上で下記を実行して
php artisan inertia:middleware

# App\Http\Kernelのwebに下記を追記
'web' => [
    // ...
    \App\Http\Middleware\HandleInertiaRequests::class,
],
  • app.tsx / bladeから呼ぶコード
    • ViteのHMRとSSR時で処理を分岐
    • Pages以下のroutingに合致するコンポーネントを呼び出して処理する
import * as React from 'react';
import './bootstrap';
import { createInertiaApp } from '@inertiajs/react'
import {createRoot, hydrateRoot} from 'react-dom/client'

createInertiaApp({
  resolve: name => {
    const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
    return pages[`./Pages/${name}.tsx`]
  },
  setup({ el, App, props }) {
    if (import.meta.env.VITE_APP_ENV === "production") {
      hydrateRoot(el, <App {...props} />)
    }
    else {
      createRoot(el).render(<App {...props} />)
    }
  },
})
  • ssr.js / ssr実行時のエントリポイント
    • Pages以下のroutingに合致するコンポーネントを呼び出して処理する
import * as React from 'react';
import { createInertiaApp } from '@inertiajs/react'
import createServer from '@inertiajs/react/server'
import ReactDOMServer from 'react-dom/server'

createServer(page =>
    createInertiaApp({
        page,
        render: ReactDOMServer.renderToString,
        resolve: name => {
            const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
            return pages[`./Pages/${name}.tsx`]
        },
        setup: ({ App, props }) => <App {...props} />,
    }),
)
  • vite.config.js
    • ssrのinputを追加
    • HMRのpollingは無茶しないように設定変更しているが必須ではない
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.tsx'],
            ssr: 'resources/js/ssr.jsx',
            refresh: true,
        }),
    ],
    server: {
        host: true,
        port: 5173,
        hmr: {
            host: 'localhost'
        },
        watch: {
            usePolling: true,
            interval: 1000,
        }
    },
});
  • tsconfig.son
    • こちらはよしなに。ひとまず下記
{
    "compilerOptions": {
        "target": "esnext",
        "module": "esnext",
        "moduleResolution": "node",
        "strict": true,
        "jsx": "preserve",
        "sourceMap": true,
        "resolveJsonModule": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "lib": [
            "esnext",
            "dom"
        ],
        "types": [
            "vite/client"
        ],
        "baseUrl": ".",
        "paths": {
            "@/*": [
                "resources/*"
            ]
        }
    },
    "include": [
        "resources/**/*"
    ],
    "exclude": [
        "node_modules"
    ]
}
  • package.json
    • ビルド方式をSSRに変更する
{
    "private": true,
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "vite build && vite build --ssr",
        "tsc": "tsc"
    },

...(省略)
  • web.php
    • FirstのURLにアクセスがあった場合、Pages以下のFirst.tsxを参照する
    • パラメータの受け渡しテストのため、適当な文字列や数値を設定
Route::get('/First', function () {
    return Inertia::render('First', [
        'numberValue' => 919,
        'stringValue' => "Quick",
        'arrayValue' => [
            11,
            22,
            "33",
            44,
        ]
    ]);
});
  • First.tsx
    • 今回描画したいコンポーネント
import * as React from 'react';

interface IPros {
  numberValue: number;
  stringValue: string;
  arrayValue: number[];
}
export const First: React.FC<IPros> = ({ numberValue, stringValue, arrayValue }) => {

  const handleClick = () => {
    console.log('クリックされた')
  }

  return (
    <React.Fragment>
      <div>
        This is First Page.
      </div>
      <div>
        number: { numberValue }
      </div>
      <div>
        string: { stringValue }
      </div>
      {arrayValue.map(v => {
        return (
          <div key={v}>
            {v}
          </div>
        )
      })}
      <div
        style={{background: '#F00' }}
        onClick={() => handleClick()}
      >
        test
      </div>
    </React.Fragment>
  )
}
export default First;
ローカル開発時のHMR
# ローカルでのHMRビルドを実施
npm run dev

# 下記のような表示が出てくる

  LARAVEL v10.47.0  plugin v1.0.2
                                 
  ➜  APP_URL: http://localhost   
  • 上記を実施したあとにhttp://localhost:9190/Firstにアクセスすると画像のようなページが表示される
  • HMR状態なので、コードを変更すると即座に変更内容が反映された状態で再描画される

ページの表示結果

ビルドと待ち受け
# ビルド処理
npm run build

# ビルド後、動作確認のためには下記のコマンドを実施する
php artisan inertia:start-ssr

# 下記のような表示が出てくる
Starting SSR server on port 13714...
Inertia SSR server started.
  • 上記を実施したあとにhttp://localhost:9190/Firstにアクセスすると先程と同じ画面が表示される
  • SSRしているため、webブラウザのJavaScriptをoffにしても画面が表示される
    • ※ボタンを押した時の処理は実施されない(hyderateされない)

最後に

まだ検証は続いていて、UIフレームワーク導入やフォームからのpost処理などを調査中です。 このあたりに問題がなければ、あとはAWS上に配置してみて監視などが行えつつ、大量アクセス時に特別な問題点がなければ上々と考えています。

以上ここまでお読みいただきありがとうございました。


\\『真のユーザーファーストでマーケットを創造する』仲間を募集中です!! //

919.jp