1

一、为什么使用SSR ?

在传统vue单页面应用中,页面的渲染都是由js完成,如下图所示,在服务端返回的html文件中,body中只有一个div标签和一个script标签,页面其余的dom结构都将由bundle.js生成,然后挂载到<div id="app"></div>上。这让搜索引擎爬虫抓取工具无法爬取页面的内容,如果 SEO 对你的站点很重要,则你可能需要服务器端渲染(SSR)解决此问题。

image.png
除了SEO,使用SSR还能加快首屏的呈现速度,因为服务端直接返回渲染好的页面html,不需要js就能看到完整渲染的页面。比起单页应用通常比较大的js文件,这部分代码量很小,所以首屏的到达时间会更快,白屏的时间更短。

当然,SSR的使用也有一些局限性,首先,开发条件受限,在服务端渲染中,created和beforeCreate之外的生命周期钩子不可用。其次,更多的服务器端负载,在服务端中渲染完整的应用程序,显然会比仅仅提供静态文件的服务器更加占用 CPU 资源。此外,SSR在部署方面有更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序(SPA)不同,服务器渲染应用程序,需要处于Node.js的运行环境。所以涉及到SSR技术选型的时候,要综合考虑它的优缺点,看看是否有必要使用。

二、基础功能实现

SSR的本质就服务端返回渲染好的html文档。我们先在项目根目录启动一个服务器,然后返回一个html文档。这里我们使用koa作为服务端框架。

//server.js
const Koa = require('koa')
const router = require('koa-router')()

const koa = new Koa()
koa.use(router.routes())

router.get('/',(ctx)=>{
  ctx.body = `<!DOCTYPE html>      //要返回给客户端的html
  <html lang="en">
    <head><title>Vue SSR</title></head>
    <body>
      <div>This is a server render page</div>
    </body>
  </html>`
})

koa.listen(9000, () => {
  console.log('server is listening in 9000');
})

在命令行启动服务器: node server.js, 然后在浏览器访问http://localhost:9000/,服务端回返回的内容如下,浏览会根据这段html,渲染出页面。

image.png

vue-server-renderer

当然,要返回的html字符串可以是由vue模板生成的,这就需要用到vue-server-renderer,它会基于Vue实例生成html字符串,是Vue SSR的核心。在上面的server.js中稍作修改:

const Koa = require('koa')
const router = require('koa-router')()

const koa = new Koa()
koa.use(router.routes())

const Vue = require('Vue')     //导入Vue,用于创建Vue实例
const renderer = require('vue-server-renderer').createRenderer()  //创建一个 renderer 实例
const app = new Vue({          //创建Vue实例
  template: `<div>{{msg}}</div>`,
  data(){
    return {
      msg: 'This is renderred by vue-server-renderer'
    }
  }
})

router.get('/',(ctx)=>{
  //调用renderer实例的renderToString方法,将Vue实例渲染成字符串
  //该方法接受两个参数,第一个是Vue实例,第二个是一个回调函数,在渲染完成后执行
  renderer.renderToString(app, (err, html) => {   //渲染得到的字符串作为回调函数的第二个参数传入
    ctx.body = `<!DOCTYPE html>
    <html lang="en">
      <head><title>Vue SSR</title></head>
      <body>
        ${html}    //将渲染得到的字符串拼接到要返回的结果中
      </body>
    </html>`
  })
})

koa.listen(9000, () => {
  console.log('server is listening in 9000');
})

重启服务器,再访问:

image.png

这样,我们就完成了一个极其基础的Vue SSR。但是不太具备实操性,我们在实际项目开发时,是不可能这样写的,我们会模块化地搭建项目,然后通过打包工具打包成一个或多个js文件。

正式一点的使用

搭建一个模块化的vue项目

我们模块化地搭建一个简单地vue项目,用vue-router管理路由。

// 打包入口文件 src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
Vue.config.productionTip = false
new Vue({
  el: '#app',
  router,
  render: h => h(App)
})
//  src/App.vue
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>
<style lang="less">
#app{
  margin: 0 auto;
  width: 700px;
  #nav{
    margin-bottom: 20px;
    text-align: center;
  }
}
</style>
//  src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
  }
]

export default new VueRouter({
  mode: 'history',
  routes
})
// src/views/Home.vue
<template>
  <div class="home">
    <h1>This is home page</h1>
  </div>
</template>
// src/views/About.vue
<template>
  <div class="about">
    <h1>This is an about page</h1>
  </div>
