Vue SSR原理介绍与Nuxt 框架简介

6,018 阅读12分钟

什么是服务器端渲染 (SSR)?

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

以上这段话来自官方的解释:Vue.js 服务器端渲染指南,通俗来讲,其大致过程如下:

用户请求--->服务器解析路由找到对应的组件 --> 通过Renderer渲染成HTML字符串并发送给客户端 --->客户端解析HTML并加载少量必要的JS文件--->执行Js对页面进行激活(Hydrate)。所谓客户端激活,实际上也就是挂载dom节点,接管服务端返回的HTML的过程。

这与通常的SPA相比,加载的资源明显减少(因为都是按需渲染,所以无所谓是否懒加载),针对性也更强。因此通常会更快的展示出页面。由于返回内容中包含预渲染的数据,因此对SEO相对友好。

服务端渲染的本质为:将Vue及对应库运行在服务端,生成应用程序的“快照”。但需要明确的是,SSR仅仅是渲染对应路由下的首屏,其余页面仍需要客户端来渲染。

构建原理

根据以上描述,有了下面这张图

原理图

可以看到,源码共有两个入口,一个server-entry, 一个client-entry。分别生成一个bundle。服务器执行server-bundle创建Renderer,在接收到请求并渲染出对应的首屏页面后,会将渲染结果以HTML字符串的形式返回,并携带着剩余的路由信息给客户端去渲染其他路由的页面。

那么,服务端的路由信息,状态信息是如何同步到客户端的呢?

源码结构(Demo)

build
├── base.js
├── client.config.js
└── server.config.js
src
├── components
│   └── HelloWorld.vue
├── App.vue
├── app.js          // 通用 entry,负责创建APP(仅创建)
├── index.html      // 模版文件
├── router.js       // VueRouter
├── store.js        // Vuex
├── index.js        // 启动服务,发送html文件
├── entry-client.js // 仅运行于浏览器,客户端构建入口文件
└── entry-server.js // 仅运行于服务器,服务器构建入口文件

在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序实例(new Vue())。对于服务器端渲染,也需如此。但服务器是一个长期运行的进程,为了避免每个请求共享同一个状态,我们需要为每个请求创建一个新的根 Vue 实例,而非全局共用一个单例。所以需要暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。

/**
 * app.js导出一个创建实例的工厂函数,被client-entry和server-entry共用
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

export function createApp () {
    const router = createRouter()
    const store = createStore()

    const app = new Vue({
        router,
        store,
        render: h => h(App)
    })

    return { app, router, store }
}

引用同一个createApp,实际上创建的实例也是完全相同的,所以路由信息和状态信息在初始状态下也是相同的。但是在引入异步数据(asyncData)之后,如果服务端提前获取并设置state,那客户端和服务端的数据将会不同,此时需要采用另外的方式来同步state,即将state序列化为字符串,夹带在html中,客户端收到并替换,以此作为初始状态,即可完成同步。这些在后面会提到。

  • server-entry负责创建并返回vm实例。但是分同步和异步两种情况
import { createApp } from './app'
// 同步情况
export default context => {
    const { app } = createApp()
    return app
}
// 亦或者存在异步操作的复杂情况,如数据预取或路由匹配,此时需要返回Promise并resolve(app)
// 以便服务器能够等待所有的内容在渲染前,就已经准备就绪。
export default context => {
    const { app } = createApp()
    return new Promise(async (resolve, reject) => {
        try {
            await 异步操作
            resolve(app)
        } catch(e){ reject(e) }
    })
}
  • client-entry负责创建实例并将其挂载到对应的dom中,从而激活应用
import { createApp } from './app'
// 客户端特定引导逻辑……
const { app, router } = createApp()

router.onReady(() => {
    // 这里假定 App.vue 模板中根元素具有 `id="app"`
    app.$mount('#app')
})

Bundle Renderer

vue-server-renderer 提供一个名为 createBundleRenderer 的核心 API,和两个核心插件

import { createBundleRenderer } from 'vue-server-renderer'
vue-server-renderer/server-plugin // 在server.config.js文件中引用
vue-server-renderer/client-plugin // 在client.config.js文件中引用

通过使用插件,server-bundle 和client-bundle将生成为可传递到 BundleRenderer 的特殊 JSON 文件vue-ssr-server-bundle.json和vue-ssr-client-manifest.json。这些特殊的json可以帮我们自动引入依赖,并且还有很多优势:

  • 提供内置source-map支持
  • 支持热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例,无需重新编译bundle.js文件)
  • ··· 详见BundlerRenderer

构建时,根据两个入口文件分别生成server-bundle和client-bundle,将其传入Renderer实例,由该实例将这二者结合起来。渲染出最终的HTML文本后,将其返回给浏览器,再由浏览器执行js代码激活Vue实例即可。

问题

如何提前获取数据并渲染出对应的HTML?

在组件的选项中增加一个方法,如asyncData,vue-router跳转成功后,获取当前active的组件列表router.getMatchedComponents(),依次调用组件中的asyncData方法,并等待所有请求完成。

由于存在异步操作,此时server-bundle需要导出一个返回Promise的函数,用于和vue-server-renderer插件配合。

如此一来,server-entry代码看起来如下:

/**
 * server-entry.js 解析路由,数据预加载
 */
