Avoid This Common Anti-Pattern In Full-Stack Vue/Laravel Apps
Anthony Gore | August 6th, 2017 | 5 min read
If you want your Vue.js single-page app to communicate with a Laravel backend, you will, quite reasonably, think of using AJAX. Indeed, Laravel comes with the Axios library loaded in by default.
However, it's not advisable to use AJAX to retrieve application state on the initial page load, as it requires an extra round-trip to the server that will delay your Vue app from rendering.
I see many full-stack Vue/Laravel apps architected in this way. An alternative to this anti-pattern is to inject initial application state into the head of the HTML page so it's available to the app as soon as it's needed. AJAX can then be used more appropriately for subsequent data fetches.
Using this approach can get messy, though, if your app has different routes requiring different initial state. In this article, I'll demonstrate a design pattern that makes it very simple to implement this injection approach, and allows for a lot of flexibility even in multi-route apps.
As you'll shortly see, an example app I created is interactive 25% sooner when implementing this design pattern.
Passing Data To Vue From Laravel
Here's an example full-stack Vue/Laravel app I built for Oldtime Cars, a fictitious vintage car retailer. The app has a front page, which shows available cars, and a generic detail page, which shows the specifics of a particular model.
This app uses Vue Router to handle page navigation. Each page needs data from the backend (e.g. the name of the car model, the price etc), so a mechanism for sending it between Vue and Laravel is required. The standard design pattern is to setup API endpoints for each page in Laravel, then use Vue Router's beforeRouteEnter
hook to asynchronously load the data via AJAX before the page transitions.
The problem with such an architecture is that it gives us this sub-optimal loading process for the initial page load:
Eliminating the AJAX request here would make the page interactive much sooner, especially on slow internet connections.
Injecting initial application state
If we inject the initial application state into the HTML page, Vue Router won't need to request it from the server, as it will already be available in the client.
We can implement this by JSON-encoding the state server-side and assigning it to a global variable:
index.html
<html>
...
<head>
...
<script type="text/javascript">
window.__INITIAL_STATE__ = '{ "cars": [ { "id": 1 "name": "Buick", ... }, { ... } ] }'
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
It is then trivial for the app to access and use the state:
let initialState = JSON.parse(window.__INITIAL_STATE__);
new Vue({
...
})
This approach eliminates the need for an AJAX request, and reduces the initial app loading process to this:
I've supplied Lighthouse reports at the bottom of the article to show the improvement in load time.
Note: this approach won't be appropriate if the initial application state includes sensitive data. In that case, you could perhaps do a "hybrid" approach where only non-sensitive data is injected into the page and the sensitive data is retrieved by an authenticated API call.
Implementation in a multi-route app
This approach is good enough as-is in an app with only a single route, or if you're happy to inject the initial state of every page within each page requested. But Oldtime Cars has multiple routes, and it'd be much more efficient to only inject the initial state of the current page.
This means we have the following problems to address:
- How can we determine what initial state to inject into the page request, since we don't know what page the user will initially land on?
- When the user navigates to a different route from within the app, how will the app know whether or not it needs to load new state or just use the injected state?
Navigation types
Vue Router is able to capture any route changes that occur from within the page and handle them without a page refresh. That means clicked links, or JavaScript commands that change the browser location.
But route changes from the browser e.g. the URL bar, or links to the app from external pages, cannot be intercepted by Vue Router and will result in a fresh page load.
Core concept of the design pattern
With that in mind, we need to ensure that each page has the required logic to get its data from either an injection into the page, or via AJAX, depending on whether the page is being loaded freshly from the server, or by Vue Router.
Implementing this is simpler than it sounds, and is best understood through demonstration, so let's go through the code of Oldtime Cars and I'll show you how I did it.
You can see the complete code in this Github repo.
Backend setup
Routes
As the site has two pages, there are two different routes to serve: the home route, and the detail route. The design pattern requires that the routes be served either views, or as JSON payloads, so I've created both web and API routes for each:
routes/web.php
<?php
Route::get('/', 'CarController@home_web');
Route::get('/detail/{id}', 'CarController@detail_web');
routes/api.php
<?php
Route::get('/', 'CarController@home_api');
Route::get('/detail/{id}', 'CarController@detail_api');
Controller
I've abbreviated some of the code to save space, but the main idea is this: the web routes return a view with the initial application state injected into the head of the page (the template is shown below), while the API routes return exactly the same state, only as a payload.
(Also note that in addition to the state, the data includes a path
. I'll need this value in the frontend, as you'll see shortly).
app/Http/Controllers/CarController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CarController extends Controller
{
/* This function returns the data for each car, by id */
public function get_cars($id) { ... }
/* Returns a view */
public function detail_web($id)
{
$state = array_merge([ 'path' => '/detail/' . $id], $this->get_cars($id));
return view('app', ['state' => $state]);
}
/* Returns a JSON payload */
public function detail_api($id)
{
$state = array_merge([ 'path' => '/detail/' . $id], $this->get_cars($id));
return response()->json($state);
}
public function home_web() { ... }
public function home_api() { ... }
}
View
I'm using the same template for each page. Its only notable feature is that it will encode the state as JSON in the head:
resource/views/app.blade.php
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
window.__INITIAL_STATE__ = "{!! addslashes(json_encode($fields)) !!}";
</script>
</head>
<body>
<div id="app"...>
</body>
</html>
Frontend setup
Router
The frontend of the app uses a standard Vue Router setup. I have a different component for each page i.e. Home.vue and Detail.vue.
Note that the router is in history mode, because I want each route to treated separately.
resources/assets/js/app.js
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
import Home from './components/Home.vue';
import Detail from './components/Detail.vue';
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/detail/:id', component: Detail }
]
});
const app = new Vue({
el: '#app',
router
});
Page components
There's very little going on in the page components. The key logic is in a mixin which I'll show next.
Home.vue
<template>
<div>
<h1>Oldtime Cars</h1>
<div v-for="car in cars"...>
</div>
</template>
<script>
import mixin from '../mixin';
export default {
mixins: [ mixin ],
data() {
return {
cars: null
}
}
};
</script>
Mixin
This mixin needs to be added to all the page components, in this case Home and Detail. Here's how it works:
- Adds a
beforeRouteEnter
hook to each page component. When the app first loads, or whenever the route changes, this hook is called. It in turn calls thegetData
method. - The
getData
method loads the injected state and inspects thepath
property. From this, it determine if it can use the injected data, or if it needs to fetch new data. If the latter, it requests the appropriate API endpoint with the Axios HTTP client. - When the promise returned from
getData
resolves, thebeforeRouteEnter
hook will use whatever data is returned, and assign it to thedata
property of that component.
mixin.js
import axios from 'axios';
let getData = function(to) {
return new Promise((resolve, reject) => {
let initialState = JSON.parse(window.__INITIAL_STATE__) || {};
if (!initialState.path || to.path !== initialState.path) {
axios.get(`/api${to.path}`).then(({ data }) => {
resolve(data);
})
} else {
resolve(initialState);
}
});
};
export default {
beforeRouteEnter (to, from, next) {
getData(to).then((data) => {
next(vm => Object.assign(vm.$data, data))
});
}
};
By implementing this mixin, the page components have the required logic to get their initial state from either the data injected into the page, or via AJAX, depending on whether the page loaded from the server, or was navigated to from Vue Router.
Performance improvements for Oldtime Cars
I've generated some reports on the app performance using the Lighthouse Chrome extension.
If I skip all of the above and go back to the standard pattern of load initial application state from the API, the Lighthouse report is as follows:
One metric of relevance is the time to first meaningful paint, which here is 2570 ms.
Let's compare this to the improved architecture:
By loading initial application state from the within the page rather than from the API, the time to first meaningful paint down to 2050 ms, a 25% improvement.
About Anthony Gore
If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!
Click to load comments...