フロントエンドの構築

前回終了時のソースコード

Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。

$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel

この章開始時点のソースコードは chapt4 ブランチにあります。

$ git switch chapt4

ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。

$ git switch -c my4 chapt4

Laravelのコードはsrcディレクトリからの相対パスになっています。

LaravelでのCSSの書き方

publicディレクトリはブラウザーからアクセスできるので、ここにstyle.cssをおけば、HTMLのheadタグ内で <link rel="stylesheet" href="/style.css" /> として、スタイルを読み込むことができます。

しかしこの方法では、以下の問題が生じる場合があります。

  1. ベースURLが変更になったとき(特にサブディレクトリでアプリケーションを動かす場合)、hrefを書き換える必要が生じる。
  2. CSSがブラウザーにキャッシュされて、変更が反映されないことがある。

Laravelはviteと連携することで、こうした問題を避けつつ、さらに便利な機能を使えるようにしています。

resources/css/app.css を編集する

LaravelではCSSは resources/css ディレクトリに置きます。 基本的に app.css に書いて、さらにファイルを分割したいときは app.css から @import で読み込むようにします。

resources/css/app.css
@import 'tailwindcss';
 
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
 
@theme {
    --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
        'Segoe UI Symbol', 'Noto Color Emoji';
}
 
/* 以下を追加してCSSの効果を確かめる */
header {
    background-color: #3B82F6;
    color: white;
}

cssを読み込むように、HTMLのheadタグを修正します。

resources/views/layouts/html.blade.php
<!DOCTYPE html>
<html lang="{{ config('app.locale') }}">
  <head>
    <meta charset="utf-8">
    <title>{{ config('app.name') }}</title>
    {{-- viteによって、コンパイルしたファイルを示すlinkとかscriptタグに置き換える --}}
    @vite(['resources/css/app.css', 'resources/js/app.js'])  
  </head>
  <body>
    @yield('body')
  </body>
</html>

この@viteはBladeのディレクティブです。

app.cssをコンパイルします。

$ npm run build

ブラウザーで再読み込みをすると反映されます。

後述するtailwindcssがすでにはいっていて、上記の修正によりそれが有効になる。その結果header以外のところも見た目が変わる。

ブラウザーでソースコードをみると、@viteの部分が以下のように書き換えられているのがわかります。

<link rel="preload" as="style" href="http://xxx/build/assets/app-PDEzYLFL.css" />

npm run buildでつくられるファイルは毎回名前が変わるので、それに合わせたファイル名をhrefとして書き出しているのがわかります。

npm run buildで行われることは package.json にかかれている。vite buildが実行され、 public/build/manifest.json がつくられ、@viteのところでこの中にあるファイル名に置き換えられる。

vite.config.jsにapp.cssとapp.jsを処理するように書かれている。 app.cssで@importするのではなく、vite.config.jsにcssとかjavascriptとかを追加してもよい。

HMR (Hot Module Replacement)を使う

CSSを書き換えるたびに npm run build とするのはとても面倒です。 viteのHMRという機能を使うと、ブラウザーの再読み込みすら必要なく、CSSやビューが反映されるようになります。 そのために、画面表示に関わる部分は resourcesディレクトリにまとめられています。

まずviteを起動します。 これは常駐するので、新しいターミナルを開いておくとよいでしょう。

$ npm run dev

このとき@viteの部分は以下のように書き換えられます。

<script type="module" src="http://127.0.0.1:5173/@@vite/client"></script>
<link rel="stylesheet" href="http://127.0.0.1:5173/resources/css/app.css" />
<script type="module" src="http://127.0.0.1:5173/resources/js/app.js"></script>

これはviteが5173ポートで待ち受けていることを示しています。 そして、ソースコードが書き換えられると、ブラウザーと通信して、画面の書き換えを行います。

app.cssを修正すると、ブラウザーを再読み込みすることも、npm run buildすることもなく、画面が変更されるのがわかります。

tailwindcssを使う