import { createApp } from './app'


export default context => {
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()

        router.push(context.url)
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }

            Promise.all(matchedComponents.map(item => {
                const { asyncData } = item
                if (asyncData) {
                    return asyncData.call(item, {
                        store,
                        route: router.currentRoute
                    })
                } else return Promise.resolve()
            })).then(() => {
                // 将状态附加到上下文
                context.state = store.state
                resolve(app)
            })
        }, reject)
    })
}

在组件中可以这样使用

export default {
    asyncData ({ store, route }) {
        // 触发 action 后,会返回 Promise
        return store.dispatch('fetchItem', 'HelloWorld')
    },
}

store中相关代码如下:

actions: {
    fetchItem ({ commit }, title) {
        return new Promise((resolve, reject) => {
            // 模拟异步操作并返回Promise
            setTimeout(() => {
                commit('setItem', title)
                resolve()
            }, 1000)
        })
    }
},

Vuex中的数据如何同步

服务端返回预渲染界面之前,会等待这些异步操作结束(如果有的话),并使用最新的数据来渲染最终的HTML。而为了客户端能够同步Vuex中已有的数据,当我们将状态附加到上下文context中,并且 template 选项用于 renderer 时,状态将自动序列化为window.INITIAL_STATE,并注入 HTML。与此同时,客户端也需要使用这个值作为store的初始状态,以此来完成同步。客户端的代码看上去如下:

/**
 * entry-client.js 客户端入口文件主要用来挂载实例,同步状态
 */

import { createApp } from './app'
const { app, router, store } = createApp()

// 防止异步数据或其他异步操作导致服务端和客户端渲染的结构不一致
router.onReady(() => {
    if (window.hasOwnProperty('__INITIAL_STATE__')) {
        // 初始化替换Store状态
        store.replaceState(window.__INITIAL_STATE__)
    }
    app.$mount('#app')
})

状态同步方式

可以看到状态以这样的方式存在与html中。

Nuxt 简介

这里有一篇介绍感觉还不错 —— Nuxt爬坑

初始化

npm init nuxt-app <name>

运行以上代码将会进入一个交互界面,类似于vue-cli,根据提示选择对应选项后即可生成以下内容。

目录结构

