2
2
3
3
## 数据预取存储容器 (Data Store)
4
4
5
- 在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据, ** 那么在开始渲染过程之前,需要先预取和解析好这些数据 ** 。
5
+ 在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照"。在我们装载客户端应用之前,我们组件中所应用的异步数据需要处于可用状态 - 否则客户端应用会使用不同的状态进行渲染,并导致激活失败 。
6
6
7
- 另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。
8
-
9
- 为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。
7
+ 为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,我们可以在渲染时预取数据,并将数据填充到 store 中。此外
8
+ ,我们将在应用渲染完成后,在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。
10
9
11
10
为此,我们将使用官方状态管理库 [ Vuex] ( https://github.com/vuejs/vuex/ ) 。我们先创建一个 ` store.js ` 文件,里面会模拟一些根据 id 获取 item 的逻辑:
12
11
@@ -22,10 +21,12 @@ Vue.use(Vuex)
22
21
import { fetchItem } from ' ./api'
23
22
24
23
export function createStore () {
24
+ // 重要: state必须是一个函数,
25
+ // 这样模块才可以多次实例化
25
26
return new Vuex.Store ({
26
- state: {
27
+ state : () => ( {
27
28
items: {}
28
- },
29
+ }) ,
29
30
actions: {
30
31
fetchItem ({ commit }, id ) {
31
32
// `store.dispatch()` 会返回 Promise,
@@ -35,6 +36,7 @@ export function createStore () {
35
36
})
36
37
}
37
38
},
39
+
38
40
mutations: {
39
41
setItem (state , { id, item }) {
40
42
Vue .set (state .items , id, item)
@@ -44,6 +46,12 @@ export function createStore () {
44
46
}
45
47
```
46
48
49
+ ::: warning
50
+ 大多数情况下,你都应该将 ` state ` 包装成一个函数,这样它的状态便不会泄露到下一个服务端执行。
51
+ [ 更多信息] ( ./structure.md#avoid-stateful-singletons )
52
+ :::
53
+
54
+
47
55
然后修改 ` app.js ` :
48
56
49
57
``` js
@@ -80,33 +88,65 @@ export function createApp () {
80
88
81
89
我们需要通过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。
82
90
83
- 我们将在路由组件上暴露出一个自定义静态函数 ` asyncData ` 。注意,由于此函数会在组件实例化之前调用,所以它无法访问 ` this ` 。需要将 store 和路由信息作为参数传递进去:
91
+ 我们将在组件中使用 ` serverPrefetch ` 选项(2.6.0+中新增)。这个选项会被服务端渲染所识别,并且暂停渲染过程,直到它所返回的promise被解决。这允许我们在渲染过程中“等待”异步数据。
92
+
93
+ ::: tip 提示
94
+ 你可以在任何组件中使用` serverPrefetch ` ,不仅仅局限在路由级别组件上
95
+ :::
96
+
97
+ 这里有一个` Item.vue ` 组件示例,它在路由匹配` '/item/:id' ` 时进行渲染。由于此时组件实例已经被创建,所以可以通过` this ` 进行访问:
84
98
85
99
``` html
86
100
<!-- Item.vue -->
87
101
<template >
88
- <div >{{ item.title }}</div >
102
+ <div v-if =" item" >{{ item.title }}</div >
103
+ <div v-else >...</div >
89
104
</template >
90
105
91
106
<script >
92
107
export default {
93
- asyncData ({ store, route }) {
94
- // 触发 action 后,会返回 Promise
95
- return store .dispatch (' fetchItem' , route .params .id )
96
- },
97
108
computed: {
98
109
// 从 store 的 state 对象中的获取 item。
99
110
item () {
100
111
return this .$store .state .items [this .$route .params .id ]
101
112
}
113
+ },
114
+ // 仅限服务端
115
+ // 它将在服务端渲染时自动被调用
116
+ serverPrefetch () {
117
+ // 在执行后返回Promise
118
+ // 以便组件在渲染执行之前等待
119
+ return this .fetchItem ()
120
+ },
121
+ // 仅限客户端
122
+ mounted () {
123
+ // 如果我们确定不在服务端执行
124
+ // 那么在这里获取item(首先展示加载文字)
125
+ if (! this .item ) {
126
+ this .fetchItem ()
127
+ }
128
+ },
129
+ methods: {
130
+ fetchItem () {
131
+ // 在执行后返回Promise
132
+ return store .dispatch (' fetchItem' , this .$route .params .id )
133
+ }
102
134
}
103
135
}
104
136
</script >
105
137
```
106
138
107
- ## 服务器端数据预取 (Server Data Fetching)
139
+ ::: warning 警告
140
+ 为了避免逻辑执行两次,你需要检查组件在` mounted ` 钩子触发时是否已完成服务端渲染
141
+ :::
108
142
109
- 在 ` entry-server.js ` 中,我们可以通过路由获得与 ` router.getMatchedComponents() ` 相匹配的组件,如果组件暴露出 ` asyncData ` ,我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。
143
+ ::: tip 提示
144
+ 你会注意到,` fetchItem() ` 逻辑在每一个组件中被重复调用了多次 (在 ` serverPrefetch ` , ` mounted ` 和 ` watch ` 回调) - 建议您自己进行抽象(例如使用混合或插件机制)以简化此类代码
145
+ :::
146
+
147
+ ## 最终状态注入
148
+
149
+ 现在我们知道了组件中,渲染过程会等待数据获取完成后继续进行,那么我们如何知道何时才是“完成”状态?为了实现此逻辑,我们需要在渲染上下文中附加一个` rendered ` 回调函数(同样是2.6新增),服务端渲染会在渲染过程完成时调用此回调。在这个时刻,全局store中保存的是应用的最终状态。此时我们可以在回调中将它注入到上下文当中:
110
150
111
151
``` js
112
152
// entry-server.js
@@ -119,29 +159,17 @@ export default context => {
119
159
router .push (context .url )
120
160
121
161
router .onReady (() => {
122
- const matchedComponents = router .getMatchedComponents ()
123
- if (! matchedComponents .length ) {
124
- return reject ({ code: 404 })
125
- }
126
-
127
- // 对所有匹配的路由组件调用 `asyncData()`
128
- Promise .all (matchedComponents .map (Component => {
129
- if (Component .asyncData ) {
130
- return Component .asyncData ({
131
- store,
132
- route: router .currentRoute
133
- })
134
- }
135
- })).then (() => {
136
- // 在所有预取钩子(preFetch hook) resolve 后,
137
- // 我们的 store 现在已经填充入渲染应用程序所需的状态。
138
- // 当我们将状态附加到上下文,
139
- // 并且 `template` 选项用于 renderer 时,
140
- // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
162
+ // `rendered`钩子函数会在应用完成渲染时被调用
163
+ context .rendered = () => {
164
+ // 在应用渲染完成后,此时我们的store中
165
+ // 填满了组件中所使用的方案状态。
166
+ // 当我们将状态附加到上下文中,并且`template`选项
167
+ // 被渲染器所使用时,状态会被自动序列化并以`window.__INITIAL_STATE__`
168
+ // 的形式注入到HTML中
141
169
context .state = store .state
170
+ }
142
171
143
- resolve (app)
144
- }).catch (reject)
172
+ resolve (app)
145
173
}, reject)
146
174
})
147
175
}
@@ -152,107 +180,14 @@ export default context => {
152
180
``` js
153
181
// entry-client.js
154
182
155
- const { app , router , store } = createApp ()
183
+ const { app , store } = createApp ()
156
184
157
185
if (window .__INITIAL_STATE__ ) {
186
+ // 使用服务端注入的数据进行store的初始化工作
158
187
store .replaceState (window .__INITIAL_STATE__ )
159
188
}
189
+ app .$mount (' #app' )
160
190
```
161
-
162
- ## 客户端数据预取 (Client Data Fetching)
163
-
164
- 在客户端,处理数据预取有两种不同方式:
165
-
166
- 1 . ** 在路由导航之前解析数据:**
167
-
168
- 使用此策略,应用程序会等待视图所需数据全部解析之后,再传入数据并处理当前视图。好处在于,可以直接在数据准备就绪时,传入视图渲染完整内容,但是如果数据预取需要很长时间,用户在当前视图会感受到"明显卡顿"。因此,如果使用此策略,建议提供一个数据加载指示器 (data loading indicator)。
169
-
170
- 我们可以通过检查匹配的组件,并在全局路由钩子函数中执行 ` asyncData ` 函数,来在客户端实现此策略。注意,在初始路由准备就绪之后,我们应该注册此钩子,这样我们就不必再次获取服务器提取的数据。
171
-
172
- ``` js
173
- // entry-client.js
174
-
175
- // ...忽略无关代码
176
-
177
- router .onReady (() => {
178
- // 添加路由钩子函数,用于处理 asyncData.
179
- // 在初始路由 resolve 后执行,
180
- // 以便我们不会二次预取(double-fetch)已有的数据。
181
- // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
182
- router .beforeResolve ((to , from , next ) => {
183
- const matched = router .getMatchedComponents (to)
184
- const prevMatched = router .getMatchedComponents (from)
185
-
186
- // 我们只关心非预渲染的组件
187
- // 所以我们对比它们,找出两个匹配列表的差异组件
188
- let diffed = false
189
- const activated = matched .filter ((c , i ) => {
190
- return diffed || (diffed = (prevMatched[i] !== c))
191
- })
192
-
193
- if (! activated .length ) {
194
- return next ()
195
- }
196
-
197
- // 这里如果有加载指示器 (loading indicator),就触发
198
-
199
- Promise .all (activated .map (c => {
200
- if (c .asyncData ) {
201
- return c .asyncData ({ store, route: to })
202
- }
203
- })).then (() => {
204
-
205
- // 停止加载指示器(loading indicator)
206
-
207
- next ()
208
- }).catch (next)
209
- })
210
-
211
- app .$mount (' #app' )
212
- })
213
- ```
214
-
215
- 2 . ** 匹配要渲染的视图后,再获取数据:**
216
-
217
- 此策略将客户端数据预取逻辑,放在视图组件的 ` beforeMount ` 函数中。当路由导航被触发时,可以立即切换视图,因此应用程序具有更快的响应速度。然而,传入视图在渲染时不会有完整的可用数据。因此,对于使用此策略的每个视图组件,都需要具有条件加载状态。
218
-
219
- 这可以通过纯客户端 (client-only) 的全局 mixin 来实现:
220
-
221
- ``` js
222
- Vue .mixin ({
223
- beforeMount () {
224
- const { asyncData } = this .$options
225
- if (asyncData) {
226
- // 将获取数据操作分配给 promise
227
- // 以便在组件中,我们可以在数据准备就绪后
228
- // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
229
- this .dataPromise = asyncData ({
230
- store: this .$store ,
231
- route: this .$route
232
- })
233
- }
234
- }
235
- })
236
- ```
237
-
238
- 这两种策略是根本上不同的用户体验决策,应该根据你创建的应用程序的实际使用场景进行挑选。但是无论你选择哪种策略,当路由组件重用(同一路由,但是 params 或 query 已更改,例如,从 ` user/1 ` 到 ` user/2 ` )时,也应该调用 ` asyncData ` 函数。我们也可以通过纯客户端 (client-only) 的全局 mixin 来处理这个问题:
239
-
240
- ``` js
241
- Vue .mixin ({
242
- beforeRouteUpdate (to , from , next ) {
243
- const { asyncData } = this .$options
244
- if (asyncData) {
245
- asyncData ({
246
- store: this .$store ,
247
- route: to
248
- }).then (next).catch (next)
249
- } else {
250
- next ()
251
- }
252
- }
253
- })
254
- ```
255
-
256
191
## Store 代码拆分 (Store Code Splitting)
257
192
258
193
在大型应用程序中,我们的 Vuex store 可能会分为多个模块。当然,也可以将这些模块代码,分割到相应的路由组件 chunk 中。假设我们有以下 store 模块:
@@ -261,14 +196,17 @@ Vue.mixin({
261
196
// store/modules/foo.js
262
197
export default {
263
198
namespaced: true ,
199
+
264
200
// 重要信息:state 必须是一个函数,
265
201
// 因此可以创建多个实例化该模块
266
202
state : () => ({
267
203
count: 0
268
204
}),
205
+
269
206
actions: {
270
207
inc : ({ commit }) => commit (' inc' )
271
208
},
209
+
272
210
mutations: {
273
211
inc : state => state .count ++
274
212
}
@@ -288,9 +226,26 @@ export default {
288
226
import fooStoreModule from ' ../store/modules/foo'
289
227
290
228
export default {
291
- asyncData ({ store }) {
292
- store .registerModule (' foo' , fooStoreModule)
293
- return store .dispatch (' foo/inc' )
229
+ computed: {
230
+ fooCount () {
231
+ return this .$store .state .foo .count
232
+ }
233
+ },
234
+ // 仅限服务端
235
+ serverPrefetch () {
236
+ this .registerFoo ()
237
+ return this .fooInc ()
238
+ },
239
+ // 仅限客户端
240
+ mounted () {
241
+ // 我们已经在服务端增加了'count'
242
+ // 我们通过'foo'状态是否存在来进行检查
243
+ const alreadyIncremented = !! this .$store .state .foo
244
+ // 我们注册foo模块
245
+ this .registerFoo ()
246
+ if (! alreadyIncremented) {
247
+ this .fooInc ()
248
+ }
294
249
},
295
250
296
251
// 重要信息:当多次访问路由时,
@@ -299,9 +254,13 @@ export default {
299
254
this .$store .unregisterModule (' foo' )
300
255
},
301
256
302
- computed: {
303
- fooCount () {
304
- return this .$store .state .foo .count
257
+ methods: {
258
+ registerFoo () {
259
+ // 如果状态在服务端已被注入,则保留之前的状态
260
+ this .$store .registerModule (' foo' , fooStoreModule, { preserveState: true })
261
+ },
262
+ fooInc () {
263
+ return this .$store .dispatch (' foo/inc' )
305
264
}
306
265
}
307
266
}
@@ -310,6 +269,6 @@ export default {
310
269
311
270
由于模块现在是路由组件的依赖,所以它将被 webpack 移动到路由组件的异步 chunk 中。
312
271
313
- ---
314
-
315
- 哦?看起来要写很多代码!这是因为,通用数据预取可能是服务器渲染应用程序中最复杂的问题,我们正在为下一步开发做前期准备。一旦设定好模板示例,创建单独组件实际上会变得相当轻松。
272
+ ::: warning 警告
273
+ 不要忘记在 ` registerModule ` 时使用 ` preserveState: true ` 选项,这样我们就可以保持服务器端注入的状态了
274
+ :::
0 commit comments