tailwindcssはCSSフレームワークです。

通常、CSSはHTMLのタグ(要素)やそれに設定されたクラスに対して、プロパティと値を設定して使います。 しかし、ここまでつくってきたように、HTMLがレイアウトとか、コンポーネントととかで細切れになってくると、HTMLのどの要素のスタイルがどのCSSに入っているのかがわからなくなってきます。

tailwindcssではHTMLの要素に直接スタイルと対応するクラスをつけて、CSSを扱うようになっています。

クラスの一覧は Tailwind CSS 日本語チートシート を参照してください。

タブを横並びにして、背景色を設定しました。

resources/views/layouts/app.blade.php
@extends('layouts.html')
 
@section('body')
  <header class='bg-blue-500 text-white py-2 px-2 flex justify-between items-start'>  {{-- 背景色、文字色、余白(padding) --}}
    <h1 class='text-xl leading-none'>{{ config('app.name') }}</h1>
  </header>
  <main id='modules' class='bg-yellow-100 px-2 min-h-screen'>
    <nav class='bg-yellow-100 py-2 px-2'>
      <ul id='module-tabs' class='flex'>  {{-- タブが横並びになるようにflexを追加 --}}
        <li class='module-tab'><a id='oligo-tab' href='/oligo'>オリゴDNA</a></li>
        {{-- モジュールが複数並んだときの様子がわかるようにダミーを追加 --}}
        <li class='module-tab'><a id='dummy1-tab' href='/dummy1'>ダミー1</a></li>
        <li class='module-tab'><a id='dummy2-tab' href='/dummy2'>ダミー2</a></li>
        <li class='module-tab'><a id='dummy3-tab' href='/dummy3'>ダミー3</a></li>
      </ul>
    </nav>
    @yield('module')
  </main>
@endsection

クラスにtailwindcssを設定する

tailwindcssのクラスはCSSのスタイルの代わりに書くことができます。 CSSのプロパティ: 値;の代わりに、@apply に続いて、tailwindcssのクラスを並べます。

resources/css/app.css
@import 'tailwindcss';
 
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
 
@theme {
    --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
        'Segoe UI Symbol', 'Noto Color Emoji';
}
 
.module-tab {
    @apply
        border border-black /* 枠線の設定 */
        first:rounded-l  /* first: によって、最初の要素に限定する。左側(-l)のみ丸める(rounded)。 */
        last:rounded-r  /* 同様に最後の枠線の右側(-r)を丸める。 */
        font-sans font-bold 
        px-6 bg-white  /* 文字の左右の余白(padding x)を6px、背景色を白に設定 */
        hover:bg-blue-500 hover:text-white /* ホバー時の背景色を青、文字色を白に設定 */
    ;
}
 
.feature-tabs {
    @apply flex;
}
.feature-tab {
    @apply border-t border-x border-black rounded-t-md mx-1 px-6 font-sans font-bold bg-gray-400 hover:bg-white hover:text-black;
}
 
.features {
    @apply bg-white p-2 min-h-screen;
}

Javascriptでタブ切り替え

これまでつくったページではみかけはタブになっているが、それぞれをクリックするとページの更新がされています。 ページが更新されると入力途中のものが消えたり、検索結果が保存されないなど、使い勝手が悪くなります。

そこで、タブの切り替えを、パネルの表示・非表示で行うように変更します。 そのために Alipine.js を使用します。 Alpine.jsはJavascriptの変数(実際にはProxyオブジェクト)の値が変更されたとき、HTMLを変更するということを容易にします。 ここでは、パネルの表示・非表示をactiveFeatureという変数の状態と連動させるようにします。

Alpine.jsのサイト

Alpine.jsとhtmxのインストール

この後使うhtmxといっしょにインストールします。

$ npm install alpinejs htmx.org

app.jsでこれらをインポートして、使えるようにする。

resources/js/app.js
// htmx の読み込みとグローバル登録
import htmx from 'htmx.org';
window.htmx = htmx;
 