</template>

src/main.js作为打包入口文件,按照客户端单页面的方式打包,然后在浏览器打开,渲染结果如下:

image.png

将项目改造成服务端渲染

我们接下来就把上面这个demo改造成服务端渲染。

主要的改造点:服务端渲染需要Vue实例,每一次客户端请求页面,服务端渲染都是用一个新的Vue实例,不同的用户不能访问同一个Vue实例。所以服务端需要一个生成Vue实例的工厂函数,每次渲染由这个工厂函数生成Vue实例。

新建一个专门用于服务端渲染的入口文件entry.server.js:

import { createApp } from './main'

export default context => {  //生成Vue实例的工厂函数,
  return new Promise((resolve, reject) => {
    const app = createApp()
    const router = app.$router

    const { url } = context    //context包含服务端需要传递给Vue实例的一些数据,比如这里的路由
    const { fullPath } = router.resolve(url).route

    if(fullPath !== url){  //判断当前路由在Vue实例中是否存在
      return reject({
        url: fullPath
      })
    }

    router.push(url)      //设置Vue实例的当前路由

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()  //判断当前路由是否有对应组件
      if(!matchedComponents.length){
        return reject({
          code: 404
        })
      }
      resolve(app)    //返回Vue实例
    }, reject)
  })
}

src/main.js改造成如下:

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'

Vue.config.productionTip = false

export function createApp(){
  const router = createRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return app
}

基于entry.server.js打包的webpack配置,也要作一些修改:

target: 'node',
entry: './src/entry.server.js',
output: {
  path: path.join(__dirname, '../dist'),
  filename: 'bundle.server.js',
  libraryTarget: 'commonjs2'
},

然后,在服务端,我们就可以通过打包后的bundle.server.js进行服务端渲染了。

//server.js作如下改变:
const renderer = require('vue-server-renderer').createRenderer({   //基于模板创建一个 renderer 实例
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
const app = require('./dist/bundle.server.js').default    //导入Vue实例工厂函数
router.get('/(.*)', async (ctx, next) => {
  const context = {                   //获取路由,用于传递给Vue实例
    url: ctx.url
  }
  let htmlStr
  await app(context).then( res => {    //生成Vue实例,并传递给renderer实例生成字符串
    renderer.renderToString(res, context, (err,html)=>{
      if(!err){
        htmlStr = html
      }
    })
  })
  ctx.body = htmlStr
});

image.png
可以看到,这里我们已经完成了服务端的渲染,页面dom结构出现在了由服务器返回的html文档中。

客户端激活

我们已经窥见了一点在实际项目使用SSR的曙光,但是这只是第一步。现在每次点击Home/About都会从服务端请求html资源,单页面的前端路由优势并没有发挥。接下来我们将加上一步客户端激活,让网页应用同时具备单页面的优势。这也是Vue SSR的官方流程。
image.png

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。 激活原理参考官网

操作起来很简单,就是在返回的html页面中,加上client bundle,用于在客户端管理当前html。下面我们来打包生成它。

新建一个entry.client.js

import { createApp } from './main'

const app = createApp()
const router = app.$router

router.onReady(() => {
  app.$mount('#app')      //服务端渲染默认会生成一个id为app的div
})

打包的webpack配置:

entry: './src/entry.client.js',
output: {
  path: path.join(__dirname, '../dist'),
  filename: 'bundle.client.js'
},

打包完成后,就是把bundle.client.js加到html中,之前我们是基于模板渲染:

const renderer = require('vue-server-renderer').createRenderer({   //基于模板创建一个 renderer 实例
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

所以只需要把bundle.client.js加到index.template.html就可以了。

//index.template.html
<!DOCTYPE html>
<html lang="en">
  <head><title>Vue SSR</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
  <script src="bundle.client.js"></script>
</html>

重启服务,再访问,就可以看到,点击Home/About切换路由时,不会再从服务器请求html文档了。
image.png

三、请求数据

在实际项目中,页面往往是由从接口请求的数据填充渲染出来的,下面我们将用请求的数据来渲染页面。为了方便(省事),就不另外写数据接口了,我们去请求豆瓣的电影排行前20的数据。

具体思路,就是假如一个组件需要请求数据,当它是服务端渲染时,我们在服务端请求数据,当客户端以SPA的路由切换方式使用该组件时,也能在客户端发送ajax请求数据。

我们将借助vuex来完成。 因为它将数据挂载在vue实例上,传递访问数据真的很方便。

在服务器端请求数据

回顾我们客户端渲染常规的请求数据的场景,在created或mounted钩子函数中发送ajax请求,请求成功后把返回数据写到实例的data中。SSR的请求数据不能这样,因为ajax请求是异步请求,请求发出去之后,数据还没返回,后端就已经渲染完了,ajax请求的数据无法填充到页面中。

所以我们直接由服务端发送请求获取数据,也就是一个服务器向另一个服务器发送http请求,和客户端向服务器发送请求不同,这里我们使用axios,这两种它都支持。

对于每个需要请求数据的组件,我们将在组件上暴露出一个自定义静态方法asyncData,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去。

//Home.vue
<template>
  <div class="movie-list">
    <div v-for="(item, index) in list" class="movie">
      <img class="cover" :src="item.cover">
      <p>
        <span class="title">{{item.title}}</span>
        <span class="rate">{{item.rate}}</span>
      </p>
    </div>
  </div>
</template>

<script>
  export default {
    name: 'MovieList',
    asyncData ({ store,route }) {    //自定义静态方法asyncData
      return store.dispatch('getTopList')     
    },
    
    /*****
    在这里,执行asyncData,就会调用getTopList方法去请求数据
    并将数据更新到vue实例的$store.state中
    actions: {            
      getTopList (store) {      
        return top20().then((res) => {
          store.commit('setTopList', res.data.subjects)
        })
      }
    }
    *****/
    
    computed: {
      list () {
        return this.$store.state.topList
      }
    },
    created () {
      if(!this.$store.state.topList){
        this.$store.dispatch('getTopList')
      }
    }
  }
</script>

entry.server.js 中,我们通过路由获得了与 router.getMatchedComponents() 相匹配的组件,如果组件暴露出 asyncData,就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。

import { createApp } from './main'

export default context => {
  return new Promise((resolve, reject) => {
    const app = createApp()
    const router = app.$router
    const store = app.$store
    ...
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if(!matchedComponents.length){
        return reject({
          code: 404
        })
      }
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          //如果组件暴露出 asyncData,就调用这个方法
          //在本例中,就会去请求豆瓣数据,并把数据更新到app.$store.state
          return Component.asyncData({  
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        context.state = store.state  //将app.$store.state赋值给渲染上下文context.state,后面同步数据到客户端的时候会用到。
        resolve(app)
      }).catch(reject)
    }, reject)
  })
  })
}

