VUE SSR初探

前端开发发展史

在正式开始接触SSR之前,我们先来了解一下前端的发展史。在前端的发展过程中,经历过一下几个阶段:

传统服务端渲染SSR -> 单页面应用SPA -> 服务端渲染SSR
针对前两个,我们做个简单介绍。

传统服务端渲染SSR

  • 代表框架:ASP.net、JSP
  • 特点:请求后端返回html页面
    Alt text

单页面应用SPA

单页面应用是目前使用最广泛的开发方式,页面内容由JS渲染出来,这种方式称为客户端渲染。

  • 代表框架:vue、react
  • 特点:页面内容由JS渲染
    Alt text

什么是Vue SSR

SSR(Server Side Render)即服务端渲染,官方给出的解释是这样的:

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

服务器渲染的 Vue.js 应用程序也可以被认为是”同构”或”通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。

解读一下,我们可以得出一下结论:

  1. Vue SSR是一个在SPA上进行改良的服务端渲染
  2. 通过Vue SSR渲染的页面,需要在客户端激活才能实现交互
  3. Vue SSR将包含两部分:服务端渲染的首屏,包含交互的SPA
    Alt text

Vue SSR改造

当我们使用SSR服务端渲染后,以后将会有多个客户端向服务端请求首屏,为了使每个用户的数据、路由保持独立,我们需要对原有的项目进行改造。

宿主html模版

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>

改造vue router

原本的路由需要改造成一个工厂函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// router/index.js
import Vue from "vue";
import Router from "vue-router";
import Home from "@/views/Home";
import About from "@/views/About";

Vue.use(Router);
//导出工厂函数
export function createRouter() {
return new Router({
routes: [
{ path: "/", component: Home },
{ path: "/about", component: About }
]
})
}

vue实例创建

vue实例的创建也要修改为一个工厂函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// main.js
import Vue from "vue";
import App from "./App.vue";
import { createRouter } from "./router";
// 导出Vue实例工厂函数,为每次请求创建独立实例
// 上下文用于给vue实例传递参数
export function createApp(context) {
const router = createRouter();
const app = new Vue({
router,
context,
render: h => h(App)
})
return { app, router }
}

服务端入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// entry-server.js
import { createApp } from "./main"

// 返回一个函数,接收请求上下文,返回创建的vue实例
export default context => {
// 这里返回一个Promise,确保路由或组件准备就绪
return new Promise((resolve, reject) => {
const { app, router } = createApp(context)
// 跳转到首屏的地址
router.push(context.url)
// 路由就绪,返回结果
router.onReady(() => {
resolve(app)
}, reject)
})
}

客户端入口

1
2
3
4
5
6
7
8
9
10
// entry-client.js
// 客户端也需要创建vue实例
import { createApp } from './main';

const { app, router } = createApp()

router.onReady(() => {
// 挂载激活
app.$mount('#app')
})

webpack配置

通过一个环境变量来动态决定是从服务端打包运行还是客户端打包运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// vue.config.js
// 两个插件分别负责打包客户端和服务端
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin")
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin")

const nodeExternals = require("webpack-node-externals")
const merge = require("lodash.merge")

// 根据传入环境变量决定入口文件和相应配置项
const TARGET_NODE = process.env.WEBPACK_TARGET === "node"
const target = TARGET_NODE ? "server" : "client"

module.exports = {
css: {
extract: false
},
outputDir: './dist/'+target,
configureWebpack: () => ({
// 将 entry 指向应用程序的 server / client 文件
entry: `./src/entry-${target}.js`,
// 对 bundle renderer 提供 source map 支持
devtool: 'source-map',
// target设置为node使webpack以Node适用的方式处理动态导入,
// 并且还会在编译Vue组件时告知`vue-loader`输出面向服务器代码。
target: TARGET_NODE ? "node" : "web",
// 是否模拟node全局变量
node: TARGET_NODE ? undefined : false,
output: {
// 此处使用Node风格导出模块
libraryTarget: TARGET_NODE ? "commonjs2" : undefined
},
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的打包文件。
externals: TARGET_NODE
? nodeExternals({
// 不要外置化webpack需要处理的依赖模块。
// 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 还应该将修改`global`(例如polyfill)的依赖模块列入白名单
whitelist: [/\.css$/]
})
: undefined,
optimization: {
splitChunks: undefined
},
// 这是将服务器的整个输出构建为单个 JSON 文件的插件。
// 服务端默认文件名为 `vue-ssr-server-bundle.json`
// 客户端默认文件名为 `vue-ssr-client-manifest.json`。
plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
}),
chainWebpack: config => {
// cli4项目添加
if (TARGET_NODE) {
config.optimization.delete('splitChunks')
}

config.module
.rule("vue")
.use("vue-loader")
.tap(options => {
merge(options, {
optimizeSSR: false
})
})
}
}

脚本修改

当我们运行一个SSR项目时,我们其实需要同事启动客户端和服务端。