// Alpine.js の読み込みと初期化
import Alpine from 'alpinejs';
window.Alpine = Alpine;
 
Alpine.start();

Alpine.js を使って表示・非表示を切り替える

Alpine.js ではHTMLの要素にx-dataという属性を設定し、その要素の内側で使用する変数などを定義します。

<div x-data="{activeFeature: ''}" >

このようにすることで、このdiv要素の子要素でactiveFeatureという変数を参照することができるようになります。

moduleレベルのdiv要素にx-dataを設定します。

resources/views/components/module.blade.php
<div {{ $attributes->merge(['class' => 'module-panel']) }} role="tabpanel"
  x-data="{ activeFeature: '' }"  {{-- Alpine.jsの変数としてactiveFeatureを定義 --}}
>
  {{ $slot }}
</div>

aタグがクリックされたときに、activeFeatureに値を設定するようにします。 @click="" はAlpine.jsのイベントの書き方で、クリックされたときにactiveFeatureに値を設定します。 .preventをつけることで、デフォルトの動作(aタグの場合はhrefを開く)をキャンセルします。 Alpine.js x-on

:class="{}"はAlpine.jsによる属性値を変更する書き方です。Javascriptオブジェクトの値が真のとき、キー(この場合bg-whiteとかbg-gray-400とか)がクラスに設定されます。

activeFeatureの値が変更になったのかをわかりやすくするために、liを追加して、それにx-text属性を追加しました。 x-textは設定されたJavascriptの式を評価して、要素のテキストに置き換えます。

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='oligo'>
    <!-- モジュールのメニュー -->
    <nav>
      <ul class='feature-tabs' role='tablist'>
        @foreach([
          'index' => '一覧',
          'create' => '追加',
        ] as $page => $label)
          <li class='feature-tab'
            {{-- activeFeatureの値に応じて背景色を切り替える --}}
            :class="{
              'bg-white': activeFeature === 'oligo-{{ $page }}', 
              'bg-gray-400 hover:bg-gray-200': activeFeature !== 'oligo-{{ $page }}'
            }"
          >
            <a href='{{ route("web.oligo.{$page}") }}' id='oligo-{{ $page }}-tab' role='tab'
              @click.prevent="activeFeature = 'oligo-{{ $page }}'" {{-- activeFeatureに値を設定 --}}
            >
              {{ $label }}
            </a>
          </li>
        @endforeach
        <li class='feature-tab bg-gray-400' x-text="activeFeature"> {{-- activeFeatureの値を表示 --}}
        </li>
      </ul>
    </nav>
    
    <!-- featureが並ぶところ -->
    <div class='features'>
      @yield('feature')
    </div>
  </x-module>
@endsection

featureレベルのdiv要素にx-showを設定します。 x-showは設定されたJavascriptの式がtrueを返すとき、その要素が表示されるようにし、falseのときに非表示になります。 この $id はcomponents/feature.blade.phpを <x-code id="xxx"として呼び出したときに設定されます。

resources/views/components/feature.blade.php
@props(['id'])
<div id="{{ $id }}" role="tabpanel"
  {{ $attributes->merge(['class' => 'feature-panel']) }}
  x-show="activeFeature === '{{ $id }}'"  {{-- activeFeatureがこのfeatureのidと一致する場合のみ表示 --}}
>
  {{ $slot }}
</div>

app.cssも少し修正しておきます。

resources/css/app.css
@import 'tailwindcss';
 
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
 
@theme {
    --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
        'Segoe UI Symbol', 'Noto Color Emoji';
}
 
.module-tab {
    @apply
        border border-black /* 枠線の設定 */
        first:rounded-l  /* first: によって、最初の要素に限定する。左側(-l)のみ丸める(rounded)。 */
        last:rounded-r  /* 同様に最後の枠線の右側(-r)を丸める。 */
        font-sans font-bold 
        px-6 bg-white  /* 文字の左右の余白(padding x)を6px、背景色を白に設定 */
        hover:bg-blue-500 hover:text-white /* ホバー時の背景色を青、文字色を白に設定 */
    ;
}
 
