📑

inertia.jsのLinkとファイルダウンロードについて

2024/12/19に公開

https://inertiajs.com/links

個人的に重要なところだけピックアップしつつ解説していく。

単純なページ遷移を試してみる

なお、ここでは全てreactを使う。

以下のように2つのルートを作る

  • /demo リンク元
  • /demo/page リンク先

routes/web.php

use Inertia\Inertia;
Route::get('/demo', function () {
    return Inertia::render('Demo/Index', [ ]);
})->name('demo.index');

Route::get('/demo/page', function () {
    return Inertia::render('Demo/Page', [ ]);
})->name('demo.page');

このように単純に2ファイルレンダリングする準備をrouteの中のクロージャーで完結している。
以降それぞれ見ていこう。

Index.jsx

resources/js/Pages/Demo/Index.jsx

import { Link, Head } from '@inertiajs/react';

export default function Demo() {
  return (
    <>
      <Head title="Demo" />
      <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
        <h1 className="text-4xl font-extrabold text-gray-800 mb-6">Link Demo</h1>
        <div className="flex space-x-4 mb-6">
          <Link
            href={route('demo.page')}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            Demo Page (Inertia.js Link)
          </Link>
        </div>
      </div>
    </>
  );
}

tailwind cssのクラスがclassNameにもりもり付いてて若干うっとうしいけど、まあこんな感じでLinkを作成できる。以下のような見た目になるだろう。

見た目はともかく、Linkで遷移できる事を確認するために、Demo/Pageも作る

Page.jsx

import { Link, Head } from '@inertiajs/react';

export default function Page() {
  return (
    <>
      <Head title="Demo Page" />
      <div className="min-h-screen bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-600 flex flex-col items-center justify-center text-white">
        <header className="text-center mb-8">
          <h1 className="text-5xl font-bold drop-shadow-lg">Welcome to the Demo Page</h1>
          <p className="mt-4 text-lg opacity-90">Exploring the possibilities of Inertia.js and Tailwind CSS</p>
        </header>

        <main className="w-full max-w-4xl p-6 bg-white bg-opacity-20 rounded-lg shadow-lg">
          <section className="mb-6">
            <h2 className="text-3xl font-semibold mb-4">Features</h2>
            <ul className="space-y-3 list-disc list-inside">
              <li className="opacity-90">Dynamic routing with <code>@inertiajs/react</code></li>
              <li className="opacity-90">Modern UI powered by Tailwind CSS</li>
              <li className="opacity-90">Seamless navigation</li>
            </ul>
          </section>

          <section className="mb-6">
            <h2 className="text-3xl font-semibold mb-4">Learn More</h2>
            <p className="opacity-90">
              Check out the official documentation for
              <a href="https://inertiajs.com/" className="text-blue-300 underline hover:text-blue-400 ml-1">
                Inertia.js
              </a>
              and
              <a href="https://tailwindcss.com/" className="text-blue-300 underline hover:text-blue-400 ml-1">
                Tailwind CSS
              </a>.
            </p>
          </section>

          <section className="text-center">
            <Link
              href={route('demo.index')}
              className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
            >
              Back to Demo
            </Link>
          </section>
        </main>

        <footer className="mt-8 text-sm opacity-70">
          <p>&copy; 2024 Demo Project. All rights reserved.</p>
        </footer>
      </div>
    </>
  );
}

これはまあAIが作ったんだけど、とりあえずテンションの上がりそうなデモページができあがったんじゃないかな?

これでIndex → Page → Indexの遷移ができる事を確認しておく。

こんな感じにレンダリングされる。

さらに

          <Link
            href={route('demo.page')}
            as="button"                                                                                                             className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            Demo Page (Inertia.js Link)
          </Link>

ここで as="button" とすると、もはや見た目はcssでwrapされててようわかんけど

buttonでレンダリングされているる。遷移はうまいこと内部でやってるようだ

なお、ドキュメントにPOST/PUT/PATCH/DELETEの作成にaタグを使ってはいけないと書かれている。これはまあそうだよね。

