Skip to content

Commit d93844e

Browse files
Akryumyyx990803
authored andcommitted
docs: use ssrPrefetch in data guide (#214)
1 parent fa83e4a commit d93844e

File tree

1 file changed

+98
-135
lines changed

1 file changed

+98
-135
lines changed

Diff for: docs/guide/data.md

+98-135
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@
22

33
## Data Store
44

5-
During SSR, we are essentially rendering a "snapshot" of our app, so if the app relies on some asynchronous data, **these data need to be pre-fetched and resolved before we start the rendering process**.
5+
During SSR, we are essentially rendering a "snapshot" of our app. The asynchronous data from our components needs to be available before we mount the client side app - otherwise the client app would render using different state and the hydration would fail.
66

7-
Another concern is that on the client, the same data needs to be available before we mount the client side app - otherwise the client app would render using different state and the hydration would fail.
8-
9-
To address this, the fetched data needs to live outside the view components, in a dedicated data store, or a "state container". On the server, we can pre-fetch and fill data into the store before rendering. In addition, we will serialize and inline the state in the HTML. The client-side store can directly pick up the inlined state before we mount the app.
7+
To address this, the fetched data needs to live outside the view components, in a dedicated data store, or a "state container". On the server, we can pre-fetch and fill data into the store while rendering. In addition, we will serialize and inline the state in the HTML after the app has finished rendering. The client-side store can directly pick up the inlined state before we mount the app.
108

119
We will be using the official state management library [Vuex](https://github.com/vuejs/vuex/) for this purpose. Let's create a `store.js` file, with some mocked logic for fetching an item based on an id:
1210

@@ -23,9 +21,12 @@ import { fetchItem } from './api'
2321

2422
export function createStore () {
2523
return new Vuex.Store({
26-
state: {
24+
// IMPORTANT: state must be a function so the module can be
25+
// instantiated multiple times
26+
state: () => ({
2727
items: {}
28-
},
28+
}),
29+
2930
actions: {
3031
fetchItem ({ commit }, id) {
3132
// return the Promise via `store.dispatch()` so that we know
@@ -35,6 +36,7 @@ export function createStore () {
3536
})
3637
}
3738
},
39+
3840
mutations: {
3941
setItem (state, { id, item }) {
4042
Vue.set(state.items, id, item)
@@ -44,6 +46,11 @@ export function createStore () {
4446
}
4547
```
4648

49+
::: warning
50+
Most of the time, you should wrap `state` in a function, so that it will not leak into the next server-side runs.
51+
[More info](./structure.md#avoid-stateful-singletons)
52+
:::
53+
4754
And update `app.js`:
4855

4956
``` js
@@ -80,34 +87,64 @@ So, where do we place the code that dispatches the data-fetching actions?
8087

8188
The data we need to fetch is determined by the route visited - which also determines what components are rendered. In fact, the data needed for a given route is also the data needed by the components rendered at that route. So it would be natural to place the data fetching logic inside route components.
8289

83-
We will expose a custom static function `asyncData` on our route components. Note because this function will be called before the components are instantiated, it doesn't have access to `this`. The store and route information needs to be passed in as arguments:
90+
We will use the `ssrPrefetch` option in our components. This option is recognized by the server renderer and will be pause the component render until the promise it returns is resolved. Since the component instance is already created at this point, it has access to `this`.
91+
92+
::: tip
93+
You can use `ssrPrefetch` in any component, not just the route-level components.
94+
:::
95+
96+
Here is an example `Item.vue` component that is rendered at the `'/item/:id'` route:
8497

8598
``` html
8699
<!-- Item.vue -->
87100
<template>
88-
<div>{{ item.title }}</div>
101+
<div v-if="item">{{ item.title }}</div>
102+
<div v-else>...</div>
89103
</template>
90104

91105
<script>
92106
export default {
93-
asyncData ({ store, route }) {
94-
// return the Promise from the action
95-
return store.dispatch('fetchItem', route.params.id)
96-
},
97-
98107
computed: {
99108
// display the item from store state.
100109
item () {
101110
return this.$store.state.items[this.$route.params.id]
102111
}
112+
},
113+
114+
// Server-side only
115+
// This will be called by the server renderer automatically
116+
ssrPrefetch () {
117+
// return the Promise from the action
118+
// so that the component waits before rendering
119+
return this.fetchItem()
120+
},
121+
122+
// Client-side only
123+
mounted () {
124+
// If we didn't already do it on the server
125+
// we fetch the item (will first show the loading text)
126+
if (!this.item) {
127+
this.fetchItem()
128+
}
129+
},
130+
131+
methods: {
132+
fetchItem () {
133+
// return the Promise from the action
134+
return store.dispatch('fetchItem', this.$route.params.id)
135+
}
103136
}
104137
}
105138
</script>
106139
```
107140

141+
::: warning
142+
You should check if the component was server-side rendered in the `mounted` hook to avoid executing the logic twice.
143+
:::
144+
108145
## Server Data Fetching
109146

110-
In `entry-server.js` we can get the components matched by a route with `router.getMatchedComponents()`, and call `asyncData` if the component exposes it. Then we need to attach resolved state to the render context.
147+
In `entry-server.js`, we will set the store state in the render context after the app is finished rendering, thanks to the `context.rendered` hook recognized by the server renderer.
111148

112149
``` js
113150
// entry-server.js
@@ -120,29 +157,17 @@ export default context => {
120157
router.push(context.url)
121158

122159
router.onReady(() => {
123-
const matchedComponents = router.getMatchedComponents()
124-
if (!matchedComponents.length) {
125-
return reject({ code: 404 })
126-
}
127-
128-
// call `asyncData()` on all matched route components
129-
Promise.all(matchedComponents.map(Component => {
130-
if (Component.asyncData) {
131-
return Component.asyncData({
132-
store,
133-
route: router.currentRoute
134-
})
135-
}
136-
})).then(() => {
137-
// After all preFetch hooks are resolved, our store is now
138-
// filled with the state needed to render the app.
160+
// This `rendered` hook is called when the app has finished rendering
161+
context.rendered = () => {
162+
// After the app is rendered, our store is now
163+
// filled with the state from our components.
139164
// When we attach the state to the context, and the `template` option
140165
// is used for the renderer, the state will automatically be
141166
// serialized and injected into the HTML as `window.__INITIAL_STATE__`.
142167
context.state = store.state
168+
}
143169

144-
resolve(app)
145-
}).catch(reject)
170+
resolve(app)
146171
}, reject)
147172
})
148173
}
@@ -153,105 +178,14 @@ When using `template`, `context.state` will automatically be embedded in the fin
153178
``` js
154179
// entry-client.js
155180

156-
const { app, router, store } = createApp()
181+
const { app, store } = createApp()
157182

158183
if (window.__INITIAL_STATE__) {
184+
// We initialize the store state with the data injected from the server
159185
store.replaceState(window.__INITIAL_STATE__)
160186
}
161-
```
162-
163-
## Client Data Fetching
164-
165-
On the client, there are two different approaches for handling data fetching:
166-
167-
1. **Resolve data before route navigation:**
168-
169-
With this strategy, the app will stay on the current view until the data needed by the incoming view has been resolved. The benefit is that the incoming view can directly render the full content when it's ready, but if the data fetching takes a long time, the user will feel "stuck" on the current view. It is therefore recommended to provide a data loading indicator if using this strategy.
170-
171-
We can implement this strategy on the client by checking matched components and invoking their `asyncData` function inside a global route hook. Note we should register this hook after the initial route is ready so that we don't unnecessarily fetch the server-fetched data again.
172-
173-
``` js
174-
// entry-client.js
175-
176-
// ...omitting unrelated code
177-
178-
router.onReady(() => {
179-
// Add router hook for handling asyncData.
180-
// Doing it after initial route is resolved so that we don't double-fetch
181-
// the data that we already have. Using `router.beforeResolve()` so that all
182-
// async components are resolved.
183-
router.beforeResolve((to, from, next) => {
184-
const matched = router.getMatchedComponents(to)
185-
const prevMatched = router.getMatchedComponents(from)
186-
187-
// we only care about non-previously-rendered components,
188-
// so we compare them until the two matched lists differ
189-
let diffed = false
190-
const activated = matched.filter((c, i) => {
191-
return diffed || (diffed = (prevMatched[i] !== c))
192-
})
193-
194-
if (!activated.length) {
195-
return next()
196-
}
197-
198-
// this is where we should trigger a loading indicator if there is one
199187

200-
Promise.all(activated.map(c => {
201-
if (c.asyncData) {
202-
return c.asyncData({ store, route: to })
203-
}
204-
})).then(() => {
205-
206-
// stop loading indicator
207-
208-
next()
209-
}).catch(next)
210-
})
211-
212-
app.$mount('#app')
213-
})
214-
```
215-
216-
2. **Fetch data after the matched view is rendered:**
217-
218-
This strategy places the client-side data-fetching logic in a view component's `beforeMount` function. This allows the views to switch instantly when a route navigation is triggered, so the app feels a bit more responsive. However, the incoming view will not have the full data available when it's rendered. It is therefore necessary to have a conditional loading state for each view component that uses this strategy.
219-
220-
This can be achieved with a client-only global mixin:
221-
222-
``` js
223-
Vue.mixin({
224-
beforeMount () {
225-
const { asyncData } = this.$options
226-
if (asyncData) {
227-
// assign the fetch operation to a promise
228-
// so that in components we can do `this.dataPromise.then(...)` to
229-
// perform other tasks after data is ready
230-
this.dataPromise = asyncData({
231-
store: this.$store,
232-
route: this.$route
233-
})
234-
}
235-
}
236-
})
237-
```
238-
239-
The two strategies are ultimately different UX decisions and should be picked based on the actual scenario of the app you are building. But regardless of which strategy you pick, the `asyncData` function should also be called when a route component is reused (same route, but params or query changed. e.g. from `user/1` to `user/2`). We can also handle this with a client-only global mixin:
240-
241-
``` js
242-
Vue.mixin({
243-
beforeRouteUpdate (to, from, next) {
244-
const { asyncData } = this.$options
245-
if (asyncData) {
246-
asyncData({
247-
store: this.$store,
248-
route: to
249-
}).then(next).catch(next)
250-
} else {
251-
next()
252-
}
253-
}
254-
})
188+
app.$mount('#app')
255189
```
256190

257191
## Store Code Splitting
@@ -262,14 +196,17 @@ In a large application, our Vuex store will likely be split into multiple module
262196
// store/modules/foo.js
263197
export default {
264198
namespaced: true,
199+
265200
// IMPORTANT: state must be a function so the module can be
266201
// instantiated multiple times
267202
state: () => ({
268203
count: 0
269204
}),
205+
270206
actions: {
271207
inc: ({ commit }) => commit('inc')
272208
},
209+
273210
mutations: {
274211
inc: state => state.count++
275212
}
@@ -289,9 +226,30 @@ We can use `store.registerModule` to lazy-register this module in a route compon
289226
import fooStoreModule from '../store/modules/foo'
290227
291228
export default {
292-
asyncData ({ store }) {
293-
store.registerModule('foo', fooStoreModule)
294-
return store.dispatch('foo/inc')
229+
computed: {
230+
fooCount () {
231+
return this.$store.state.foo.count
232+
}
233+
},
234+
235+
// Server-side only
236+
ssrPrefetch () {
237+
this.registerFoo()
238+
return this.fooInc()
239+
},
240+
241+
// Client-side only
242+
mounted () {
243+
// We already incremented 'count' on the server
244+
// We know by checking if the 'foo' state already exists
245+
const alreadyIncremented = !!this.$store.state.foo
246+
247+
// We register the foo module
248+
this.registerFoo()
249+
250+
if (!alreadyIncremented) {
251+
this.fooInc()
252+
}
295253
},
296254
297255
// IMPORTANT: avoid duplicate module registration on the client
@@ -300,9 +258,14 @@ export default {
300258
this.$store.unregisterModule('foo')
301259
},
302260
303-
computed: {
304-
fooCount () {
305-
return this.$store.state.foo.count
261+
methods: {
262+
registerFoo () {
263+
// Preserve the previous state if it was injected from the server
264+
this.$store.registerModule('foo', fooStoreModule, { preserveState: true })
265+
},
266+
267+
fooInc () {
268+
return this.$store.dispatch('foo/inc')
306269
}
307270
}
308271
}
@@ -311,6 +274,6 @@ export default {
311274

312275
Because the module is now a dependency of the route component, it will be moved into the route component's async chunk by webpack.
313276

314-
---
315-
316-
Phew, that was a lot of code! This is because universal data-fetching is probably the most complex problem in a server-rendered app and we are laying the groundwork for easier further development. Once the boilerplate is set up, authoring individual components will be actually quite pleasant.
277+
::: warning
278+
Don't forget to use the `preserveState: true` option for `registerModule` so we keep the state injected by the server.
279+
:::

0 commit comments

Comments
 (0)