.feature-tabs {
    @apply flex;
}
.feature-tab {
    @apply border-t border-x border-black rounded-t-md mx-1 px-6 font-sans font-bold;
}
 
.features {
    @apply bg-white p-2 min-h-screen;
}

これでタブのクリックにより、featureのパネル部分が表示されるようになります。

htmxを使ったHTMLの入れ替え

現在のところ、featureのパネルは最初のHTMLに含まれていないので、タブをクリックしても中身が表示されません。 それぞれのタブをクリックしたときに、featureのHTMLを取得し、全体のHTMLに追加するようにします。 そのために htmx を使用します。

htmxのサイト

まず、タブがクリックされたときに、htmxを動作させる関数 selectTab を定義します。

resources/views/components/module.blade.php
<div role="tabpanel" {{ $attributes->merge(['class' => 'module-panel']) }}
  x-data="{
    activeFeature: '',
    /* activeFeatureの値を変更し、そのHTML要素がないときに取得する */
    selectTab(event){
      /* aタグのhref属性からfeatureのidを取得する */
      const a = event.currentTarget;
      const url = a.href;
      const page = a.id.slice(0, '-tab'.length * -1);
 
      /* activeFeatureにidを設定 */
      this.activeFeature = page;
 
      /* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
      history.pushState({}, '', url);
 
      /* idで要素を取得 */
      const panel = document.getElementById(page);
 
      /* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
      if(null == panel) {
        htmx.ajax('GET', url, {
          target:'#{{ $id }} div.features', /* 取得したHTMLの置き換え場所 */
          swap: 'beforeend'  /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
        });
      }
    }
  }"
>
  {{ $slot }}
</div>

次に、aタグの動作を変更します。 activeFeatureに直接値を設定するのではなく、selectTabを呼び出すようにします。

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='oligo'>
    <!-- モジュールのメニュー -->
    <nav>
      <ul class='feature-tabs' role='tablist'>
        @foreach([
          'index' => '一覧',
          'create' => '追加',
        ] as $page => $label)
          <li class='feature-tab'
            :class="{
              'bg-white': activeFeature === 'oligo-{{ $page }}',
              'bg-gray-400 hover:bg-gray-200': activeFeature !== 'oligo-{{ $page }}'
            }"
          >
            <a href='{{ route("web.oligo.{$page}") }}' id='oligo-{{ $page }}-tab' role='tab'
 
              {{-- クリックされたとき selectTab関数を呼び出す --}}
              @click.prevent="selectTab(event)"
 
            >
              {{ $label }}
            </a>
          </li>
        @endforeach
        <li class='feature-tab bg-gray-400' x-text="activeFeature">
        </li>
      </ul>
    </nav>
    
    <!-- featureが並ぶところ -->
    <div class='features'>
      @yield('feature')
    </div>
  </x-module>
@endsection

ここまでのところで、最初に用意していないfeatureでもHTMLを取得するようになりました。 しかし、サーバーが返すのがDOCTYPEから始まる完全なHTMLなので、そのまま既存のHTMLに差し込むと構造が壊れます。 該当するfeatureの部分 <div id="oligo-index" class="feature-panel" > を返すようにコントローラーを修正します。

Bladeにはビューの一部分を取り出す @fragment ディレクティブがあります。 htmxからのリクエストのとき、@fragmentを有効にするようにします。

htmxはリクエストヘッダーにHX-Requestを設定するようになっています。 なので、これが有るときに@fragmentが有効にするようにコントローラーを修正します。

app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Http\Requests\OligoRequest;
 
