This article will continue on from my post, setting up weback for ssr with Vue, where we implemented basic server side rendering. Now we will add hydration.
If the application relies on an external resource, for example data retreived from an external endpoint, the data needs to be fetched and resolved before we call renderer.renderToString
.
The source code is available here.
For this example, we will fetch a post from JSONPlaceholder. The data looks like this:
{
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit"
}
The strategy will go like this:
Client Side Rendering:
mounted
lifecycle hook, dispatch
a Vuex action
commit
the response
Server Side Rendering:
asyncData
function we will make
asyncData
, and call dispatch(action)
renderer.renderToString
We need some new modules. Namely:
axios
- a HTTP Client that works in a browser and node environment
vuex
- to store the data
Install them with:
npm install axios vuex --save
Let’s make a store, and get it working on the dev server first. Create a store by running touch src/store.js
. Inside it, add the following:
import Vue from "vue"
import Vuex from "vuex"
import axios from "axios"
Vue.use(Vuex)
export function createStore() {
return new Vuex.Store({
state: {
post: {}
},
mutations: {
SET_POST(state, { post }) {
state.post = {...state.post, ...post}
}
},
actions: {
fetchPost({ commit }) {
return axios.get("https://jsonplaceholder.typicode.com/posts/1")
.then(response => commit("SET_POST", { post: response.data }))
}
}
})
}
Standard Vuex, nothing special, so I won’t go into any detail.
We need to use the store now. Update create-app
:
import Vue from "vue"
import App from "./Hello.vue"
import { createStore } from "./store"
export function createApp() {
const store = createStore()
const app = new Vue({
el: "#app",
store,
render: h => h(App)
})
return { app, store, App }
}
We are now returning { app, store, App }
. This is because we will need access to both App
and store
in src/server.js
later on.
If you run npm run dev
, and visit localhost:8080
, everything should still be working. Update src/Hello.vue
, to dispatch the action in mounted
, and retreive it using a computed
property:
import Vue from "vue"
import App from "./Hello.vue"
import { createStore } from "./store"
export function createApp() {
const store = createStore()
const app = new Vue({
el: "#app",
store,
render: h => h(App)
})
return { app, store, App }
}
localhost:8080
should now display the title
as well as Hello
.
Run npm run build && node src/server.js
, then visit localhost:8000
. You will notice Hello
is rendered, but the post.title
is not. This is because mounted
only runs in a browser. There are no dynamic updated when using SSR, only created
and beforeCreate
execute. See here for more information. We need another way to dispatch the action.
In Hello.vue
, add a asyncData
function. This is not part of Vue, just a regular JavaScript function.
// ...
export default {
// ...
asyncData(store) {
return store.dispatch("fetchPost")
},
// ...
}
// ...
We have to pass store
as an argument. This is because asyncData
is not part of Vue, so it doesn’t have access to this
, so we cannot access the store - in fact, because we will call this function before calling renderer.renderToString
, this
doesn’t even exist yet.
Now update src/server.js
to call asyncData
:
// ...
server.get("*", (req, res) => {
const { app, store, App } = createApp()
App.asyncData(store).then(() => {
renderer.renderToString(app).then(html => {
res.end(html)
})
})
})
// ...
Now we when render app
, store.state
should already contain post
! Let’s try it out:
npm run build && node src/server.js
Visting localhost:8000
causes a error to be shown in the terminal:
(node:9708) UnhandledPromiseRejectionWarning: ReferenceError: XMLHttpRequest is not defined
at /Users/lachlanmiller/javascript/vue/webpack-simple/dist/main.js:7:63038
at new Promise (<anonymous>)
at t.exports (/Users/lachlanmiller/javascript/vue/webpack-simple/dist/main.js:7:62939)
at t.exports (/Users/lachlanmiller/javascript/vue/webpack-simple/dist/main.js:12:10624)
at <anonymous>
at process._tickCallback (internal/process/next_tick.js:188:7)
XMLHttpRequest
is Web API, and does not exist in a Node environment. But why is this happening? axios
is meant to work on both the client and server, right?
Let’s take a look at axios
:
cat node_modules/axios/package.json
There is a bunch of stuff. The fields are interested in are browser
and main
:
"main": "index.js"
...
"browser": {
"./lib/adapters/http.js": "./lib/adapters/xhr.js"
}
browser
is the source of the problem. See more about browser on npm. Basically, if there is a browser
field, and the target
of the webpack build is web
, it will use the browser
field instead of main
. Let’s review our config/server.js
:
const path = require("path")
module.exports = {
entry: "./src/create-app.js",
output: {
libraryTarget: "commonjs2"
}
}
We did not specify target
. If we check the documentation here, we can see that the default value for target
is web. This means we are using the axios
build intended for the client instead of the Node.js build. Update config.server.js
:
const path = require("path")
module.exports = {
entry: "./src/create-app.js",
target: "node",
output: {
libraryTarget: "commonjs2"
}
}
Now run
npm run build && node src/server.js
and visit localhost:8000
. The title
is rendered! Compare it to localhost:8080
using the dev server - you can see that when we do the client side fetching, the title is blank briefly, until the request finished. Visiting localhost:8000
doesn’t have this problem, since the data is fetched before the app is even rendered.
We saw how to write code that runs both on the server and client. This configuration is by no means robust and is not meant for use in a serious app, but illustrates how to set up different webpack configs for the client and server.
In this post we learned:
package.json
, specifically the browser
property
target
property
Many improvements remain:
The source code is available here.