フロントエンドの構築
前回終了時のソースコード
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" /> として、スタイルを読み込むことができます。
しかしこの方法では、以下の問題が生じる場合があります。
- ベースURLが変更になったとき(特にサブディレクトリでアプリケーションを動かす場合)、hrefを書き換える必要が生じる。
- 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 中で設定しています。
このファイルは OligoController – oligo/index.blade.php – oligo/module.blade.php – components/module.blade.php の順で呼ばれるので、最初にどのfeatureが呼ばれてた結果なのか知ることができません。
そのため、適切な初期値を設定することができません。
そこで components/module.blade.php にURLを渡す方法を考えないといけません。
OligoControllerかoligo/index.blade.phpからfeature_idという変数を順に渡していく。- 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>