这一篇来加入路由和状态到服务端渲染里面,来解决上一篇遗留的问题。
路由,状态以及实例的实现其实是差不多的,都是需要在服务端生成多实例,所以同样需要导出函数。这里来将 router
和 store
一起讲,因为处理思路都差不多,两个模块在服务端实现中的重点会分别指出。
创建一个create-router.js
,路由同样需要导出一个函数,然后在app.js
中执行函数,创建实例,最后返回router
实例,这样服务端入口文件server-entry.js
就能拿到router
实例。
// src/create-router.js
// 用来创建路由
// 可以用异步组件来加载(webpack 代码分割功能,import())
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const Foo = () => import('./components/Foo')
const Bar = () => import('./components/Bar')
export default () => {
const router = new VueRouter({
mode: 'history',
routes: [
{path: '/', redirect: '/bar'},
{path: '/foo', component: Foo},
{path: '/bar', component: Bar}
]
})
return router
}
因为是服务端渲染,所以模式选择的是history
,路由映射的组件是动态import
进来的。
创建一个create-store.js
,同样导出一个函数,这里把state
,mutations
和actions
都加上。
// src/create-store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default () => {
const store = new Vuex.Store({
state: {
name: 'john'
},
mutations: {
changeName(state, payload){
state.name = payload
}
},
actions: {
changeName({commit}, payload){
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('changeName', payload)
resolve()
}, 2000)
})
}
}
})
return store // 导出store容器
}
接下来修改一下组件,给App.vue
加上导航切换:
// src/App.vue
bar
foo
给Bar.vue
加上store
状态, 在组件挂载之后,修改name
的名称:
// src/components/Bar.vue
bar {{ this.$store.state.name }}
接下来在app.js
中引入router,store
。客户端的处理是一样的,需要注意的还是服务端,跟app
实例一样,需要导出给服务端用。
// src/app.js
import Vue from 'vue'
import App from './App.vue'
import createRouter from './create-router'
import createStore from './create-store'
// const vm = new Vue({
// el: '#app',
// render: h => h(App)
// })
// 1. 客户端渲染的时候每打开一个浏览器都会产生一个vue的实例,
// 而服务器如果按照这样的写法,会在所有人访问时都产生同样的实例,
// 所有app.js一定要导出一个函数,每次访问都产生新的实例
export default () => {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return {// 返回一个对象,后续会加入router等
app,
router,
store
}
}
客户端的入口文件不需要更改,服务端入口文件server-entry.js
需要接收来自server.js
中传入的上下文对象contect
,这个对象中放入了 url
,服务端入口文件拿到这个url
之后,直接跳转到路由router.push(context.url)
,在异步组件挂载之后调用在返回app实例
。
// src/server-entry.js
import createApp from './app'
// 服务端入口导出函数,每次请求进来返回的都是全新
// export default () => {
// const { app, router }= createApp()
// return app
// }
export default (context) => { // context中包含着当前访问服务端的路径 context.url
return new Promise((resolve, reject) => {
const { app, router, store }= createApp()
// 服务端会传进来一个context.url,直接默认跳转到路径
router.push(context.url)
// 路由里加载的有异步组件,需要等带组件渲染完成之后,在返回app,可以调router的onReady方法,在回调中resolve(app)
router.onReady(() => {
resolve(app)
})
})
}
最后就来看下server.js
中的处理,这个时候就不能把访问路由直接写成/根路由
,这个路由要是可变的。路由信息就存在context.url
中,router.get
里面不能放*
了,会报错,官方文档还是写的*
,更新有点慢。。,这里面通过try catch
捕获没有映射的路由直接返回404
,代码如下:
router.get('/(.*)', async (ctx) => {
// 在渲染页面的时候,需要让服务器根据当前路径渲染对应的路由
// 不能写get('*')会报错,要写成'/(.*)',但是这样写,事件又不行了,原因是注册路由和静态资源匹配的顺序
try {
ctx.body = await render.renderToString({
url: ctx.url
})
} catch(e){
if(e.code === 404) {
ctx.body = 'page bot found'
}
}
})
// 先匹配静态文件,资源找不到再匹配路由规则,顺序不能乱
app.use(static(path.resolve(__dirname, 'dist'))) // 静态文件查找路径
app.use(router.routes())
app.listen(3006)
到这里关于router
和store
的实现就差不多了,现在跑一下代码,浏览器中访问http://localhost:3006/
,切换路由操作,然后刷新,发现页面并不会出现not found
,首屏也确实返回的html
信息:
npm run build:all
npm start
但是还是有个问题,因为Bar组件
中写了在挂载是完成之后就把name
的名字从john 改成 good
,但是服务端并不会走到mounted
方法,这就造成服务端渲染的数据和客户端渲染的数据是不一致,前后端的状态是不同的,这是不对的。
解决办法就是在页面级别的组件
上声明一个asyncData
方法,而且这个方法只能在服务端被调用
,调用之后将结果放到vuex
中。这种实现也是nuxt.js
的实现方式。服务端在路由挂载完成之后,检查所有匹配到的路由组件
,循环匹配到的路由组件,看看组件中是否有asyncData
方法,如果有就执行,然后将store
传进去,等到所有组件中方法全部执行完之后,将store中
的状态放到上下文对象
中,即context.state = store.state
,执行这段代码之后,会自动给页面加上一个window
属性,这个属性上挂了state
的状态,最后用这个状态替换store中的state
,这样前后端的状态就能保持一致,window
上挂在的这个__INITIAL_STATE__
名字也是固定的。这一步也vue-server-render
做的。
这样就要修改Bar.vue
代码,加上asyncData
方法:
修改server-entry.js
,加上上面说的逻辑:
import createApp from './app'
// 服务端入口导出函数,每次请求进来返回的都是全新
// export default () => {
// const { app, router }= createApp()
// return app
// }
export default (context) => { // context中包含着当前访问服务端的路径 context.url
return new Promise((resolve, reject) => {
const { app, router, store }= createApp()
router.push(context.url) // 服务端会传进来一个context.url,直接默认跳转到路径
// 路由里加载的有异步组件,需要等带组件渲染完成之后,在返回app
router.onReady(() => {
// 获取当前匹配到的组件
const matchedComponents = router.getMatchedComponents();
if(matchedComponents.length > 0){ // 匹配到了路由
// 调用组件的 asyncData 方法, 将store传进去
Promise.all(matchedComponents.map(component => {
if(component.asyncData) {
// 返回的是promise,等到所有组件3的promise全部完成
return component.asyncData({ store, route: router.currentRoute})
}
})).then(() => {
// 所有promise完成,路由准备完毕调用返回app
// 成功之后还要将store放到上下文context中,会自动给页面增加一个window属性
context.state = store.state
resolve(app)
},reject)
} else {
return reject({code: 404})
}
}, reject)
// router.onReady(() => {
// resolve(app)
// })
})
}
create-store.js
代码也要修改:
// 前端运行的时候会执行下面方法,从window上取出server端加上去的state属性,然后替换掉前端的状态,就可以保持前后统一
//
// window.__INITIAL_STATE__这个方法名也是固定的
if(typeof window !== 'undefined' && window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
return store // 导出store容器
这个时候重新打包,启动项目,访问http://localhost:3006/
,访问根目录会重定向到/bar
切换路由,刷新页面,依然是服务端渲染:
到这整个流程就结束了,目前只是大致的梳理一下服务端渲染的流程,有很多细节并没有特别的处理,后续再完善一下吧,这个系列暂时就算完成了,撒花。
github:https://github.com/mxcz213/vue-ssr-demo/tree/part-three