└─test_nuxt
  ├─.nuxt               // Nuxt自动生成,临时的用于编译的文件,build
  ├─assets              // 用于组织未编译的静态资源如LESS、SASS或JavaScript,对于不需要通过 Webpack 处理的静态资源文件,可以放置在 static 目录中
  ├─components          // 用于自己编写的Vue组件,比如日历组件、分页组件
  ├─layouts             // 布局目录,用于组织应用的布局组件,不可更改⭐
  ├─middleware          // 用于存放中间件
  ├─node_modules
  ├─pages               // 用于组织应用的路由及视图,Nuxt.js根据该目录结构自动生成对应的路由配置,文件名不可更改⭐
  ├─plugins             // 用于组织那些需要在 根vue.js应用 实例化之前需要运行的 Javascript 插件。
  ├─static              // 用于存放应用的静态文件,此类文件不会被 Nuxt.js 调用 Webpack 进行构建编译处理。 服务器启动的时候,该目录下的文件会映射至应用的根路径 / 下。文件夹名不可更改。⭐
  └─store               // 用于组织应用的Vuex 状态管理。文件夹名不可更改。⭐
  ├─.editorconfig       // 开发工具格式配置
  ├─.eslintrc.js        // ESLint的配置文件,用于检查代码格式
  ├─.gitignore          // 配置git忽略文件
  ├─nuxt.config.js      // 用于组织Nuxt.js 应用的个性化配置,以便覆盖默认配置。文件名不可更改。⭐
  ├─package-lock.json   // npm自动生成,用于帮助package的统一设置的,yarn也有相同的操作
  ├─package.json        // npm 包管理配置文件
  └─README.md

其目录结构大致与vue-cli相同,但nuxt在此基础上做了一些处理。其中:

  • components下的组件可以自动被引用,再也不用每次写代码之前先引用一大堆组件了。当然如果你愿意引用也是可以的。
  • layouts目录下的组件用于布局,可以在页面组件中指定layout选项。若未指定,默认使用default.vue组件,类似于App.vue,不同的是你可以手动指定使用哪一个布局。
  • middleware目录存放中间件,中间件可以类比为路由守卫,可以用来做一些精细的控制,如拦截或重定向。不同的是,传入中间件的参数为context,后面会提到,它的内容更加丰富。
  • pages目录结构自动生成对应的路由配置(routes)。可以选择当前页面应用哪一个布局(layout),也可以指定应用哪些中间件(middleware)
  • plugins目录,用来配置各种插件,如UI组件库的按需引入、i18n等等

生命周期

Nuxt生命周期

当请求到来时,首先初始化store,类似于组件中的asyncData方法,这里的nuxtServerInit初始化state,仍然需要返回Promise。由于这里可以获取到上下文对象,从中可以获取到请求对象,意味着我们可以把一些登录信息等保存在客户端的信息保存到vuex中,供客户端使用。

接下来进入中间件校验环节,中间件就是一个函数,其参数包含上下文和请求对象等,可以在这里做拦截或重定向,类似于路由守卫。中间件有三个级别,会依次调用。

然后校验路由参数,使用validate函数。validate同样是nuxt对于vue的扩展选项,作为一个函数,传入context,根据返回值决定是否为404,可以异步,当然也可以重定向为别的页面,可以很灵活。

获取异步数据这里,将会等待asyncData执行完毕,将其返回的结果merge进data选项,这意味着asyncData函数可以设置组件数据。而fetch方法主要是用来处理vuex中的逻辑,比如发起action获取某些数据。在2.12版本之后,这个方法在生命周期中的位置移动到了created之后,并且可以作为method调用

最终进入客户端的页面渲染。当路由改变时,从中间件阶段开始循环执行。

上下文对象(context)

function (context) {
  const {
    app,
    store,
    route,
    params,
    query,
    env,
    isDev,
    isHMR,
    redirect,
    error,
    $config
  } = context
  // Server-side
  if (process.server) {
    const { req, res, beforeNuxtRender } = context
  }
  // Client-side
  if (process.client) {
    const { from, nuxtState } = context
  }}

上下文对象提供额外的参数,帮助开发者更精细的控制应用逻辑。它可以在nuxt的以下生命周期函数中获取到,如: asyncData, fetch, middleware, nuxtServerInit等,具体内容如下图所示

context

这类似于上面原理部分的这段代码,asyncData传入的参数正是所谓的context

