はじめに
今回、日頃のプログラミング学習のアウトプットとしてLaravelとVue.jsでWebアプリを制作しました。
どういった方法で制作したのか、どういう流れで進めていったのかを個人的な振り返りもかねて記事にまとめてみたのでこれからWebアプリを作ってみようかなと考えている方、LaravelとVueで何か作ってみたい方の参考になれば嬉しいです。
目的
前回はPHPを用いてフルスクラッチでのWebアプリを開発しました。
WebサービスをXserverで公開する方法 - Qiita
Webサービスの基本的な機能の開発のアウトプットができたので、今回は開発現場では主流?のフレームワークを用いての開発経験をつけたくLaravelとVue.jsでWebサービスを作っとみようと思った次第です。
今回のポートフォリオの制作で目指したこと。
- MVCモデルの理解
- LaravelとVue.jsでのWebアプリの完成
- フレームワーク独自の機能について知る。利用する。
- CSS設計
- スマホでの利用に重きを置いたのでレスポンシブ対応を強化
開発環境
使用言語・データベース
- PHP 7.2.15
- JavaScript
- HTML
- CSS
- MySQL
フレームワーク
- Laravel
- Vue.js
使用ツール・ライブラリ
- jQuery
- Bootstrap
- SASS
- axios
- GitHub
その他は以下参照↓
{
"private": true,
"scripts": {
"dev": "NODE_ENV=development webpack --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "NODE_ENV=production webpack --config=node_modules/laravel-mix/setup/webpack.config.js",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "NODE_ENV=development webpack --config=node_modules/laravel-mix/setup/webpack.config.js --watch",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"axios": "^0.18",
"bootstrap": "^4.0.0",
"browser-sync-webpack-plugin": "^2.2.2",
"cross-env": "^5.1",
"jquery": "^3.2",
"laravel-mix": "^4.0.15",
"lodash": "^4.17.5",
"popper.js": "^1.12",
"resolve-url-loader": "^2.3.1",
"sass": "^1.15.2",
"sass-loader": "^7.1.0",
"vue": "^2.6.10",
"vue-template-compiler": "^2.6.9"
}
}
ソースコード
制作物について
アプリ名:『やいまクイズ』
やいまクイズは沖縄のさらに南にある八重山(やえやま)諸島についてクイズ形式で学べるクイズアプリです。
私自身が八重山諸島の出身という事もあり、地元に関する何かを作りたいと考え、利用者にクイズ形式で楽しみながら八重山諸島について知る機会になればと思い今回のようなクイズアプリを制作しました。
主な機能
■ユーザー関連
- ユーザー登録
- ログイン・ログアウト
- パスワードリマインダー
- パスワード、メールアドレス変更
- 退会
- マイページ
■クイズ関連
- クイズページ
- クイズ結果をツイート
- クイズの作成・編集・削除
- クイズ一覧ページ
デモ 🎥
クイズページ
クイズ作成
メールアドレス変更
制作手順
1. 機能洗い出し
言わずもがな、まず初めに何を作るのか
、どんな機能を入れるのか
を考えていきます。
↓MVCモデルを実際に書いてみると構造のイメージが掴みやすくなるのでおすすめ✨
2. テーブル設計
ユーザー登録や、今回のようなクイズ作成時に必要なデータ情報は何かを洗い出しテーブル設計をしていきます。
3. WF(ワイヤーフレーム)作成
画面をすぐさまコーディングするのではなく、紙や、WF作成ツール等でどういうレイアウトにするのかを考えてからコーディングをするようにしましょう。
デザインが曖昧なままコーディングに取りかかるとあとあと修正が増えたりして返って非効率になってしまします。
(と、偉そうにいってますが今回のWFめっちゃテキトー書いちゃってますww)
[ひとりごと]
最近知ったんだけど、Adobe XDというツールがWF作成に便利っぽい!
しかも、無料で使えるとは!!
Adobeのツール(フォトショとかイラレ)って有料のイメージだったけどこのXDは無料でも使えるのね〜。
今度からはXD利用してWF作成してみよう。💪
3. CSS設計について(反省...💦)
コーディング当初はBootstrapを用いてコーディングをしていました。
主にレスポンシブに対応するためにグリットシステムを導入。また、ボタンやフォーム、ドロップダウンメニュー等です。
後半からひょんな事にCSS設計についての学習がてらFLOCSSを導入しようと試みてものすごくリファクタリングに時間がかかってしまいました。(結局中途半端になってしまったけど、、)
コーディングを行う前にWF作成をする事もそうですが、CSS設計についてもあらかじめルールを決めておくのが吉ですね。
4. Laravelの環境構築と画面作成
初めにLaravelが使用できるように環境構築します。
画面のコーディングに関しては、
- クイズ画面は
Vue.js
- その他の画面(ユーザー登録、クイズ作成、一覧、etc..)は
Laravelのblade
で作成しました。一部画面のソースコードを紹介↓
クイズTOP画面
@include('layouts.head')
<title>やいまクイズ</title>
</head>
<body>
<quiz-header id="quiz-header"></quiz-header>
<quiz-contents id="quiz-contents"></quiz-contents>
<footer class="l-footer">
Copyright© <a href="https://yonaguni-media.com" target="_blank">どなんメディア</a>.
</footer>
<script src=" {{ mix('js/app.js') }} "></script>
</body>
</html>
↓クイズ画面のコンポーネントの構成としては、
├──QuizHeader.vue
| ├──QuizMenu.vue
├──QuizContents.vue
├──QuizResult.vue
↓クイズの問題・選択肢部分のコンポーネント
<template>
<main id="quiz" class="l-section__wide">
<article id="question" class="p-quiz">
<section>
<div v-if="hidden">
<h1 class="c-bar c-bar--large c-bar--pink">問題 {{quizNum}}.{{quizzes[quizNum - 1].title}}</h1>
<div v-if="showQuiz">
<div v-if="quizzes[quizNum - 1].image_name">
<div class="p-quiz__img">
<img :src="quizzes[quizNum - 1].image_name" alt="クイズ画像">
</div>
</div>
<div class="p-quiz__choice">
<ul v-for="choice in aChoice">
<li class="c-bar c-bar--gray" @click="showAnswer(choice)">{{ choice }}</li>
</ul>
</div>
</div>
</div>
<div class="p-quiz__explain" v-if="showExplain">
<h2 class="is-correct" v-if="judgment">
<i class="far fa-circle mr-4"></i>正解!
</h2>
<h2 class="is-uncorrect" v-else>
<i class="fas fa-times mr-4"></i>不正解
</h2>
<p>
<strong>解説:</strong>
{{quizzes[quizNum-1].explain_sentence}}
</p>
<button @click="next()" type="button" class="btn btn-default">次へ</button>
</div>
</section>
<section class="p-quiz__empty-msg" v-if="alertMsg">
<p>
<i class="far mr-2 fa-lg fa-tired"></i>クイズはまだ登録されていません。
<i class="far fa-lg fa-tired"></i>
</p>
<a href="/quiz">クイズTOPへ</a>
</section>
</article>
<quiz-result ref="result" :totalCorrectNum="totalCorrectNum"></quiz-result>
</main>
</template>
<script>
import QuizResult from "./QuizResult";
export default {
name: "QuizContents",
components: {
QuizResult
},
data: function() {
return {
quizNum: 1,
totalQuizNum: 0,
totalCorrectNum: 0,
quizzes: [
{
title: "",
correct: "",
uncorrect1: "",
uncorrect2: "",
image_name: "",
explain_sentence: ""
}
],
aChoice: [],
showQuiz: true,
showExplain: false,
existImage: false,
hidden: false,
alertMsg: false,
judgment: "",
axiosUrl: ""
};
},
created() {
//DOM構築前にクイズデータをaxiosで取得(そうしないとエラーでる↓)
//"TypeError: Cannot read property 'title' of undefined"
this.getQuizzes();
},
methods: {
getQuizzes: function() {
let quizUrl = location.pathname;
let catId = quizUrl.match(/\d/g);
let catNum;
if (catId) {
catNum = catId.join("");
}
if (quizUrl == "/quiz/" + catNum) {
this.axiosUrl = "ajax/menu" + catNum;
} else if (quizUrl == "/quiz/region/" + catNum) {
this.axiosUrl = "ajax/region" + catNum;
} else {
this.axiosUrl = "ajax/menu";
}
axios
.get(this.axiosUrl)
.then(res => {
this.quizzes = res.data;
this.totalQuizNum = this.quizzes.length;
//クイズがある時はDOMを表示しクイズがない場合は無いですメッセージを表示
if (this.totalQuizNum) {
this.hidden = true;
} else {
this.alertMsg = true;
}
this.getChoice(this.quizNum - 1);
})
.catch(error => {
console.log(error);
});
},
shuffleAry: function(array) {
const ary = array.slice();
for (let i = ary.length - 1; 0 < i; i--) {
let r = Math.floor(Math.random() * (i + 1));
[ary[i], ary[r]] = [ary[r], ary[i]];
}
return ary;
},
getChoice: function(index) {
//前回の選択肢を削除してから新しく選択肢を追加する
this.aChoice = [];
this.aChoice.push(
this.quizzes[index].correct,
this.quizzes[index].uncorrect1,
this.quizzes[index].uncorrect2
);
this.aChoice = this.shuffleAry(this.aChoice);
},
showAnswer: function(choice) {
this.showQuiz = !this.showQuiz; //false
this.showExplain = !this.showExplain; //true
let answer = this.quizzes[this.quizNum - 1].correct;
if (choice === answer) {
this.judgment = true;
this.totalCorrectNum++;
this.$refs.totalCorrectNum;
} else {
this.judgment = false;
}
},
next: function() {
if (this.quizNum < this.totalQuizNum) {
this.showQuiz = true;
this.showExplain = false;
this.quizNum++;
this.nextCounter++;
this.getChoice(this.quizNum - 1);
} else {
this.$refs.result.showResult();
}
}
}
};
</script>
<style scoped>
</style>
クイズ登録画面
@extends('layouts.formWithHeader')
@section('title','クイズ作成')
@section('content')
<form method="post" action="{{ url('mypage') }}" enctype="multipart/form-data" class="form">
@csrf
@method('POST')
<div class="form-heading">
<h1>クイズの作成</h1>
<p>八重山についてのクイズを作成してみよう。</p>
</div>
<div class="form-group">
<div class="row">
<div class="col-6">
<label>カテゴリ<span class="attention">必須</span></label>
<select class="form-control" id="category" name="category_id">
@foreach ($categories as $category)
<option value="{{ $category->id }}" {{ $category->id == old('category_id') ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<div class="col-6">
<label>地域<span class="attention">必須</span></label>
<select class="form-control" id="region" name="region_id">
@foreach($region as $island)
<option value="{{ $island->id }}" {{ $island->id == old('region_id') ? 'selected' : '' }}>
{{ $island->name }}
</option>
@endforeach
</select>
</div>
</div>
</div>
<div class="form-group">
<label>問題文を入力<span class="attention">必須</span></label>
<textarea cols="40" rows="3" class="form-control{{ $errors->has('title') ? ' is-invalid' : '' }}" name="title" value="{{ old('title') }}" placeholder="例)日本最西端の島はどこでしょう?">
{{ old('title') }}
</textarea>
@if($errors->has('title'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('title') }}
</span>
@endif
</div>
<div class="form-group">
<label>選択肢を入力<span class="attention">必須</span></label>
<span>注意</span>
<p>・同じ内容の選択肢は入力しないでください。<br>
・クイズの回答は一番上に入力してください。<br>
・カテゴリで選択したことに関するクイズを投稿すること。</p>
<!-- correct -->
<input type="text" class="form-control{{ $errors->has('correct') ? ' is-invalid' : '' }}" name="correct" value="{{ old('correct') }}" placeholder="答え)与那国島">
@if($errors->has('correct'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('correct') }}
</span>
@endif
<!-- uncorrect1 -->
<input type="text" class="form-control{{ $errors->has('uncorrect1') ? ' is-invalid' : '' }} mt-2" name="uncorrect1" value="{{ old('uncorrect1') }}" placeholder="選択肢1)択捉島">
@if($errors->has('uncorrect1'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('uncorrect1') }}
</span>
@endif
<!-- uncorrect2 -->
<input type="text" class="form-control{{ $errors->has('uncorrect2') ? ' is-invalid' : '' }} mt-2" name="uncorrect2" value="{{ old('uncorrect2') }}" placeholder="選択肢2)沖ノ鳥島">
@if($errors->has('uncorrect2'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('uncorrect2') }}
</span>
@endif
</div>
<!-- image -->
<div class="form-group form-image-area">
<label>画像挿入</label>
<div class="form-image js-area-drop">
<i class="far fa-image fa-5x"></i>
<input type="file" class="form-control-file{{ $errors->has('image_name') ? ' is-invalid' : '' }} input-file" name="image_name">
<img class="prev-img" src="" style="@if(!(old('image_name'))) {{ 'display:none' }} @endif" alt="投稿画像">
</div>
@if($errors->has('image_name'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('image_name') }}
</span>
@endif
</div>
<!-- explain -->
<div class="form-group">
<label>解説を入力<span class="attention">必須</span></label>
<textarea cols="40" rows="3" class="form-control{{ $errors->has('explain_sentence') ? ' is-invalid' : '' }}" name="explain_sentence" value="{{ old('explain_sentence') }}" placeholder="解説)解説を書きます">
{{ old('explain_sentence') }}
</textarea>
@if($errors->has('explain_sentence'))
<span class="invalid-feedback" role="alert">
{{ $errors->first('explain_sentence') }}
</span>
@endif
</div>
<button type="submit" class="btn btn-default btn-large">投稿</button>
</form>
@endsection
5. マイグレーションを使ってDB作成
Laravelにはマイグレーションといったデータベースをソース上で管理できる機能があります。
Laravel独自のコマンドを打つ事でテーブル作成に必要なテンプレートを自動で作成してくれたり、テーブル構造を書いたソースファイルをマイグレーション実行する事で簡単にDBにテーブルを構築することができます。
また、のちにカラムを追加したり削除したいとなった場合にも変更が容易に出来ます。
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateQuizzesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('quizzes', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned();
$table->string('title');
$table->string('correct');
$table->string('uncorrect1');
$table->string('uncorrect2');
$table->string('explain_sentence');
$table->string('image_name')->nullable()->default(NULL);
$table->integer('category_id')->unsigned();
$table->integer('region_id')->unsigned();
$table->boolean('delete_flg')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent();
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade')
->onUpdate('cascade');
$table->foreign('category_id')
->references('id')
->on('categories')
->onDelete('cascade')
->onUpdate('cascade');
$table->foreign('region_id')
->references('id')
->on('region')
->onDelete('cascade')
->onUpdate('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('quizzes');
}
}
6. シーダーでテストデータを投入
Laravel使った際にこれ便利!と思った機能の一つの**「シーディング機能」**。
色々な機能を実装していく際にちゃんとデーターが表示できているか、カテゴリ別に表示できているか、などの動きを確認するのにはデータが必要になってきます。
一つ一つデータをDBにインサートしていくのはなかなか面倒。
もし、開発途中でデータが消えてしまった。。(泣)ってことになった時にまた1から入れなおすのも泣きたくなります。(実際開発中に訳わからなくなってDBを消したりしてやり直したりしてました💭)
シーディング機能を使うとコマンド一つでデータをインサートすることができます。
<?php
use Illuminate\Database\Seeder;
class QuizTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$allquiz = [
$quiz1 = [
'user_id' => '1',
'title' => '長命草を食べるとどうなるといわれている?',
'correct' => '長生きできる',
'uncorrect1' => '与那国馬になれる',
'uncorrect2' => '空を飛べる',
'explain_sentence' => '長命草には豊富な栄養素が含まれています。皆さんも摂取して健康長寿!',
'category_id' => '2',
'region_id' => '2',
],
$quiz2 = [
'user_id' => '2',
'title' => '与那国島に生息している世界最大の蛾の名前は?',
'correct' => 'ヨナグニサン',
'uncorrect1' => 'サイトウサン',
'uncorrect2' => 'オオシロサン',
'explain_sentence' => '与那国島で初めて発見されたことから「ヨナグニサン」という名前になりました。羽を広げると18cm~24cmにもなります。(でか!)ちなみに与那国の方言では「アヤミハビル」と言います。',
'category_id' => '3',
'region_id' => '4',
],
$quiz3 = [
'user_id' => '3',
'title' => '与那国島の方言で「ありがとう」はなんという?',
'correct' => 'ふがらっさ〜',
'uncorrect1' => 'てんきゅ〜',
'uncorrect2' => 'かむさ〜',
'explain_sentence' => '与那国の方言でありがとうは「ふがらっさー」と言います。ネイティブの発音はぜひ現地で聞いてみてね〜♪',
'image_name' => 'images/uma.jpg',
'category_id' => '4',
'region_id' => '6',
],
$quiz4 = [
'user_id' => '4',
'title' => '44与那国島の方言で「ありがとう」はなんという?',
'correct' => 'ふがらっさ〜',
'uncorrect1' => 'てんきゅ〜',
'uncorrect2' => 'かむさ〜',
'explain_sentence' => '与那国の方言でありがとうは「ふがらっさー」と言います。ネイティブの発音はぜひ現地で聞いてみてね〜♪',
'image_name' => 'images/uma.jpg',
'category_id' => '4',
'region_id' => '10',
'delete_flg' => '1'
]
];
foreach ($allquiz as $quiz) {
DB::table('quizzes')->insert($quiz);
}
}
}
7. 各機能の実装
要件定義 → 設計 → WF作成 → 画面コーディング → DB作成
とやっていき、ここからやっとログイン機能やクイズ作成・編集などの実装をしていきます。
CRUD機能について
Laravelの便利な機能としてCRUD機能
があります。
CRUD機能とは、
・登録機能(Create)
・参照機能(Read)
・変更機能(Update)
・削除機能(Delete)
のことを指します。
世に出ているシステムやWebサービスにはほぼ確実に備わっている機能であり、必要最低限これらの機能はないといけないよねっていう基本的な機能になります。
Laravelではこの必要最低限のCURD機能をコマンド一つで作ってくれます。(便利)
Laravel5.7: usersのCRUD機能を実装する - Qiita
そもそもフレームワークというのは開発キットみたいにあらかじめソフトを作る上で必要なものを用意してくれていて、開発スピードを上げたり、書き方が統一されるため改修がしやすくなったりというメリットがあります。
クイズCRUD機能
<?php
namespace App\Http\Controllers\User;
use App\Http\Requests\StorePost;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use App\Models\Quiz;
use Image;
use Log;
class QuizPostController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
//投稿クイズ一覧
public function index()
{
$id = Auth::id();
$quiz_posts = Quiz::latest()
->where([
['user_id', '=', $id]
])
->get();
return view('userpage.quiz_posts', ['quiz_posts' => $quiz_posts]);
}
//ユーザー設定
public function show()
{
return view('userpage.setting');
}
//クイズ作成フォーム
public function create()
{
// カテゴリと地域をviewに渡す
$categories = DB::table('categories')
->select('id', 'name')
->get();
$region = DB::table('region')
->select('id', 'name')
->get();
return view('userpage.create_quiz', compact('categories', 'region'));
}
//投稿されたデータをDBへ保存する
public function store(StorePost $request)
{
$quiz = new Quiz();
$quiz->user_id = $request->user()->id;
$quiz->category_id = $request->category_id;
$quiz->region_id = $request->region_id;
$quiz->title = $request->title;
$quiz->correct = $request->correct;
$quiz->uncorrect1 = $request->uncorrect1;
$quiz->uncorrect2 = $request->uncorrect2;
$quiz->explain_sentence = $request->explain_sentence;
//画像ファイル名をランダムの文字列へ&path変更
$file = $request->file('image_name');
if ($file != null) {
$fileName = str_random(20) . '.' . $file->getClientOriginalExtension();
Image::make($file)->save(public_path('images/' . $fileName));
$quiz->image_name = '/images/' . $fileName;
}
$quiz->save();
return redirect('/mypage');
}
//クイズ編集
public function edit($quiz_id)
{
// カテゴリと地域をviewに渡す
$categories = DB::table('categories')
->select('id', 'name')
->get();
$region = DB::table('region')
->select('id', 'name')
->get();
$quiz = Quiz::findOrFail($quiz_id);
return view(
'userpage.edit_quiz',
compact('quiz', 'categories', 'region')
);
}
public function update(StorePost $request, $quiz_id)
{
$quiz = Quiz::findOrFail($quiz_id);
$quiz->category_id = $request->category_id;
$quiz->region_id = $request->region_id;
$quiz->title = $request->title;
$quiz->correct = $request->correct;
$quiz->uncorrect1 = $request->uncorrect1;
$quiz->uncorrect2 = $request->uncorrect2;
$quiz->explain_sentence = $request->explain_sentence;
//画像ファイル名をランダムの文字列へ&path変更
$file = $request->file('image_name');
if ($file != null) {
$fileName = str_random(20) . '.' . $file->getClientOriginalExtension();
Image::make($file)->save(public_path('images/' . $fileName));
$quiz->image_name = '/images/' . $fileName;
}
$quiz->save();
return redirect('/mypage');
}
//削除
public function destroy($quiz_id)
{
$quiz = Quiz::findOrFail($quiz_id);
$quiz->delete();
return redirect('/mypage');
}
}
しんどかった実装
一つ目は、LaravelとVue.jsのデータの受け渡しの実装です。
クイズを解いていく部分はVue.jsで実装し、クイズのデータの受け渡しはLaravel側で制御する構成で作ったのですが、カテゴリ別の表示がなかなか上手くいかず。。
実装手法は簡単にまとめるとaxiosを使ってLaravel側にカテゴリ別にデータを取得するように通信してjsonデータをVue側に渡してあげるって感じです。
この部分の実装方法はまた別の記事で書いていこうと思います。😏
二つ目は、マイグレーションでのテーブル構築です。
前半の方でマイグレーションを使うと容易に構築できると書いたのですが、初期設定やMySQLのバージョンの問題などで上手くDBに接続できなかったりしました。エラー解決したと思えば別のエラーとエラーループに陥って結構手強かったです。
おわりに
今回初めてフレームワーク(Laravel、Vue.js)を使用してのWebアプリ開発をしてみて感じたことは、フレームワークはあらかじめ便利な機能が用意されているけどそれを上手く活用して開発していくにはある程度実装したい機能の仕組みやWebサービスを作っていく中での流れを一通りふまえてないと宝の持ち腐れになってしまうなって感じました。
エラーでつまずいた時や、機能を実装していく時に役立ったのはグーグル先生もそうですが過去にフルスクラッチで開発した経験だったりもします。
とにかく作る経験を積めば積むほど技術も知識も自ずと身についていくもんだなと。💪
これからもスキルアップに精進していきたいです。
以上!