メソッド指定

上記の注釈でそうはいったものの、Inertia リンクの HTTP リクエストメソッドを method プロパティで指定することもできて、これを使用して POST/PUT/PATCH/DELETE のリクエストを送信できる。ここではPUTを送信してみる。注釈の通りas="button"にしときますよ。

          <Link
            href={route('demo.put')}
            method="put"
            as="button"
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            PUT method
          </Link>

routes/web.phpに適当なエンドポイントを作って

Route::put('/demo/put', function () {
    dd("called");
})->name('demo.put');

"called" と表示されて止める。これを確認しておく。

パラメーターの追加

さらにリクエストに対し、パラメーターも与える事ができる

          <Link
            href={route('demo.put')}
            method="put"
            as="button"
            data={{ foo: "bar" }}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            PUT method
          </Link>

とすれば

use Illuminate\Http\Request;
Route::put('/demo/put', function (Request $request) {
    dd($request->all());
})->name('demo.put');

、となる。

Headerの追加

さらに


          <Link
            href={route('demo.put')}
            method="put"
            as="button"
            headers={{ foo: "bar" }}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            PUT method
          </Link>

としたらheaderを付けることができ、

Route::put('/demo/put', function (Request $request) {
    dd($request->headers->all());
})->name('demo.put');

とすると

となる。

Partialリロード

only プロパティを使用すると、ページの一部のプロパティ(データ)のみをサーバーから取得するよう指定できるってわけで、これがパーシャルリロードなんだけど

import { Link } from '@inertiajs/react';

<Link href="/users?active=true" only={['users']}>Show active</Link>

まあこれはちょっと概念がむずいのでいずれ別に解説ページを作るかもしれないし、作らないかもしれない。

Aタグとの比較

いよいよここからAタグと比較してみよう。まず

import { Link, Head} from '@inertiajs/react';
import { router } from '@inertiajs/react';

export default function Demo() {

  return (
    <>
      <Head title="Demo" />
      <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
        <h1 className="text-4xl font-extrabold text-gray-800 mb-6">Link Demo</h1>
        <div className="flex space-x-4 mb-6">

          <Link
            href={route('demo.page')}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            Demo Page (Inertia.js Link)
          </Link>

          <a
            href={route('demo.page')}
            className="px-6 py-3 bg-gray-800 text-white rounded-lg shadow-md hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 transition-all"
          >
            Demo Page (HTML Anchor)
          </a>

        </div>
      </div>
    </>
  );
}

このようにほとんど同じ機能のものを2つ作る。すると

こうなるんだけど、開発者タブでネットワークをみてみてInertia.jsのLinkコンポーネントをクリックすると

これだけの読み込みしか行っていない。

これに対してAタグをクリックすると

このように、大量の読み込みが発生しているのがわかる。これが違い。

Linkコンポーネントの欠点

ほとんどのニーズに対応できるのだが、これはバイナリーダウンロードのリンクを発生させられない。これに関しては <A>タグを使うのが正解 なのだけど、たとえばこういう仕組みを考えてみたとき

import { Link, Head, useForm } from '@inertiajs/react';
import InputLabel from '@/Components/InputLabel';
import TextInput from '@/Components/TextInput';

export default function Demo() {
  const { data, setData, post } = useForm({
    password: '',
  });

  const submit = (e) => {
    e.preventDefault();
    post(route('download.readme'));
  };

  return (
    <>
      <Head title="Demo" />
      <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
        <h1 className="text-4xl font-extrabold text-gray-800 mb-6">Link Demo</h1>
        <div className="flex space-x-4 mb-6">

          <Link
            href={route('demo.page')}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            Demo Page (Inertia.js Link)
          </Link>

          <a
            href={route('demo.page')}
            className="px-6 py-3 bg-gray-800 text-white rounded-lg shadow-md hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 transition-all"
          >
            Demo Page (HTML Anchor)
          </a>
        </div>
        <div>
          <form onSubmit={submit} className="flex flex-col space-y-4">
            <InputLabel htmlFor="password" value="Password"/>
            <TextInput
              id="password"
              type="password"
              name="password"
              className="mt-1 block w-full"
              onChange={(e) => setData('password', e.target.value)}
            />
            <button
              type="submit"
              className="px-6 py-3 bg-teal-600 text-white rounded-lg shadow-md hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition-all"
            >
              Download readme.zip (Inertia.js Form POST)
            </button>
          </form>
        </div>
      </div>
    </>
  );
}

