LaravelでVue Routerを使って、SPAを作るというのを見かけてSymfonyでもできないかと思いやってみました。
Symfony側実装
Symfonyインストール
Symfonyの空プロジェクトを作成します。今回はWebアプリケーションなので、必要なパッケージも合わせてインストールしていきます。
cd /path/to/
composer create-project symfony/skeleton todo
cd todo
composer require maker
composer require api
composer require webpack-encore-bundle
composer require --dev symfony/profiler-pack # デバッグツールバーが必要な場合
makerはSymfony Consoleでのクラス生成ツールです。apiはAPI Platformで、モデルに対してRestful APIを生成することができます。webpack-encore-bundleはWebpack Encoreで、簡単に言うとLaravel Mixです。これでVueをビルドしていきます。
タスク制御API開発
TODOリストのタスクを制御するAPIを開発していきます。まずはタスクのエンティティを作成します。
bin/console make:entity
Class name of the entity to create or update (e.g. GrumpyChef):
> Task
Mark this class as an API Platform resource (expose a CRUD API for it) (yes/no) [no]:
> yes
created: src/Entity/Task.php
created: src/Repository/TaskRepository.php
Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.
Taskエンティティには以下のプロパティを用意します。
名前 | 型 | 内容 |
---|---|---|
id | int | ID(自動生成) |
title | string | タスクタイトル |
シンプルですね。生成されたエンティティですが、
Mark this class as an API Platform resource
をyesにすることで、
通常のEntityにちょっと追記されます。
<?php
namespace App\Entity;
+
+ use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
/**
+ * @ApiResource
* @ORM\Entity(repositoryClass="App\Repository\TaskRepository")
*/
class Task
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
@ApiResource
アノテーションを追加されることで、API-Platformが以下のようなAPIを提供してくれます。
URI | Method | 処理 |
---|---|---|
/api/tasks | GET | 一覧取得 |
/api/tasks | POST | 追加 |
/api/tasks/{id} | GET | 1件取得 |
/api/tasks/{id} | PUT | 置換 |
/api/tasks/{id} | PATCH | 更新 |
/api/tasks/{id} | DELETE | 削除 |
以上で、タスク制御API開発完了です。
Vue用のHTML出力ページ作成
まずはコントローラーを準備します。
bin/console make:controller AppController
生成されたコントローラーを変更していきます。
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class AppController extends AbstractController
{
/**
+ * @Route("{wildcard}", name="app", requirements={"wildcard"="^(?!build|api|_(profiler|wdt)).*"})
- * @Route("/app", name="app")
*/
public function index()
{
return $this->render('app/index.html.twig');
}
}
ここが一番のポイントです。 ルーティングの変更になります。API-Platformが提供する/api/***、ビルドしたスクリプトが格納される/build、Symfonyのデバッグツールバーで利用される/_profiler, /_wdt 以外のURIは全てAppController::index()が実行されるようにします。つづいて、twig(テンプレート)を変更していきます。twigファイルはごそっと変えます。
{% extends 'base.html.twig' %}
{% block title %}Hello AppController!{% endblock %}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
{% block body %}
<div id="app"></div>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
stylesheets, javascriptsでwebpack-encoreでビルドするファイルを読み込むために、{{ encore_entry_link_tags('app') }}
、{{ encore_entry_script_tags('app') }}
を記述します。あとはbodyブロックにVueを実行するdivタグ、<div id="app"></div>
を記述します。
これで、Vue用のHTMLは準備できました。
Vue側実装
では、今回の要であるVue側の実装をしていきます。
インストール
まずはもろもろインストールします。
yarn add vue vuex bootstrap-vue bootstrap axios
yarn add vue-router vue-loader vue-template-compiler --dev
ストア機能を使いたいのでvuex、HTTP通信のためのaxios、見た目を整えたいのでbootstrap, bootstra-vueをインストールします。
続いて、Vue側のルーティング用にvue-routerと、ロード、テンプレートコンパイルのためにvue-loader, vue-template-compilerをインストールします。
ストア実装
インストールが終わったら、まずはストアから作ってきます。
import axios from 'axios';
const task = {
state: {
tasks: []
},
mutations: {
addTask(state, task) {
state.tasks.push(task);
},
deleteTask(state, index) {
state.tasks.splice(index, 1);
},
setTasks(state, tasks) {
state.tasks = tasks;
}
},
actions: {
async fetchTasks({commit}) {
const list = await axios.get('/api/tasks', {
data: {},
headers: {'Accept': 'application/json'}
}).then(res => {
commit('setTasks', res.data);
});
},
async addTask({state, commit}, task) {
await axios.post('/api/tasks', task, {headers: {'Accept': 'application/json'}})
.then(res => {
commit('addTask', res.data);
});
},
async deleteTask({state, commit}, task) {
const index = state.tasks.indexOf(task);
await axios.delete('/api/tasks/' + task.id, {headers: {'Accept': 'application/json'}})
.then(res => {
commit('deleteTask', index);
});
}
}
};
export default task;
タスク用のストアです。先ほどSymfonyで作成したAPIを呼び出して一覧取得、追加、削除の処理を記載しています。
import Vue from 'vue';
import Vuex from 'vuex';
import task from './modules/task'
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
task
}
});
こちらはstoreのindexです。上記のtask.jsをインポートして、ストアのモジュールとして設定しています。
コンポーネント実装
次にコンポーネントを実装します。
<template>
<div class="container">
<div class="row">
<h2>TODOリスト!</h2>
</div>
<div class="form-group">
<label for="title">タスク</label>
<input id="title" type="text" class="form-control" name="title" v-model="title">
</div>
<div class="form-group">
<button @click="add">登録</button>
</div>
<ul class="list-group">
<li v-for="task in tasks" class="list-group-item d-flex justify-content-between align-items-center">
{{ task.title }}
<button class="btn btn-danger" @click="deleteTask(task)">削除</button>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "Tasks",
data() {
return {
title: null
};
},
computed: {
tasks() {
// ストアのタスク一覧を出力
return this.$store.state.task.tasks;
}
},
methods: {
// ストアのタスク一覧取得アクション実行
async getTasks() {
await this.$store.dispatch('fetchTasks');
},
// ストアのタスク追加アクションを実行
async add()
{
await this.$store.dispatch('addTask', {title: this.title});
},
// ストアの削除アクションを実行
async deleteTask(task)
{
await this.$store.dispatch('deleteTask', task);
}
},
// 描画時にタスク一覧を取得
mounted() {
this.getTasks();
}
}
</script>
<style scoped>
</style>
本当は、フォームやタスクリスト内のタスクは別コンポーネントにした方がいいと思いますが、まだVueよくわからないので、ひとつにしちゃいました。このコンポーネントは
- リスト取得
- タスク追加
- タスク削除
の機能があります。そもそもの機能はストアに実装しているので、ストアのプロパティやアクションを参照しています。
ルーティング設定
続いてVue Routerによるルーティングを行っていきます。
import Vue from 'vue';
import VueRouter from "vue-router";
import Tasks from "./components/Tasks";
Vue.use(VueRouter);
const router = new VueRouter({
mode: 'history',
routes: [
{path: '/tasks', name: 'tasks', component: Tasks}
]
});
export default router;
/tasksに先ほど作成したTasksコンポーネントを割り当てます。これで、/tasksにアクセスした際に、Tasksコンポーネントが実行されます。
View実装
Viewの実装です。
<template>
<router-view>
</router-view>
</template>
<script>
export default {
name: "App"
}
</script>
<style scoped>
</style>
ルーティングされたコンポーネントを表示するViewを作成します。
アプリの設定
最後にアプリの設定を行います。まずはwebpackの設定に追記します。
var Encore = require('@symfony/webpack-encore');
// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
// directory where compiled assets will be stored
.setOutputPath('public/build/')
// public path used by the web server to access the output path
.setPublicPath('/build')
// only needed for CDN's or sub-directory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
*
* Add 1 entry for each "page" of your app
* (including one that's included on every page - e.g. "app")
*
* Each entry will result in one JavaScript file (e.g. app.js)
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('app', './assets/js/app.js')
//.addEntry('page1', './assets/js/page1.js')
//.addEntry('page2', './assets/js/page2.js')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// will require an extra script tag for runtime.js
// but, you probably want this, unless you're building a single-page app
.enableSingleRuntimeChunk()
/*
* FEATURE CONFIG
*
* Enable & configure other features below. For a full
* list of features, see:
* https://symfony.com/doc/current/frontend.html#adding-more-features
*/
.cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
// enables @babel/preset-env polyfills
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = 3;
})
// enables Sass/SCSS support
//.enableSassLoader()
// uncomment if you use TypeScript
//.enableTypeScriptLoader()
// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
//.enableIntegrityHashes(Encore.isProduction())
// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
// uncomment if you use API Platform Admin (composer req api-admin)
//.enableReactPreset()
//.addEntry('admin', './assets/js/admin.js')
+ // Enable VueJs
+ .enableVueLoader()
;
module.exports = Encore.getWebpackConfig();
Vueを有効にします。
/*
* Welcome to your app's main JavaScript file!
*
* We recommend including the built version of this JavaScript file
* (and its CSS file) in your base layout (base.html.twig).
*/
const axios = require('axios');
// any CSS you import will output into a single css file (app.css in this case)
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap-vue/dist/bootstrap-vue.css';
import '../css/app.css';
// Need jQuery? Install it with "yarn add jquery", then uncomment to import it.
// import $ from 'jquery';
import Vue from 'vue';
import Routes from './routes.js';
import App from './views/App';
import store from './stores/index';
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue';
Vue.use(BootstrapVue);
Vue.use(IconsPlugin);
const app = new Vue({
el: '#app',
router: Routes,
render: h => h(App),
store
});
export default app;
作ってきたものをインポートして、appに紐付けます。これで開発完了です。
実行
DB設定
SQLiteで動作するよう、.envを設定します。
今回はローカル環境でのみSQLiteにしたかったので、.env.localを設定します。
APP_ENV=dev
DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
設定したら、DBとテーブルを作成します。
bin/console doctrine:database:create
bin/console doctrine:schema:update --force
ビルド&サーバ起動
DBが設定できたら、ローカルでの実行はPHPのビルトインサーバを起動し、Vueをビルドします。
symfony server:start -d # もしくは php -S localhost:8000 -t public/
yarn run watch # yarn encore dev --watch
起動したら、http://localhost:8000/tasks にアクセスします。
タスクを登録したり、登録したタスクが削除できたりしました。
(見た目が悪かったのでassets/css/app.cssのbackground-colorをwhiteにしました)
これで、完成です。