From 2e6ac99ba85ac3ced0ff036d4dd39c25815d4960 Mon Sep 17 00:00:00 2001 From: Guillaume Chau Date: Mon, 1 May 2017 01:10:54 +0200 Subject: [PATCH] Working SSR support --- README.md | 300 ++++++++++++++++++++++++++++++++++++++--- src/apollo-provider.js | 177 +++++++++++++++++++----- src/consts.js | 12 ++ src/dollar-apollo.js | 29 +--- src/index.js | 11 +- src/smart-apollo.js | 14 +- src/utils.js | 6 + ssr.js | 112 --------------- 8 files changed, 455 insertions(+), 206 deletions(-) create mode 100644 src/consts.js delete mode 100644 ssr.js diff --git a/README.md b/README.md index 72af3c6..057345b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Integrates [apollo](http://www.apollostack.com/) in your [Vue](http://vuejs.org) [icon Apollo graphql server example](https://github.com/Akryum/apollo-server-example) -[icon Apollo "hello world" example app](https://github.com/Akryum/frontpage-vue-app) +[icon Apollo "hello world" example app](https://github.com/Akryum/frontpage-vue-app) (outdated) [icon 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.` + 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) +}) +``` --- diff --git a/src/apollo-provider.js b/src/apollo-provider.js index 4bcafeb..bdcff66 100644 --- a/src/apollo-provider.js +++ b/src/apollo-provider.js @@ -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 } diff --git a/src/consts.js b/src/consts.js new file mode 100644 index 0000000..71f68e2 --- /dev/null +++ b/src/consts.js @@ -0,0 +1,12 @@ +export const VUE_APOLLO_QUERY_KEYWORDS = [ + 'variables', + 'watch', + 'update', + 'result', + 'error', + 'loadingKey', + 'watchLoading', + 'skip', + 'throttle', + 'debounce', +] diff --git a/src/dollar-apollo.js b/src/dollar-apollo.js index a15fa55..57a2915 100644 --- a/src/dollar-apollo.js +++ b/src/dollar-apollo.js @@ -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) { diff --git a/src/index.js b/src/index.js index f5c2eb0..b05fc50 100644 --- a/src/index.js +++ b/src/index.js @@ -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' diff --git a/src/smart-apollo.js b/src/smart-apollo.js index 92c7c78..3dca9c4 100644 --- a/src/smart-apollo.js +++ b/src/smart-apollo.js @@ -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) { diff --git a/src/utils.js b/src/utils.js index 3963f65..a5819e6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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) +} diff --git a/ssr.js b/ssr.js deleted file mode 100644 index e482bff..0000000 --- a/ssr.js +++ /dev/null @@ -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 })