でたとえばパスワードがmogeのときだけダウンロードできるとかそういうのとかよくあるじゃないすか

// File download route
Route::match(['get', 'post'], '/download/readme', function (Request $request) {
    $password = request()->input('password');

    if ($password !== 'moge') {
        abort(403, 'Unauthorized');
    }

    $path = storage_path('app/readme.zip');

    if (!file_exists($path)) {
        abort(404, 'File not found');
    }

    return response()->download($path, 'readme.zip');
})->name('download.readme');

なお、これは動作しない

https://github.com/inertiajs/inertia-laravel/issues/85

で議論されているようにformにしないといけなかったりする

import { Link, Head, useForm } from '@inertiajs/react';
import { router } from '@inertiajs/react';
import InputLabel from '@/Components/InputLabel';
import TextInput from '@/Components/TextInput';
import SecondaryButton from '@/Components/SecondaryButton';

export default function Demo({ csrfToken }) {
  const { data, setData, post, processing } = useForm({
    password: '',
  });

  const submit = (e) => {
    e.preventDefault();
    post(route('download.readme'));
  };

  return (
    <>
      <Head title="Demo" />
      <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
        <h1 className="text-4xl font-extrabold text-gray-800 mb-6">Secure Download Demo</h1>

        <div className="flex space-x-4 mb-6">
          <Link
            href={route('demo.page')}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all"
          >
            Demo Page (Inertia.js Link)
          </Link>

          <a
            href={route('demo.page')}
            className="px-6 py-3 bg-gray-800 text-white rounded-lg shadow-md hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 transition-all"
          >
            Demo Page (HTML Anchor)
          </a>
        </div>

        <div className="w-full max-w-md space-y-8">
          {/* Classic Form with CSRF Token */}
          <form action={route('download.readme')} method="post" className="bg-white p-6 shadow rounded-lg">
            <input type="hidden" name="_token" value={csrfToken} />
            <div className="mb-4">
              <InputLabel htmlFor="password" value="Password" />
              <TextInput
                id="password"
                type="password"
                name="password"
                className="mt-1 block w-full"
              />
            </div>
            <SecondaryButton type="submit" className="w-full">
              Download (Classic Form)
            </SecondaryButton>
          </form>

          {/* Inertia.js Form */}
          <form onSubmit={submit} className="bg-white p-6 shadow rounded-lg">
            <div className="mb-4">
              <InputLabel htmlFor="password" value="Password" />
              <TextInput
                id="password"
                type="password"
                value={data.password}
                onChange={(e) => setData('password', e.target.value)}
                className="mt-1 block w-full"
              />
            </div>
            <button
              type="submit"
              disabled={processing}
              className="px-6 py-3 w-full bg-teal-600 text-white rounded-lg shadow-md hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition-all disabled:opacity-50"
            >
              {processing ? 'Processing...' : 'Inertia Form (NOT WORK)'}
            </button>
          </form>
        </div>
      </div>
    </>
  );
}
// File download route
Route::match(['get', 'post'], '/download/readme', function (Request $request) {
    $password = request()->input('password');

    if ($password !== 'moge') {
        abort(403, 'Unauthorized');
    }

    $path = storage_path('app/readme.zip');

    if (!file_exists($path)) {
        abort(404, 'File not found');
    }

    return response()->download($path, 'readme.zip');
})->name('download.readme');

まあ、こんな感じでうまいこと対応する事。

このようになり、Classic Formは動作するけどinertiaのformは動作しない。これはまあそういうもんだと思って諦めないといけないので、設計するときにはこれを最初から理解して行う事が必要になるわけだ。

Discussion