Building a Single-page App with Vue.js
Getting Started
Clone the demo Laravel app and get it running locally
$ git clone git@github.com:dabernathy89/vue-spa-workshop.git
cd vue-spa-workshop
mv .env.example .env
mkdir database
touch database/scavengerhunt.sqlite
composer install
php artisan key:generate
php artisan migrate:fresh --seed
php -S localhost:8081
Getting Started
Following along
$ git reset --hard HEAD
$ git checkout origin/step-2
# Run this *after* we have walked through Step 2
Defining "Single-page Application"
In a single-page application, all interactions between the client and server after the initial page load happen without reloading the page (usually via AJAX).
JavaScript becomes responsible for:
- routing
- sending form submissions
- rendering HTML
We can't refactor our entire application...
You don't need to!
Easing the Transition
- Start simple - it's ok to just use `<script>` tags
- Start small - refactor feature-by-feature, creating "mini" SPAs
Vue makes both of these approaches easy
Introducing Vue.js
Vue.js allows you to write HTML that automatically reacts to changes in your data.
Vue helps you avoid:
- storing information about your data inside the HTML (via data attributes, for example)
- writing logic to update the HTML when your app's data changes
Introducing Vue.js
The core Vue library is supplemented by additional, official libraries and tools:
- Vue Router (managing client-side navigation)
- Vuex (state management)
- Vue CLI (quick way to provision Vue apps)
- Vue Dev Tools (browser extension)
Scavenger Hunt App
Home Page
- list user's scavenger hunts
- list scavenger hunts users have joined or can join
Create Scavenger Hunt Page
Scavenger Hunt Solutions Page
- owners can view submitted solutions and select a winner
Single Scavenger Hunt Page
- owners can add goals
- participants can add solutions
Starting the Refactor
Each step has a corresponding branch in the Git repo. Between each step, we'll do some live coding.
When it's time to move on to the next step, we'll all clear our working directory and start with the same code.
$ git reset --hard HEAD
$ git checkout origin/step-2
Step 1
Introduce Vue
- Comment out Laravel's bundled JS and drop in Vue script tag
- Add a section near the closing of each page for JavaScript to go in
// resources/views/layouts/app.blade.php
{{-- <script src="{{ asset('js/app.js') }}" defer></script> --}}
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
// resources/views/layouts/app.blade.php
@yield('js')
// resources/views/home.blade.php
@section('js')
@endsection
Step 1
Introduce Vue (cont'd)
- Add a basic Vue instance to your home template
// resources/views/home.blade.php (near top)
<h1>Testing: @{{ title }}</h1>
// resources/views/home.blade.php
@section('js')
<script>
new Vue({
el: '#app',
data: {
title: "I'm using Vue.JS!",
}
});
</script>
@endsection
- Now check it out in the browser! Open Dev Tools and play with the Vue instance.
Step 2
Start Vue-ifying Home Page
- Pass data from PHP to Vue
new Vue({
el: '#app',
data: {
ownedHunts: @json($owned_hunts),
}
});
Step 2
Start Vue-ifying Home Page
- Convert a Blade `@if` into a Vue `v-if`
@if (!$owned_hunts->isEmpty())
<a class="btn btn-primary" href="{{ route('hunt.create') }}">
<a v-if="ownedHunts.length" class="btn btn-primary" href="/hunts/create">
Create <i class="fas fa-plus-square"></i>
</a>
@endif
Step 2
Start Vue-ifying Home Page
<ul class="list-group list-group-flush">
@foreach($owned_hunts as $hunt)
<li class="list-group-item">
<a href="{{ route('hunt.show', ['hunt' => $hunt->id]) }}">
{{ $hunt->name }}
</a>
</li>
@endforeach
<ul v-if="ownedHunts.length" class="list-group list-group-flush">
<li v-for="hunt in ownedHunts" class="list-group-item">
<a :href="'hunts/' + hunt.id">
@{{ hunt.name }}
</ul>
- Convert a Blade `@foreach` into a Vue `v-for`
Step 2
Start Vue-ifying Home Page
- Use `v-else` to display fallback content
<div v-else class="card-body">
<p>It looks like you don't currently own any scavenger hunts. Create one now:</p>
<a class="btn btn-primary" href="/hunts/create">Create A Scavenger Hunt</a>
</div>
Step 3
Continue Vue-ifying Home Page
- Use what we've done so far to convert the "Other Scavenger Hunts" list into Vue
- For now, just comment out the "Join" and "Leave" buttons
Step 4
Convert an HTML Form
// /resources/views/hunt/partials/join-hunt-button.blade.php
<form
action="{{ route('hunt.add_user', ['hunt' => $hunt->id, 'user' => auth()->id()]) }}"
method="POST">
@csrf
<button title="Join Scavenger Hunt" class="btn btn-secondary" type="submit">
Join <i class="fas fa-user-plus"></i>
</button>
</form>
// /resources/views/home.blade.php
@include('hunt.partials.leave-hunt-button')
<button
v-if="!hunt.includesCurrentUser && hunt.is_open"
@click="joinHunt(hunt.id)"
title="Join Scavenger Hunt"
class="btn btn-secondary">
Join <i class="fas fa-user-plus"></i>
</button>
Step 4
Convert an HTML Form (cont'd)
// /resources/views/home.blade.php
new Vue({
el: '#app',
data: {
ownedHunts: @json($owned_hunts),
otherHunts: @json($other_hunts),
},
});
currentUserId: {{ auth()->id() }}
},
methods: {
joinHunt(id) {
axios.post('/hunts/' + id + '/users/' + this.currentUserId)
.then(function (response) {
console.log(response);
});
}
}
});
// /resources/views/layouts/app.blade.php
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
Step 4
Convert an HTML Form (cont'd)
Your turn! Do the same refactor, but for the "Leave" button.
Hint - the URL is the same, but uses the DELETE method
Step 5
Convert an HTML Form (cont'd)
Our refactor from HTML forms to Vue methods is working, but we're still missing some features:
- The success/error messages are not appearing
- The responses from the server are HTTP redirects
- The join/leave buttons are not automatically updated
Step 5
Convert an HTML Form (cont'd)
// /resources/views/layouts/app.blade.php
@if (session('success'))
<div class="alert alert-success fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
@endif
<div v-if="successMessage" class="alert alert-success fade show" role="alert">
@{{ successMessage }}
<button @click="successMessage = ''" type="button" class="close" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
Step 5
Convert an HTML Form (cont'd)
// app/Http/Controllers/HuntController.php
return redirect()
->back()
->with('success', 'You successfully left the Scavenger Hunt "' . $hunt->name . '".');
return response()->json([
'successMessage' => 'You successfully left the Scavenger Hunt "' . $hunt->name . '".'
]);
Step 5
Convert an HTML Form (cont'd)
// resources/views/home.blade.php
joinHunt(id) {
joinHunt(id, index) {
var vm = this;
axios.post('/hunts/' + id + '/users/' + this.currentUserId)
.then(function (response) {
console.log(response);
});
}
vm.successMessage = response.data.successMessage;
vm.otherHunts[index].includes_current_user = true;
window.scrollTo({top: 0});
});
}
// resources/views/home.blade.php
<li v-for="(hunt, index) in otherHunts">
Step 6
Convert the "Create a Scavenger Hunt" page
- Setup is the same as the home page; we need a root Vue instance mounted to `#app`
- Since this is a form with input and not just a button, we are keeping the form
- We can sync the text input value with our Vue data using `v-model`
- We'll use the `@submit` event on the form rather than the `@click` event on the button
Step 6
Convert the "Create a Scavenger Hunt" page
<form action="{{ route('hunt.store') }}" method="POST">
@csrf
<form @submit.prevent="createHunt">
<div class="form-group">
<label for="name">Scavenger Hunt Name</label>
<input class="form-control" type="text" name="name" placeholder="My Cool Scavenger Hunt">
<input v-model="huntName" class="form-control" type="text" placeholder="My Cool Scavenger Hunt">
</div>
<input class="btn btn-primary" type="submit" value="Submit">
</form>
Step 6
Convert the "Create a Scavenger Hunt" page (cont'd)
new Vue({
el: '#app',
data: {
successMessage: '',
huntName: '',
},
});
methods: {
createHunt: function () {
var vm = this;
axios.post('/hunts/create', {name: this.huntName})
.then(function (response) {
vm.successMessage = response.data.successMessage;
vm.huntName = '';
});
}
}
});
Step 6
Convert the "Create a Scavenger Hunt" page (cont'd)
createHunt: function () {
var vm = this;
axios.post('/hunts', {name: this.huntName})
.then(function (response) {
vm.successMessage = response.data.successMessage;
vm.huntName = '';
});
}
Handling errors
- Laravel automatically converts errors to JSON; we just need to display them
this.errors = [];
}, function (error) {
var errors = error.response.data.errors;
for (field in errors) {
vm.errors = vm.errors.concat(errors[field]);
}
});
}
Step 6
Convert the "Create a Scavenger Hunt" page (cont'd)
// resources/views/partials/errors.blade.php
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Handling errors
<div v-if="errors.length" class="alert alert-danger">
<ul class="mb-0">
<li v-for="error in errors">@{{ error }}</li>
</ul>
</div>
Step 7
Freebie - convert the single Scavenger Hunt page for participants
Step 8
Your turn - start converting the single Scavenger Hunt page for owners. You can choose one or more of these:
-
Form for creating new goals
- Should appear on the list when added
- Buttons for closing / deleting a Scavenger Hunt
- Appropriate buttons should be visible depending on the state of the Hunt
- Buttons for deleting goals
- Goals should be removed from the list when they're deleted
Step 8 (cont'd)
- Freebie #2 - convert the page for viewing solutions and selecting a winner
Step 9
Migrating to Webpack
At this point all of the screens in our app have been converted to use Vue. However:
- We are still relying on Laravel for routing and for rendering the overall page
- We are still passing some data to Vue via Blade
- We are starting to see code duplication
-
Our pages are completely independent from each other, with no shared structure
In order to solve these problems and move toward a real Single-page Application, we need to move our JavaScript out of Blade templates and into separate JavaScript files.
Step 9
Migrating to Webpack
- Laravel Mix is a wrapper around Webpack that reduces its complexity significantly. It is not dependent on Laravel, and can easily be dropped into most projects.
- Laravel comes pre-configured with Laravel Mix and includes a helper function for loading the bundled assets:
{{-- <script src="{{ asset('js/app.js') }}" defer></script> --}}
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="{{ mix('/js/app.js') }}"></script>
$ npm install
At this point you should install your JS dependencies with npm (or yarn):
Step 9
Migrating to Webpack
We'll get started by reducing our `app.js` file to the bare minimum:
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.Vue = require('vue');
Now you can run `npm run hot` to start watching for changes automatically:
$ npm run hot
We'll also edit `webpack.mix.js` to only compile JavaScript, and not SASS:
mix.js('resources/assets/js/app.js', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css');
Step 10
Setting up Single File Components
With Laravel Mix set up, we have much more flexibility.
We can:
- Move our existing Vue code into single file `.vue` components
- Use modern JavaScript
- Remove the `@` symbols used to prevent Blade confusion
Step 10
Setting up Single File Components (cont'd)
To get started, we will set up one parent Vue instance in `app.js` that will be shared among all of the pages. We'll also start referencing the Home page component.
Vue.component('home', require('./components/Home.vue'));
const app = new Vue({
el: '#app',
data: {
successMessage: '',
},
});
Step 10
Setting up Single File Components (cont'd)
Next, we'll set up the Home Page single file component:
// resources/assets/js/components/Home.vue
<template>
... copy everything from the 'content' section in `home.blade.php` here
</template>
<script>
export default {
... copy everything inside of the `new Vue()` call here
}
</script>
Step 10
Migrate to Single File Components (cont'd)
Our Blade file will contain the Vue component, and it will set up some data for it:
// resources/views/home.blade.php
@extends('layouts.app')
@section('content')
<home></home>
@endsection
@section('js')
<script>
window.currentUserId = {{ auth()->id() }};
window.ownedHunts = @json($owned_hunts);
window.otherHunts = @json($other_hunts);
</script>
@endsection
Step 11
Parent-child Communication
Our success message no longer works, because it lives outside of the component.
Communication from components to their parents is handled via events. This requires:
- An event handler (method) on the parent
- An event to be fired from the child (via `this.$emit`)
- A directive on the component that listens for fired events and triggers the appropriate handler on the parent
Step 11
Parent-child Communication (cont'd)
// resources/assets/js/app.js - event handler
methods: {
success(message) {
this.successMessage = message;
}
},
data: { successMessage: '' },
// resources/assets/js/components/Home.vue - event trigger
// this.successMessage = response.data.successMessage;
this.$emit('success', response.data.successMessage);
// resources/views/home.blade.php - event listener
@section('content')
<home @success="success"></home>
@endsection
Step 12
Your turn - migrate the "Create a Scavenger Hunt" page into a single file component called `Create.vue`
You'll need to:
- Register the component in `app.js`
- Move the template into the single file component, making the necessary adjustments
- Move the JavaScript into the single file component
- Dispatch event on success
Step 13
Freebie - migrating the additional pages to Single File Components
Step 14
Setting up Vue Router
Vue Router is a separate package that needs to be installed independently:
$ npm install vue-router
We'll then pull it in to our main `app.js` file:
// resources/assets/js/app.js
// After Vue is imported:
import VueRouter from 'vue-router';
Vue.use(VueRouter)
Step 14
Setting up Vue Router (cont'd)
To set up Vue Router, we need to:
- Define our routes and map them to our Single File Components
- In Laravel, point all URLs handled by Vue Router to the same endpoint
- Switch our `Vue.component` definitions to imports, so Vue Router receives the actual component object
Let's start with just the home page and the "Create a Scavenger Hunt" page.
Step 14
Setting up Vue Router (cont'd)
// resources/assets/js/app.js
import Home from './components/Home.vue';
import Create from './components/Create.vue';
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/hunts/create', component: Create }
]
});
const app = new Vue({
router: router,
...
We'll define our Router & routes above our root Vue instance so that we can pass the Router object to it.
Step 14
Setting up Vue Router (cont'd)
// routes/web.php
Route::get('/hunts/create', 'HuntController@create')->name('hunt.create');
We need to point the 'Create' route at the home URL.
Route::get('/hunts/create', 'HomeController@index')->name('hunt.create');
In our `home.blade.php` file, we'll replace the component tag with the special `<router-view>` tag:
// resources/views/home.blade.php
<home @success="success"></home>
<router-view @success="success"></router-view>
Step 14
Setting up Vue Router (cont'd)
// routes/web.php
Route::get('/hunts/create', 'HuntController@create')->name('hunt.create');
We need to point the 'Create' route at the home URL.
Route::get('/hunts/create', 'HomeController@index')->name('hunt.create');
In our `home.blade.php` file, we'll replace the component tag with the special `<router-view>` tag:
// resources/views/home.blade.php
<home @success="success"></home>
<router-view @success="success"></router-view>
Step 15
Navigation
Normal links will cause full page reloads. Vue Router provides the special <router-link> element to make it easy to generate links.
// resources/assets/js/components/Home.vue
<a v-if="ownedHunts.length" class="btn btn-primary" href="/hunts/create/">
Create <i class="fas fa-plus-square"></i>
</a>
<router-link
v-if="ownedHunts.length"
class="btn btn-primary"
:to="{ path: '/hunts/create'}">
Create <i class="fas fa-plus-square"></i>
</router-link>
Step 15
Navigation
You can also navigate programmatically:
// resources/assets/js/components/Create.vue
// After successfully creating a new Scavenger Hunt:
this.$router.push({path: '/'});
Step 16
Fetching data
We don't want to rely on Blade to pass data to Vue, so we'll set up some API endpoints to grab Hunt data:
// routes/web.php
Route::group(['middleware' => ['auth']], function () {
Route::get('/api/ownedHunts', 'HuntController@ownedHunts')->name('hunt.owned');
Route::get('/api/otherHunts', 'HuntController@otherHunts')->name('hunt.other');
...
// app/Http/HuntController.php
public function ownedHunts()
{
return auth()->user()->ownedHunts;
}
public function otherHunts()
{
return Hunt::where('owner_id', '!=', auth()->id())->get();
}
Step 16
Fetching data
We can hook into Vue Router to fetch the data before the route is entered:
// resources/assets/js/components/Home.vue
beforeRouteEnter (to, from, next) {
let ownedHunts;
let otherHunts;
axios.get('/api/ownedHunts').then(response => {
ownedHunts = response.data;
return axios.get('/api/otherHunts')
}).then(response => {
otherHunts = response.data;
next(vm => {
vm.ownedHunts = ownedHunts;
vm.otherHunts = otherHunts;
});
});
},
Building a Single-page App With Vue.js
By Daniel Abernathy
Building a Single-page App With Vue.js
- 1,177