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
<template>
  <div id="app">
    <router-link to="/bar">bar</router-link>
    <router-link to="/foo">foo</router-link>
    <router-view></router-view>
  </div>
</template>
<script>
import Bar from './components/Bar.vue'
import Foo from './components/Foo.vue'
export default {
  components: {
    Bar,
    Foo
  }
}
</script>

Bar.vue加上store状态, 在组件挂载之后,修改name的名称:

// src/components/Bar.vue
<template>
  <div>
    bar {{ this.$store.state.name }}
  </div>
</template>
<script>
import { mapState } from 'vuex'
export default {
  mounted(){
    this.$store.dispatch('changeName', 'good')
  }
}
</script>
<style scoped>
div{
  background: #f00;
}
</style>

接下来在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做的。

<script>
    window.__INITIAL_STATE__ = {
        "name": "good"
    }
  </script>

这样就要修改Bar.vue代码,加上asyncData方法:

<script>
import { mapState } from 'vuex'
export default {
  // 默认页面一挂载就展示,后端服务器不支持mounted
  // 希望有些数据更新操作是在后端执行的,可以自定义一个方法 asyncData函数,nuxt里面是这么叫的,官方文档也是写的
  asyncData({ store, route }){ // 只有页面级别的组件才有这个方法,然后在服务端调用这个方法,然后把结果放到 vuex中
    return store.dispatch('changeName', 'good')
    // 这样写还有个问题,后端返回了最新的数据,但是前端的store是老的数据,然后覆盖了后端返回的,
    // 所以需要把后端的store同步给前端的store
  },
  mounted(){
    this.$store.dispatch('changeName', 'good')
  }
}
</script>

修改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属性,然后替换掉前端的状态,就可以保持前后统一
  // <script>
  //   window.__INITIAL_STATE__ = {
  //       "name": "good"
  //   }
  // </script>
  // 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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342