当在数据更新到app.$store.state后,服务端渲染的html中就有数据了。可是页面是空白的,并且发送了ajax请求。原因是当客户端激活其实经过了二次渲染,也就是当bundle.client.js加载并执行后,页面由bundle.client.js再次渲染,通常来说,渲染结果会跟之前一样,所以察觉不到。

image.png

避免客户端重复请求数据

这里是跨域,所以ajax请求没有成功。如果不是跨域,页面也能出现内容的,是由客户端发送ajax获得的数据渲染而得。但在服务端已经请求的数据,在客户端应该避免重复请求,怎样同步数据到客户端?

当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端激活时,在挂载到应用程序之前,客户端的vm.$store 就应该获取到window.__INITIAL_STATE__ 状态。

1.在server.js中,为renderer.renderToString方法添加第二个参数context,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。

router.get('/(.*)', async (ctx, next) => {
  const context = {
    url: ctx.url
  }
  let htmlStr
  await app(context).then( res => {
    renderer.renderToString(res, context, (err,html)=>{  //添加第二个参数context
      if(!err){
        htmlStr = html
      }
    })
  })
  ctx.body = htmlStr
});

image.png

  1. 修改entry.client.js

    import { createApp } from './main'
    
    const app = createApp()
    const router = app.$router
    const store = app.$store
    
    if (window.__INITIAL_STATE__) {   //如果window.__INITIAL_STATE__有内容,就存到app.$store中
      store.replaceState(window.__INITIAL_STATE__)
    }
    
    router.onReady(() => {
      app.$mount('#app')
    })

    这样就避免了客户端重复请求数据,最终效果如下,可以看到客户端没有发送ajax请求了。

image.png

这个项目搭建很简易,主要是整理一下Vue SSR的原理、流程,实际开发可以选择nuxt.js这种比较成熟的框架。

项目地址: https://github.com/alasolala/...

参考资料

解密Vue SSR

手把手教你搭建SSR(vue/vue-cli + express)

Vue SSR Guide


Alaso
44 声望7 粉丝