vue 服务端渲染(三):进阶

这一篇来加入路由和状态到服务端渲染里面,来解决上一篇遗留的问题。

路由,状态以及实例的实现其实是差不多的,都是需要在服务端生成多实例,所以同样需要导出函数。这里来将 routerstore 一起讲,因为处理思路都差不多,两个模块在服务端实现中的重点会分别指出。

创建一个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,同样导出一个函数,这里把statemutationsactions都加上。

// 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.vue加上store状态, 在组件挂载之后,修改name的名称:

// src/components/Bar.vue



接下来在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)

到这里关于routerstore的实现就差不多了,现在跑一下代码,浏览器中访问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

你可能感兴趣的:(vue 服务端渲染(三):进阶)