Promise.all(matchedComponents.map(item => {
    const { asyncData } = item
    if (asyncData) {
        return asyncData.call(item, {
            store,
            route: router.currentRoute
        })
    } else return Promise.resolve()
}))

高频问题

Window or document is not defined.

这是因为一些只兼容客户端的脚本被打包进了服务端的执行脚本中去。对于只适合在客户端运行的脚本,需要通过使用 process.client 变量来判断导入。

SSR需要区分环境,不同宿主环境提供的API不相同,服务端没有window对象也没有document对象

举个例子

if (process.client) {
  require('external_library')
  window.scrollTo(100, 100)
  ...
}

The client-side rendered virtual DOM tree is not matching server-rendered content.

在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。详见客户端激活

通常情况不会出现这种问题,但浏览器可能会更改的一些特殊的 HTML 结构。例如,当你在 Vue 模板中写入:

<table>
  <tr><td>hi</td></tr>
</table>

浏览器会在 <table> 内部自动注入 <tbody>,然而,由于 Vue 生成的虚拟 DOM (virtual DOM) 不包含 <tbody>,所以会导致无法匹配。为能够正确匹配,请确保在模板中写入有效的 HTML。

还有一种情况,常常是由于环境问题:由于组件库内部默认了客户端的环境,没有对API可用性进行判断,导致服务端渲染时因调用了不属于该环境的API而出错。我们需要区分环境,只在客户端引入组件库,在nuxt.config.js中配置如下代码,指定组件库仅在客户端引入。

plugins: [
  { src: '~/plugins/bydesign.js', mode: 'client' }
],

之后由于服务端渲染时缺少组件库的支持,组件将会以xml文本形式存在与html中,

<byted-alert type="info">{{ title }}</byted-alert>
<byted-button @click="onClick">点击</byted-button>

如以上代码将被渲染成下面这样

没有组件库支持的服务端渲染结果

可以看到组件并没有被解析为常规的html结构,而是以xml文本形式存在。由于客户端无法匹配这个结构,将会销毁重建。好在nuxt为我们提供了一个内置组件ClientOnly,被该组件包括的部分仅会在客户端被渲染。将他们包裹起来后渲染结果如下

<CLientOnly>
    <byted-alert type="info">{{ title }}</byted-alert>
    <byted-button @click="onClick">点击</byted-button>
</CLientOnly>

ClientOnly组件使用效果

可以看到被包裹的组件并没有被渲染出来。此时控制台报错消失,问题解决。

由此可见,如果组件库不支持服务端运行的话,那其实都得用ClientOnly包裹起来。这样一来,SSR的效果就会打折扣,毕竟本意就是需要将内容预渲染出来。如果这样的话,客户端收到的依旧是没什么内容的空壳,还是得自己渲染。所以如果组件库不支持在服务端使用的话,就要慎重考虑一下了。

总结

SSR方案具有更好的 SEO,更快的内容到达时间 (time-to-content),但同时也有很多随之而来的问题,如:

  • 浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,如mock全局变量等,才能在服务器环境下运行。
  • 涉及更多的构建配置和部署要求
  • 会增加服务器端负载,因此如果在高流量环境 (high traffic) 下使用,需准备相应的服务器负载,并明智地采用缓存策略

通常来说,对于新闻、博客、社区、咨询或一些公司对外的主页等需要流量和曝光的项目来说,在搜索引擎的排名靠前能在一定程度上带来商业利益,这种情况下可以考虑使用SSR方案。而对于一些内部使用,没有SEO需求的系统或内网项目来说,纯客户端渲染已经足够满足需求,收益可能并不明显。

但不可否认的是,Nuxt框架基于Vue做了更高层次的封装,同时提供了许多便利,也带来了一些新的组织代码的思路,例如,通过组件分为路由组件、layout组件和业务组件,使得它们可以各自独立的处理自己的事情,并实现解耦。这种思想是值得学习的。

最后,无论是否采用SSR方案,Nuxt都值得大家试一试,但使用前记得一定要仔细阅读开篇提到的官方教程,知其然,知其所以然。