class OligoController
{
    // CSVファイルからデータを読み込む
    public function index(){
        $csv = storage_path('app/private/oligo.csv');
        $fp = fopen($csv, 'r');
        $items = [];
        while($row = fgetcsv($fp)){
            $items[] = array_combine(['name', 'sequence', 'created_at', 'owner'], $row);
        }
        fclose($fp);
	return view('oligo.index', compact('items'))
 
            /* fragmentIf(): 特定の条件のときに、ビューの一部を返す
	     * 1番目の引数が条件、2番目の引数が返す内容
	     */
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
	    ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligoRequest $request){
        $validated = $request->validated();
        $data = [];
        foreach(['name', 'sequence', 'created_at', 'owner'] as $key){
            $data[] = sprintf('"%s"', $validated[$key] ?? '');
        }
        $csv = storage_path('app/private/oligo.csv');
        file_put_contents($csv, implode(',', $data).PHP_EOL, FILE_APPEND);
 
        return redirect()->route('web.oligo.index')->with('status', '保存しました');
    }
}

feature.blade.phpに@fragmentディレクティブを加えて、部分HTMLとして返す範囲を指定します。

resources/views/components/feature.blade.php
@props(['id'])
@fragment("feature-{$id}")  {{-- featureの部分HTMLの範囲を示す --}}
  <div id="{{ $id }}" role="tabpanel"
    {{ $attributes->merge(['class' => 'feature-panel']) }}
    x-show="activeFeature === '{{ $id }}'"  {{-- activeFeatureがこのfeatureのidと一致する場合のみ表示 --}}
  >
    {{ $slot }}
  </div>
@endfragment

初期画面でactiveFeatureを設定する

activeFeatureの初期値は components/module.blade.php 中で設定しています。 このファイルは OligoControlleroligo/index.blade.phpoligo/module.blade.phpcomponents/module.blade.php の順で呼ばれるので、最初にどのfeatureが呼ばれてた結果なのか知ることができません。 そのため、適切な初期値を設定することができません。

そこで components/module.blade.php にURLを渡す方法を考えないといけません。

  1. OligoControlleroligo/index.blade.php から feature_id という変数を順に渡していく。
  2. View全体で使える変数をつくる。

1の方法では、featureを返す可能性があるすべてのコントローラーで、viewに変数を渡していく必要があります。 この場合、コントローラーを増やしたり、機能を拡張していくときにミスが起きる可能性が高くなります。 なので2の方法を考えます。

Viewオブジェクトにfeature_idを設定するコードを app/Providers/AppServiceProvider.php に追加します。

request()でリクエストオブジェクトを取得し、Routeを取得して、現在のルート名を取得します。 これを.で区切って、先頭を削除し、-でつなぎなおして feature_idとします。

app/Providers/AppServiceProvider.php
<?php
 
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;  // Viewの設定ができるようにする
 
class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }
 
    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        View::composer('*', function ($view) {
            $route = request()->route();
            if(! is_null($route)){
                $route_name = $route->getName();
                if(! is_null($route_name)){
                    $feature_id = join('-', array_slice(explode('.', $route_name), 1));
                    View::share('feature_id', $feature_id);
                }
            }
        });
    }
}

つくったfeature_idをactiveFeatureの初期値として設定するようにしました。

resources/views/components/module.blade.php
<div role="tabpanel" {{ $attributes->merge(['class' => 'module-panel']) }}
  x-data="{
    activeFeature: '{{ $feature_id }}',    {{-- AppServiceProviderで設定した値を使う --}}
 
    /* activeFeatureの値を変更し、そのHTML要素がないときに取得する */
    selectTab(event){
      /* aタグのhref属性からfeatureのidを取得する */
      const a = event.currentTarget;
      const url = a.href;
      const page = a.id.slice(0, '-tab'.length * -1);
 
      /* activeFeatureにidを設定 */
      this.activeFeature = page;
 
      /* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
      history.pushState({}, '', url);
 
      /* idで要素を取得 */
      const panel = document.getElementById(page);
 
      /* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
      if(null == panel) {
        htmx.ajax('GET', url, {
          target:'#{{ $id }} div.features', /* 取得したHTMLの置き換え場所 */
          swap: 'beforeend'  /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
        });
      }
    }
  }"
>
  {{ $slot }}
</div>