1
2
3
4
5
6
// package.json
"scripts": {
"build:client": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build",
"build": "npm run build:server && npm run build:client"
}

服务器启动文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// nodejs代码
// express是我们web服务器
const express = require('express')
const path = require('path')
const fs = require('fs')

// 获取express实例
const server = express()

// 获取绝对路由函数
function resolve(dir) {
// 把当前执行js文件绝对地址和传入dir做拼接
return path.resolve(__dirname, dir)
}


// 处理favicon
const favicon = require('serve-favicon')
server.use(favicon(path.join(__dirname, '../public', 'favicon.ico')))

// 第 1 步:开放dist/client目录,关闭默认下载index页的选项,不然到不了后面路由
// /index.html
server.use(express.static(resolve('../dist/client'), {index: false}))

// 第 2 步:获得一个createBundleRenderer
const { createBundleRenderer } = require("vue-server-renderer");

// 第 3 步:导入服务端打包文件
const bundle = require(resolve("../dist/server/vue-ssr-server-bundle.json"));

// 第 4 步:创建渲染器
const template = fs.readFileSync(resolve("../public/index.html"), "utf-8");
const clientManifest = require(resolve("../dist/client/vue-ssr-client-manifest.json"));
const renderer = createBundleRenderer(bundle, {
runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
template, // 宿主文件
clientManifest // 客户端清单
});


// 编写路由处理不同url请求
server.get('*', async (req, res) => {
// 构造上下文
const context = {
title: 'ssr test',
url: req.url // 首屏地址
}
// 渲染输出
try {
const html = await renderer.renderToString(context)
// 响应给前端
res.send(html)
} catch (error) {
res.status(500).send('服务器渲染出错')
}
})

// 监听端口
server.listen(3000, () => {
console.log('server running!');

})

改造vuex

和vue router一样,vuex也要改造成工厂函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export function createStore () {
return new Vuex.Store({
state: {
count:108
},
mutations: {
init (state, count) {
state.count = count;
},
add (state) {
state.count += 1;
}
},
actions: {
// 加一个异步请求count的action
getCount({ commit }) {
return new Promise(resolve => {
setTimeout(() => {
commit("init", Math.random() * 100);
resolve()
}, 1000)
})
}
}
})
}

针对异步获取的vuex数据,我们需要额外做些处理:

在需要获取异步数据的组件内添加:

1
2
3
4
5
6
export default {
asyncData({ store, route }) { // 约定预取逻辑编写在预取钩子asyncData中
// 触发 action 后,返回 Promise 以便确定请求结果
return store.dispatch("getCount");
}
}

服务端数据预取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// entry-server.js
import { createApp } from "./app";

export default context => {
return new Promise ((resolve, reject) => {
// 拿出store和router实例
const { app, router, store } = createApp(context);
router.push(context.url);
router.onReady(() => {
// 获取匹配的路由组件数组
const matchedComponents = router.getMatchedComponents();
// 若无匹配则抛出异常
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对所有匹配的路由组件调用可能存在的`asyncData()`
Promise.all(
matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute,
})
}
})
)
.then(() => {
// 所有预取钩子 resolve 后,
// store 已经填充入渲染应用所需状态
// 将状态附加到上下文,且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state;
resolve(app)
})
.catch(reject)
}, reject)
})
}

客户端上挂上数据:

1
2
3
4
5
6
7
8
9
// entry-client.js
// 导出store
const { app, router, store } = createApp()

// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态自动嵌入到最终的 HTML
// 在客户端挂载到应用程序之前,store 就应该获取到状态:
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}

在首屏渲染后,如果首屏不包含异步请求的vuex数据,在后续跳转其他页面时将会无法获得这些vuex,所以客户端在挂载到应用程序之前,store也应该获取到状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// main.js
Vue.mixin({
beforeMount() {
const { asyncData } = this.$options;
if (asyncData) {
// 将获取数据操作分配给 promise
// 以便在组件中,我们可以在数据准备就绪后
// 通过运行 `this.dataPromise.then(...)` 来执行其他任务
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})

为什么要使用SSR

之所以要引入SSR,是因为我们发现SPA存在这些缺点:

  • 较差的SEO:页面只有一个宿主html模板
  • 首屏的响应时间长

相比之下,SSR的优点就很明显了:

  • 更好的SEO:由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
  • 更快的内容到达时间 (time-to-content):特别是对于缓慢的网络情况或运行缓慢的设备。无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记。

但是SSR也存在着问题:

  • 开发条件所限:浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用,因为服务端没有mount挂载的操作,所以mount及以后的生命周期将无法使用
  • 涉及构建设置和部署的更多要求:与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
  • 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集)。

所以在我们选择是否使用SSR前,我们需要慎重问问自己这些问题:

  1. 需要SEO的页面是否只是少数几个,这些是否可以使用预渲染(Prerender SPA Plugin)实现
  2. 首屏的请求响应逻辑是否复杂,数据返回是否大量且缓慢