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

客户端渲染:使用 JavaScript 框架进行页面渲染
服务端渲染:服务端将HTML文本组装好,并返回给浏览器,这个HTML文本被浏览器解析之后,不需要经过 JavaScript 脚本的执行,即可直接构建出希望的 DOM 树并展示到页面中,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

为什么使用服务器端渲染 (SSR)?

优点:

更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳,它需要执行 JavaScript 脚本之后才会渲染真正的页面。
用户将会更快速地看到完整渲染的页面

缺点:

为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况
由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的nodejs服务,新增了数据获取的IO和渲染HTML的CPU占用,
服务器渲染应用程序,需要处于 Node.js server 运行环境。

如何实现?

想要在服务器端渲染,我们需要做什么呢?那就是同构我们的项目,Vue.js 是构建客户端应用程序的框架,服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行

当运行在不同环境中时,我们的代码将不会完全相同,同构就是让一份代码,既可以在服务端中执行,也可以在客户端中执行,并且执行的效果都是一样的,都是完成这个html的组装,正确的显示页面。
对于同构应用来说,我们必须实现客户端与服务端的路由、模型组件、数据模型的共享。

服务器端渲染注意事项

为避免造成交叉请求状态污染,每个请求应该都是全新的、独立的应用程序实例。
由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。
通用代码不可接受像 window 或 document,这种仅浏览器可用的全局变量
浏览器可能会更改的一些特殊的 HTML 结构,例如,浏览器会在

内部自动注入 ,然而,由于 Vue 生成的虚拟 DOM(virtual DOM) 不包含 ,所以会导致无法匹配。

基本步骤

第 1 步:创建一个 Vue 实例
第 2 步:创建一个 renderer
第 3 步:将 Vue 实例用renderer-渲染为 HTM
第4 步:在服务端监听路由并发送页面给客户端

在这里插入图片描述

具体步骤

构建配置#
对于客户端应用程序和服务器应用程序,我们都要使用 webpack 打包 - 服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。

一个基本项目可能像是这样的:

├── build
│ ├── webpack.base.config.js # 基本配置文件:包含在两个环境共享的配置,例如,输出路径 (output path),别名 (alias) 和 loader
│ ├── webpack.client.config.js # 客户端配置文件 :生成一个构建结果清单,这个清单是用来告诉服务端,当前页面需要加载哪些JS脚本和CSS样式表。
│ ├── webpack.server.config.js # 服务端配置文件:生成传递给 createBundleRenderer 的 server bundle
└── src
├── router
│ └── index.js # 路由工厂
├── store
│ └── index.js # 状态工厂
└── components
│ ├── comp1.vue # 组件1
│ └── copm2.vue # 组件2
├── App.vue # 顶级 vue 组件
├── index.template.html # html 模板
├── app.js # 通用 entry, 根vue实例工厂
├── entry-client.js # 浏览器渲染的入口文件,在浏览器加载了客户端编译后的代码后,组件会被渲染到id为app的元素节点上 
├── entry-server.js # 服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。除了创建和返回应用程序实例之外,还会执行服务器端路由匹配和服务器端数据预取逻辑。
├── server.js # Http 服务:在server中挂起路由请求;将构建结果运行在nodejs服务器上

HTML准备完成后,使用客户端清单 (client manifest) 和服务器 bundle(server bundle),renderer 现在具有了服务器和客户端的构建信息,因此它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive)(将自动注入所有link样式表标签,而占位符将会被替换成模板组件被渲染后的具体的HTML片段和script脚本标签)

//server.js
const {createBundleRenderer} = require('vue-server-renderer');
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')
//使用 vue-server-renderer 的createBundleRenderer创建一个html渲染器:
const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})

路由、模型组件的共享

为了实现模板组件共享,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例;我们需要将获取 Vue 渲染实例写成通用代码,同样的规则也适用于 router、store 和 event bus 实例

// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'
 
export function createApp () {
  // 创建 router 和 store 实例
  const router = createRouter()
  const store = createStore()
 
  // 同步路由状态(route state)到 store
  sync(store, router)
 
  // 创建应用程序实例,将 router 和 store 注入
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })
 
  // 暴露 app, router 和 store。
  return { app, router, store }
}

同时,需要在 entry-server.js 中实现服务器端路由逻辑,将请求的URL传递给router,使得在创建app的时候可以根据URL匹配到对应的路由,进而可知道需要渲染哪些组件

// entry-server.js
import createApp from './createApp';
 
export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前就已经准备就绪。
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp();
        // 设置服务器端 router 的位置
        router.push(context.url)
        // onReady 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }
            // 在路由组件匹配到了之后,调用asyncData方法,获取数据后传递给renderer
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoutecontext.state = store.state
                    });
                }
            })).then(() => {
                // 如果设置了template选项,那么会把context.state的值作为window.__INITIAL_STATE__自动插入到模板html中
                context.state = store.state
                resolve(app)
            }).catch(reject);
        }, reject);
    })
}

数据模型的共享——数据预取

服务端数据预取
如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

另外,客户端在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器中(vuex)。

首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

那么,我们在哪里放置「dispatch 数据预取 action」的代码?

给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

将在路由组件上暴露出一个自定义静态函数 asyncData。

在路由组件匹配到了之后,调用asyncData方法,获取数据后传递给renderer

客户端数据预取

// entry-client.js
 
// ...忽略无关代码

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)
 
    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })
 
    if (!activated.length) {
      return next()
    }
 
    // 这里如果有加载指示器 (loading indicator),就触发
 
    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {
 
      // 停止加载指示器(loading indicator)
 
      next()
    }).catch(next)
  })
 
  app.$mount('#app')
})

客户端激活

指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果检查服务器渲染的输出结果,应用程序的根元素上添加了一个特殊的属性:

<div id="app" data-server-rendered="true"> //它是为了让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。

服务端预请求数据之后,通过将数据注入到组件中,渲染组件并转化成HTML,然后吐给客户端,那么客户端为了激活后端返回的HTML被解析后的DOM节点,需要将后端渲染组件时用的store的state也同步到浏览器的store中,保证在页面渲染的时候保持与服务器渲染时的数据是一致的,才能完成DOM的激活

总结
用webpack对服务器端应用程序和客户端应用程序分别进行打包,生成server bundle用于在服务器生成根vue实例,并进行相应的路由匹配、数据预取逻辑,生成client bundle用于在客户端激活服务器端发送的静态html。
当浏览器访问服务端渲染项目时,服务端将URL传给到预选构建好的VUE应用渲染器,渲染器匹配到对应的路由的组件之后,执行我们预先在组件内定义的asyncData方法获取数据,并将获取完的数据传递给渲染器的上下文,利用template组装成HTML,并将HTML和状态state一并吐给前端浏览器,浏览器加载了构建好的客户端VUE应用后,将state数据同步到前端的store中,并根据数据激活后端返回的被浏览器解析为DOM元素的HTML文本
客户端数据预取,由于服务器端只进行首屏渲染,后续路由跳转及数据预取则交给客户端应用程序处理。

参考文献

https://ssr.vuejs.org/guide/structure.html#avoid-stateful-singletons

https://github.com/yacan8/blog/issues/30
https://segmentfault.com/a/1190000016637877#comment-area

https://github.com/yujihu/vue-ssr-demo

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