Working SSR support

This commit is contained in:
Guillaume Chau
2017-05-01 01:10:54 +02:00
parent 0ffb99e3d0
commit 2e6ac99ba8
8 changed files with 455 additions and 206 deletions
+285 -15
View File
@@ -11,7 +11,7 @@ Integrates [apollo](http://www.apollostack.com/) in your [Vue](http://vuejs.org)
[<img src="https://assets-cdn.github.com/favicon.ico" alt="icon" width="16" height="16"/> Apollo graphql server example](https://github.com/Akryum/apollo-server-example)
[<img src="https://assets-cdn.github.com/favicon.ico" alt="icon" width="16" height="16"/> Apollo "hello world" example app](https://github.com/Akryum/frontpage-vue-app)
[<img src="https://assets-cdn.github.com/favicon.ico" alt="icon" width="16" height="16"/> Apollo "hello world" example app](https://github.com/Akryum/frontpage-vue-app) (outdated)
[<img src="https://cdn-static-1.medium.com/_/fp/icons/favicon-medium.TAS6uQ-Y7kcKgi0xjcYHXw.ico" alt="icon" width="16" height="16"/> Howto on Medium](https://dev-blog.apollodata.com/use-apollo-in-your-vuejs-app-89812429d8b2#.pdd4hmcrc)
@@ -1068,28 +1068,298 @@ tags: {
## Server-Side Rendering
(Work in progress)
### Prefetch components
Use `apolloProvider.collect()` to being collect queries made against this provider. You get a function that returns a promise when all queries collected are ready. Note that the provider stops collecting queries when you call the function.
On the queries you want to prefetch on the server, add the `prefetch` option. It can either be:
- a variables object,
- a function that gets the context object (which can contain the URL for example) and return a variables object,
- `true` (query's `variables` is reused).
**Warning! You don't have access to the component instance when doing prefetching on the server. Don't use `this` in `prefetch`!**
Example:
```javascript
const ensureReady = apolloProvider.collect({
// If set to false, may resolve when partial/cache result is emitted
waitForLoaded: true, // default
})
export default {
apollo: {
allPosts: {
query: gql`query AllPosts {
allPosts {
id
imageUrl
description
}
}`,
prefetch: true,
}
}
}
```
new Vue({
el: '#app',
apolloProvider,
render: h => h(App),
})
Example 2:
ensureReady().then(results => {
console.log(results.length, 'queries ready')
```javascript
export default {
apollo: {
post: {
query: gql`query Post($id: ID!) {
post (id: $id) {
id
imageUrl
description
}
}`,
prefetch: ({ route }) => {
return {
id: route.params.id,
}
},
variables () {
return {
id: this.id,
}
},
}
}
}
```
You can also tell vue-apollo that some components not used in a `router-view` (and thus not in vue-router `matchedComponents`) need to be prefetched, with the `willPrefetch` method:
```javascript
import { willPrefetch } from 'vue-apollo'
export default willPrefetch({
apollo: {
allPosts: {
query: gql`query AllPosts {
allPosts {
id
imageUrl
description
}
}`,
prefetch: true, // Don't forget this
}
}
})
```
[White paper](./ssr.js)
### On the server
To prefetch all the apollo queries you marked, use the `apolloProvider.prefetchAll` method. The first argument is the context object passed to the `prefetch` hooks (see above). It is recommended to pass the vue-router `currentRoute` object. The second argument is the array of component definition to include (e.g. from `router.getMatchedComponents` method). The third argument is an optional `options` object. It returns a promise resolved when all the apollo queries are loaded.
Here is an example with vue-router and a Vuex store:
```javascript
return new Promise((resolve, reject) => {
const { app, router, store, apolloProvider } = CreateApp({
ssr: true,
})
// set router's location
router.push(context.url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
reject({ code: 404 })
}
let js = ''
// Call preFetch hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
Promise.all([
// Vuex Store prefetch
...matchedComponents.map(component => {
return component.preFetch && component.preFetch(store)
}),
// Apollo prefetch
apolloProvider.prefetchAll({
route: router.currentRoute,
}, matchedComponents),
]).then(() => {
// Inject the Vuex state and the Apollo cache on the page.
// This will prevent unecessary queries.
// Vuex
js += `window.__INITIAL_STATE__=${JSON.stringify(store.state)};`
// Apollo
js += apolloProvider.exportStates()
resolve({
app,
js,
})
}).catch(reject)
})
})
```
The `options` argument defaults to:
```javascript
{
// Include components outside of the routes
// that are registered with `willPrefetch`
includeGlobal: true,
}
```
Use the `apolloProvider.exportStates` method to get the JavaScript code you need to inject into the generated page to pass the apollo cache data to the client.
It takes an `options` argument which defaults to:
```javascript
{
// Global variable name
globalName: '__APOLLO_STATE__',
// Global object on which the variable is set
attachTo: 'window',
// Prefix for the keys of each apollo client state
exportNamespace: '',
}
```
### Creating the Apollo Clients
It is recommended to create the apollo clients inside a function with an `ssr` argument, which is `true` on the server and `false` on the client.
Here is an example:
```javascript
// src/api/apollo.js
import Vue from 'vue'
import { ApolloClient, createNetworkInterface } from 'apollo-client'
import VueApollo from 'vue-apollo'
// Install the vue plugin
Vue.use(VueApollo)
// Create the apollo client
export function createApolloClient (ssr = false) {
let initialState
// If on the client, recover the injected state
if (!ssr && typeof window !== 'undefined') {
const state = window.__APOLLO_STATE__
if (state) {
// If you have multiple clients, use `state.<client_id>`
initialState = state.defaultClient
}
}
const apolloClient = new ApolloClient({
networkInterface: createNetworkInterface({
// You should use an absolute URL here
uri: 'https://api.graph.cool/simple/v1/cj1jvw20v3n310152sv0sirl7',
transportBatching: true,
}),
...(ssr ? {
// Set this on the server to optimize queries when SSR
ssrMode: true,
} : {
// Inject the state on the client
initialState,
// This will temporary disable query force-fetching
ssrForceFetchDelay: 100,
}),
})
return apolloClient
}
```
Example for common `CreateApp` method:
```javascript
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
import Vuex from 'vuex'
Vue.use(Vuex)
import { sync } from 'vuex-router-sync'
import VueApollo from 'vue-apollo'
import { createApolloClient } from './api/apollo'
import App from './ui/App.vue'
import routes from './routes'
import storeOptions from './store'
function createApp (context) {
const router = new VueRouter({
mode: 'history',
routes,
})
const store = new Vuex.Store(storeOptions)
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// Apollo
const apolloClient = createApolloClient(context.ssr)
const apolloProvider = new VueApollo({
defaultClient: apolloClient,
})
return {
app: new Vue({
el: '#app',
router,
store,
apolloProvider,
...App,
}),
router,
store,
apolloProvider,
}
}
export default createApp
```
On the client:
```javascript
import CreateApp from './app'
CreateApp({
ssr: false,
})
```
On the server:
```javascript
import { CreateApp } from './app'
const { app, router, store, apolloProvider } = CreateApp({
ssr: true,
})
// set router's location
router.push(context.url)
// wait until router has resolved possible async hooks
router.onReady(() => {
// Prefetch, render HTML (see above)
})
```
---
+142 -35
View File
@@ -1,3 +1,7 @@
import omit from 'lodash.omit'
import { VUE_APOLLO_QUERY_KEYWORDS } from './consts'
import { getMergedDefinition } from './utils'
export class ApolloProvider {
constructor (options) {
if (!options) {
@@ -5,43 +9,146 @@ export class ApolloProvider {
}
this.clients = options.clients || {}
this.clients.defaultClient = this.defaultClient = options.defaultClient
this._collecting = false
this.prefetchQueries = []
}
collect (options) {
const finalOptions = Object.assign({}, {
waitForLoaded: true,
}, options)
this._ready = false
this._promises = []
this._collectingOptions = finalOptions
this._isCollecting = true
this._ensureReadyPromise = null
return this.ensureReady.bind(this)
}
ensureReady () {
if (this._ready) {
return Promise.resolve()
} else {
if (!this._ensureReadyPromise) {
this._ensureReadyPromise = this._ensureReady()
}
return this._ensureReadyPromise
}
}
_waitFor (promise) {
if (this._isCollecting) {
this._promises.push(promise)
}
}
_ensureReady () {
this._isCollecting = false
return Promise.all(this._promises).then((result) => {
this._ready = true
return result
willPrefetchQuery (queryOptions, client) {
this.prefetchQueries.push({
queryOptions,
client,
})
}
willPrefetch (component) {
component = getMergedDefinition(component)
const apolloOptions = component.apollo
if (!apolloOptions) {
return
}
const componentClient = apolloOptions.$client
for (let key in apolloOptions) {
const options = apolloOptions[key]
if (
!options.query || (
(typeof options.ssr === 'undefined' || options.ssr) &&
(typeof options.prefetch !== 'undefined' && options.prefetch)
)
) {
this.willPrefetchQuery(options, options.client || componentClient)
}
}
}
willPrefetchComponents (definitions) {
for (const def of definitions) {
this.willPrefetch(def)
}
}
prefetchAll (context, components, options) {
// Optional components argument
if (!options && components && !Array.isArray(components)) {
options = components
components = undefined
}
const finalOptions = Object.assign({}, {
includeGlobal: true,
}, options)
if (components) {
this.willPrefetchComponents(components)
}
if (finalOptions.includeGlobal) {
this.willPrefetchComponents(globalPrefetchs)
}
return Promise.all(this.prefetchQueries.map(
o => this.prefetchQuery(o.queryOptions, context, o.client)
))
}
prefetchQuery (queryOptions, context, client) {
let variables
// Client
if (!client) {
client = this.defaultClient
} else if (typeof client === 'string') {
client = this.clients[client]
if (!client) {
throw new Error(`[vue-apollo] Missing client '${client}' in 'apolloProvider'`)
}
}
// Simple query
if (!queryOptions.query) {
queryOptions = {
query: queryOptions,
}
} else {
const prefetch = queryOptions.prefetch
const prefetchType = typeof prefetch
// Resolve variables
if (prefetchType !== 'undefined') {
let result
if (prefetchType === 'function') {
result = prefetch(context)
} else {
result = prefetch
}
const optVariables = queryOptions.variables
if (!result) {
return Promise.resolve()
} else if (prefetchType === 'boolean' && typeof optVariables !== 'undefined') {
// Reuse `variables` option with `prefetch: true`
if (typeof optVariables === 'function') {
variables = optVariables.call(context)
} else {
variables = optVariables
}
} else {
variables = result
}
}
}
// Query
return new Promise((resolve, reject) => {
const options = omit(queryOptions, VUE_APOLLO_QUERY_KEYWORDS)
options.variables = variables
client.query(options).then(resolve, reject)
})
}
exportStates (options) {
const finalOptions = Object.assign({}, {
exportNamespace: '',
globalName: '__APOLLO_STATE__',
attachTo: 'window',
}, options)
let js = `${finalOptions.attachTo}.${finalOptions.globalName} = {`
for (const key in this.clients) {
const client = this.clients[key]
const state = { [client.reduxRootKey || 'apollo']: client.getInitialState() }
js += `['${finalOptions.exportNamespace}${key}']:${JSON.stringify(state)},`
}
js += `};`
return js
}
}
const globalPrefetchs = []
export function willPrefetch (component) {
globalPrefetchs.push(component)
return component
}
+12
View File
@@ -0,0 +1,12 @@
export const VUE_APOLLO_QUERY_KEYWORDS = [
'variables',
'watch',
'update',
'result',
'error',
'loadingKey',
'watchLoading',
'skip',
'throttle',
'debounce',
]
+2 -27
View File
@@ -15,33 +15,8 @@ export class DollarApollo {
return this._apolloProvider || this.vm.$root._apolloProvider
}
_autoCollect (query) {
if (this.provider._isCollecting) {
let sub
this.provider._waitFor(new Promise((resolve, reject) => {
sub = query.subscribe({
next: result => {
if (!this.provider._collectingOptions.waitForLoaded || !result.loading) {
resolve(result)
}
},
error: error => {
reject(error)
},
})
}).then(result => {
sub.unsubscribe()
return result
}, error => {
sub.unsubscribe()
return error
}))
}
return query
}
query (options) {
return this._autoCollect(this.getClient(options).query(options))
return this.getClient(options).query(options)
}
getClient (options) {
@@ -77,7 +52,7 @@ export class DollarApollo {
this._apolloSubscriptions.push(sub)
return sub
}
return this._autoCollect(observable)
return observable
}
mutate (options) {
+6 -5
View File
@@ -1,8 +1,7 @@
import omit from 'lodash.omit'
import { DollarApollo } from './dollar-apollo'
import { ApolloProvider } from './apollo-provider'
let Vue
import { Globals } from './utils'
const keywords = [
'$subscribe',
@@ -46,7 +45,7 @@ const launch = function launch () {
let apollo = this.$options.apollo
if (apollo) {
if (apollo.subscribe) {
Vue.util.warn('vue-apollo -> `subscribe` option is deprecated. Use the `$subscribe` option instead.')
Globals.Vue.util.warn('vue-apollo -> `subscribe` option is deprecated. Use the `$subscribe` option instead.')
}
if (apollo.$subscribe) {
@@ -72,11 +71,11 @@ function defineReactiveSetter ($apollo, key, value) {
}
}
function install (pVue, options) {
function install (Vue, options) {
if (install.installed) return
install.installed = true
Vue = pVue
Globals.Vue = Vue
// Options merging
const merge = Vue.config.optionMergeStrategies.methods
@@ -133,3 +132,5 @@ function install (pVue, options) {
ApolloProvider.install = install
export default ApolloProvider
export { willPrefetch } from './apollo-provider'
+2 -12
View File
@@ -1,5 +1,6 @@
import omit from 'lodash.omit'
import { throttle, debounce } from './utils'
import { VUE_APOLLO_QUERY_KEYWORDS } from './consts'
class SmartApollo {
type = null
@@ -137,18 +138,7 @@ class SmartApollo {
export class SmartQuery extends SmartApollo {
type = 'query'
vueApolloSpecialKeys = [
'variables',
'watch',
'update',
'result',
'error',
'loadingKey',
'watchLoading',
'skip',
'throttle',
'debounce',
]
vueApolloSpecialKeys = VUE_APOLLO_QUERY_KEYWORDS
loading = false
constructor (vm, key, options, autostart = true) {
+6
View File
@@ -1,6 +1,8 @@
import loThrottle from 'lodash.throttle'
import loDebounce from 'lodash.debounce'
export const Globals = {}
function factory (action) {
return (cb, options) => {
if (typeof options === 'number') {
@@ -14,3 +16,7 @@ function factory (action) {
export const throttle = factory(loThrottle)
export const debounce = factory(loDebounce)
export function getMergedDefinition (def) {
return Globals.Vue.util.mergeOptions({}, def)
}
-112
View File
@@ -1,112 +0,0 @@
// app.js
import VueApollo from 'vue-apollo'
Vue.use(VueApollo)
export default function createApp ({ createApolloClients }) {
const router = new VueRouter({})
const store = new Vuex.Store({})
const { apolloClientA, apolloClientB } = createApolloClients()
const apollo = new VueApollo({
clients: {
a: apolloClientA,
b: apolloClientB,
},
defaultClient: apolloClientA,
})
const ensureReady = apollo.collect()
const app = new Vue({
el: '#app',
router,
store,
apollo,
...App,
})
return { app, router, store, apollo, ensureReady }
}
// server.js
import { ApolloClient, createNetworkInterface } from 'apollo-client'
import { createLocalInterface } from 'apollo-local-query'
import graphql from 'graphql'
import { schema } from './graphql/schema'
function createApolloClients() {
const apolloClientA = new ApolloClient({
ssrMode: true,
networkInterface: createLocalInterface(graphql, schema)
})
const apolloClientB = new ApolloClient({
ssrMode: true,
networkInterface: createNetworkInterface({
uri: 'http://my-awesome-api/graphql',
transportBatching: true,
}),
})
return {
apolloClientA,
apolloClientB,
}
}
export default function render (context) {
const { app, router, store, apollo, ensureReady } = createApp({ createApolloClients })
router.push(context.url)
router.onReady(async () => {
// Wait for apollo queries to be loaded
await ensureReady()
// Inject initial apollo states
let js = `window.__APOLLO_STATE__ = {`
for (const key in apollo.clients) {
const client = apollo.clients[key]
const state = {[client.reduxRootKey]: client.getInitialState() }
js += `['${key}']:${JSON.stringify(state)},`
}
js += `};`
// TODO Render here
// TODO Add the js to the response
})
}
// client.js
import { ApolloClient, createNetworkInterface } from 'apollo-client'
function createApolloClients() {
const state = window.__APOLLO_STATE__
const apolloClientA = new ApolloClient({
networkInterface: createNetworkInterface({
uri: '/graphql',
transportBatching: true,
}),
initialState: state.a,
ssrForceFetchDelay: 100,
})
const apolloClientB = new ApolloClient({
networkInterface: createNetworkInterface({
uri: 'http://my-awesome-api/graphql',
transportBatching: true,
}),
initialState: state.b,
ssrForceFetchDelay: 100,
})
export {
apolloClientA,
apolloClientB,
}
}
createApp({ createApolloClients })