Fork me on GitHub

带你五步学会Vue SSR

stack

前言

SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档。通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助。

我会通过五个步骤,一步步带你完成SSR的配置:

  1. 纯浏览器渲染
  2. 服务端渲染,不包含Ajax初始化数据
  3. 服务端渲染,包含Ajax初始化数据
  4. 服务端渲染,使用serverBundle和clientManifest进行优化
  5. 一个完整的基于Vue + VueRouter + Vuex的SSR工程

如果你现在对于我上面说的还不太了解,没有关系,跟着我一步步向下走,最终你也可以独立配置一个SSR开发项目,所有源码我会放到github上,大家可以作为参考

正文

1. 纯浏览器渲染

这个配置相信大家都会,就是基于weback + vue的一个常规开发配置,这里我会放一些关键代码,完整代码可以去github查看。

目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
- node_modules
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
app.js
1
2
3
4
5
6
7
import Vue from 'vue';
import App from './App.vue';

let app = new Vue({
el: '#app',
render: h => h(App)
});
App.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<Foo></Foo>
<Bar></Bar>
</div>
</template>

<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';

export default {
components: {
Foo, Bar
}
}
</script>
index.html
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>纯浏览器渲染</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
components/Foo.vue
1
2
3
4
5
6
7
8
9
10
11
<template>
<div class="foo">
<h1>Foo Component</h1>
</div>
</template>

<style>
.foo {
background: yellowgreen;
}
</style>
components/Bar.vue
1
2
3
4
5
6
7
8
9
10
11
<template>
<div class="bar">
<h1>Bar Component</h1>
</div>
</template>

<style>
.bar {
background: bisque;
}
</style>
webpack.config.js
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
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
mode: 'development',

entry: './app.js',

output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},

module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
// 如果需要单独抽出CSS文件,用下面这个配置
// use: ExtractTextPlugin.extract({
// fallback: 'vue-style-loader',
// use: [
// 'css-loader',
// 'postcss-loader'
// ]
// })
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},

plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './index.html'
}),
// 如果需要单独抽出CSS文件,用下面这个配置
// new ExtractTextPlugin("styles.css")
]
};
postcss.config.js
1
2
3
4
5
module.exports = {
plugins: [
require('autoprefixer')
]
};
.babelrc
1
2
3
4
5
6
7
8
9
{
"presets": [
"@babel/preset-env"
],
"plugins": [
// 让其支持动态路由的写法 const Foo = () => import('../components/Foo.vue')
"dynamic-import-webpack"
]
}
package.json
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
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build": "webpack"
},
"dependencies": {
"vue": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"babel-plugin-dynamic-import-webpack": "^1.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9"
}
}
命令
启动开发环境
1
yarn start
构建生产环境
1
yarn run build

最终效果截图:

纯浏览器渲染

完整代码查看github

2. 服务端渲染,不包含Ajax初始化数据

服务端渲染SSR,类似于同构,最终要让一份代码既可以在服务端运行,也可以在客户端运行。如果说在SSR的过程中出现问题,还可以回滚到纯浏览器渲染,保证用户正常看到页面。

那么,顺着这个思路,肯定就会有两个webpack的入口文件,一个用于浏览器端渲染weboack.client.config.js,一个用于服务端渲染webpack.server.config.js,将它们的公有部分抽出来作为webpack.base.cofig.js,后续通过webpack-merge进行合并。同时,也要有一个server来提供http服务,我这里用的是koa。

我们来看一下新的目录结构:

目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- node_modules
- config // 新增
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- entry-client.js // 新增
- entry-server.js // 新增
- index.html
- index.ssr.html // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。

所以,我们要对app.js做修改,将其包装为一个工厂函数,每次调用都会生成一个全新的根组件。

app.js

1
2
3
4
5
6
7
8
9
10
import Vue from 'vue';
import App from './App.vue';

export function createApp() {
const app = new Vue({
render: h => h(App)
});

return { app };
}

在浏览器端,我们直接新建一个根组件,然后将其挂载就可以了。

entry-client.js

1
2
3
4
5
import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');

在服务器端,我们就要返回一个函数,该函数的作用是接收一个context参数,同时每次都返回一个新的根组件。这个context在这里我们还不会用到,后续的步骤会用到它。

entry-server.js

1
2
3
4
5
6
7
import { createApp } from './app.js';

export default context => {
const { app } = createApp();

return app;
}

然后再来看一下index.ssr.html

index.ssr.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>服务端渲染</title>
</head>
<body>
<!--vue-ssr-outlet-->

<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
的作用是作为一个占位符,后续通过vue-server-renderer插件,将服务器解析出的组件html字符串插入到这里。

是为了将webpack通过webpack.client.config.js打包出的文件放到这里(这里是为了简单演示,后续会有别的办法来做这个事情)。

因为服务端吐出来的就是一个html字符串,后续的Vue相关的响应式、事件响应等等,都需要浏览器端来接管,所以就需要将为浏览器端渲染打包的文件在这里引入。

用官方的词来说,叫客户端激活(client-side hydration)

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

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

1
2
// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

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

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

1
<div id="app" data-server-rendered="true">

Vue在浏览器端就依靠这个属性将服务器吐出来的html进行激活,我们一会自己构建一下就可以看到了。

接下来我们看一下webpack相关的配置:

webpack.base.config.js

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
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
mode: 'development',

resolve: {
extensions: ['.js', '.vue']
},

output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].bundle.js'
},

module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
}
]
},

plugins: [
new VueLoaderPlugin()
]
};

webpack.client.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},

plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});

注意,这里的入口文件变成了entry-client.js,将其打包出的client.bundle.js插入到index.html中。

webpack.server.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
target: 'node',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});

这里有几个点需要注意一下:

  1. 入口文件是 entry-server.js
  2. 因为是打包服务器端依赖的代码,所以target要设为node,同时,output的libraryTarget要设为commonjs2

这里关于HtmlWebpackPlugin配置的意思是,不要在index.ssr.html中引入打包出的server.bundle.js,要引为浏览器打包的client.bundle.js,原因前面说过了,是为了让Vue可以将服务器吐出来的html进行激活,从而接管后续响应。

那么打包出的server.bundle.js在哪用呢?接着往下看就知道啦~~

package.json

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
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build:client": "webpack --config config/webpack.client.config.js",
"build:server": "webpack --config config/webpack.server.config.js"
},
"dependencies": {
"koa": "^2.5.3",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"vue": "^2.5.17",
"vue-server-renderer": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"style-loader": "^0.23.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9",
"webpack-merge": "^4.1.4"
}
}

接下来我们看server端关于http服务的代码:

server/server.js

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
const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

// 后端Server
backendRouter.get('/index', (ctx, next) => {
// 这里用 renderToString 的 promise 返回的 html 有问题,没有样式
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});
});

backendApp.use(serve(path.resolve(__dirname, '../dist')));

backendApp
.use(backendRouter.routes())
.use(backendRouter.allowedMethods());

backendApp.listen(3000, () => {
console.log('服务器端渲染地址: http://localhost:3000');
});


// 前端Server
frontendRouter.get('/index', (ctx, next) => {
let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
ctx.type = 'html';
ctx.status = 200;
ctx.body = html;
});

frontendApp.use(serve(path.resolve(__dirname, '../dist')));

frontendApp
.use(frontendRouter.routes())
.use(frontendRouter.allowedMethods());

frontendApp.listen(3001, () => {
console.log('浏览器端渲染地址: http://localhost:3001');
});

这里对两个端口进行监听,3000端口是服务端渲染,3001端口是直接输出index.html,然后会在浏览器端走Vue的那一套,主要是为了和服务端渲染做对比使用。

这里的关键代码是如何在服务端去输出html字符串。

1
2
3
4
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

可以看到,server.bundle.js在这里被使用啦,因为它的入口是一个函数,接收context作为参数(非必传),输出一个根组件app。

这里我们用到了vue-server-renderer插件,它有两个方法可以做渲染,一个是createRenderer,另一个是createBundleRenderer。

1
2
const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 选项 */ })
1
2
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })

createRenderer无法接收为服务端打包出的server.bundle.js文件,所以这里只能用createBundleRenderer。

serverBundle 参数可以是以下之一:

  • 绝对路径,指向一个已经构建好的 bundle 文件(.js 或 .json)。必须以 / 开头才会被识别为文件路径。
  • 由 webpack + vue-server-renderer/server-plugin 生成的 bundle 对象。
  • JavaScript 代码字符串(不推荐)。

这里我们引入的是.js文件,后续会介绍如何使用.json文件以及有什么好处。

1
2
3
4
5
6
7
8
9
10
11
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});

使用createRenderer和createBundleRenderer返回的renderer函数包含两个方法renderToString和renderToStream,我们这里用的是renderToString成功后直接返回一个完整的字符串,renderToStream返回的是一个Node流。

renderToString支持Promise,但是我在使用Prmoise形式的时候样式会渲染不出来,暂时还不知道原因,如果大家知道的话可以给我留言啊。

配置基本就完成了,来看一下如何运行。

1
2
3
4
yarn run build:client       // 打包浏览器端需要bundle
yarn run build:server // 打包SSR需要bundle

yarn start // 其实就是 node server/server.js,提供http服务

最终效果展示:

访问http://localhost:3000/index

SSR没有ajax数据

我们看到了前面提过的data-server-rendered=”true”属性,同时会加载client.bundle.js文件,为了让Vue在浏览器端做后续接管。

访问http://localhost:3001/index还和第一步实现的效果一样,纯浏览器渲染,这里就不放截图了。

完整代码查看github

3. 服务端渲染,包含Ajax初始化数据

如果SSR需要初始化一些异步数据,那么流程就会变得复杂一些。

我们先提出几个问题:

  1. 服务端拿异步数据的步骤在哪做?
  2. 如何确定哪些组件需要获取异步数据?
  3. 获取到异步数据之后要如何塞回到组件内?

    带着问题我们向下走,希望看完这篇文章的时候上面的问题你都找到了答案。

    服务器端渲染和浏览器端渲染组件经过的生命周期是有区别的,在服务器端,只会经历beforeCreate和created两个生命周期。因为SSR服务器直接吐出html字符串就好了,不会渲染DOM结构,所以不存在beforeMount和mounted的,也不会对其进行更新,所以也就不存在beforeUpdate和updated等。

    我们先来想一下,在纯浏览器渲染的Vue项目中,我们是怎么获取异步数据并渲染到组件中的?一般是在created或者mounted生命周期里发起异步请求,然后在成功回调里执行this.data = xxx,Vue监听到数据发生改变,走后面的Dom Diff,打patch,做DOM更新。

    那么服务端渲染可不可以也这么做呢?答案是不行的

  4. 在mounted里肯定不行,因为SSR都没有mounted生命周期,所以在这里肯定不行。

  5. 在beforeCreate里发起异步请求是否可以呢,也是不行的。因为请求是异步的,可能还没有等接口返回,服务端就已经把html字符串拼接出来了。

    所以,参考一下官方文档,我们可以得到以下思路:

  6. 在渲染前,要预先获取所有需要的异步数据,然后存到Vuex的store中。

  7. 在后端渲染时,通过Vuex将获取到的数据注入到相应组件中。
  8. 把store中的数据设置到window.INITIAL_STATE属性中。
  9. 在浏览器环境中,通过Vuex将window.INITIAL_STATE里面的数据注入到相应组件中。

    正常情况下,通过这几个步骤,服务端吐出来的html字符串相应组件的数据都是最新的,所以第4步并不会引起DOM更新,但如果出了某些问题,吐出来的html字符串没有相应数据,Vue也可以在浏览器端通过Vuex注入数据,进行DOM更新。

    更新后的目录结构:

目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- node_modules
- config
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- store // 新增
store.js
- App.vue
- app.js
- entry-client.js
- entry-server.js
- index.html
- index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

先来看一下store.js:

store/store.js

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
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const fetchBar = function() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('bar 组件返回 ajax 数据');
}, 1000);
});
};

function createStore() {
const store = new Vuex.Store({
state: {
bar: ''
},

mutations: {
'SET_BAR'(state, data) {
state.bar = data;
}
},

actions: {
fetchBar({ commit }) {
return fetchBar().then((data) => {
commit('SET_BAR', data);
}).catch((err) => {
console.error(err);
})
}
}
});

if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}

return store;
}

export default createStore;

typeof window

如果不太了解Vuex,可以去Vuex官网先看一些基本概念。

vuex

这里fetchBar可以看成是一个异步请求,这里用setTimeout模拟。在成功回调中commit相应的mutation进行状态修改。

这里有一段关键代码:

1
2
3
4
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}

因为store.js同样也会被打包到服务器运行的server.bundle.js中,所以运行环境不一定是浏览器,这里需要对window做判断,防止报错,同时如果有window.INITIAL_STATE属性,说明服务器已经把所有初始化需要的异步数据都获取完成了,要对store中的状态做一个替换,保证统一。

components/Bar.vue

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
<template>
<div class="bar">
<h1 @click="onHandleClick">Bar Component</h1>
<h2>异步Ajax数据:</h2>
<span>{{ msg }}</span>
</div>
</template>

<script>
const fetchInitialData = ({ store }) => {
store.dispatch('fetchBar');
};

export default {
asyncData: fetchInitialData,

methods: {
onHandleClick() {
alert('bar');
}
},

mounted() {
// 因为服务端渲染只有 beforeCreate 和 created 两个生命周期,不会走这里
// 所以把调用 Ajax 初始化数据也写在这里,是为了供单独浏览器渲染使用
let store = this.$store;
fetchInitialData({ store });
},

computed: {
msg() {
return this.$store.state.bar;
}
}
}
</script>

<style>
.bar {
background: bisque;
}
</style>

这里在Bar组件的默认导出对象中增加了一个方法asyncData,在该方法中会dispatch相应的action,进行异步数据获取。

需要注意的是,我在mounted中也写了获取数据的代码,这是为什么呢? 因为想要做到同构,代码单独在浏览器端运行,也应该是没有问题的,又由于服务器没有mounted生命周期,所以我写在这里就可以解决单独在浏览器环境使用也可以发起同样的异步请求去初始化数据。

components/Foo.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div class="foo">
<h1 @click="onHandleClick">Foo Component</h1>
</div>
</template>

<script>
export default {
methods: {
onHandleClick() {
alert('foo');
}
},
}
</script>

<style>
.foo {
background: yellowgreen;
}
</style>

这里我对两个组件都添加了一个点击事件,为的是证明在服务器吐出首页html后,后续的步骤都会被浏览器端的Vue接管,可以正常执行后面的操作。

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';

export function createApp() {
const store = createStore();

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

return { app, store, App };
}

在建立根组件的时候,要把Vuex的store传进去,同时要返回,后续会用到。

最后来看一下entry-server.js,关键步骤在这里:

entry-server.js

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
import { createApp } from './app.js';

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

let components = App.components;
let asyncDataPromiseFns = [];

Object.values(components).forEach(component => {
if (component.asyncData) {
asyncDataPromiseFns.push(component.asyncData({ store }));
}
});

Promise.all(asyncDataPromiseFns).then((result) => {
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
context.state = store.state;

console.log(222);
console.log(store.state);
console.log(context.state);
console.log(context);

resolve(app);
}, reject);
});
}

我们通过导出的App拿到了所有它下面的components,然后遍历,找出哪些component有asyncData方法,有的话调用并传入store,该方法会返回一个Promise,我们使用Promise.all等所有的异步方法都成功返回,才resolve(app)。

context.state = store.state作用是,当使用createBundleRenderer时,如果设置了template选项,那么会把context.state的值作为window.INITIAL_STATE自动插入到模板html中。

这里需要大家多思考一下,弄清楚整个服务端渲染的逻辑。

如何运行:

1
2
3
4
yarn run build:client
yarn run build:server

yarn start

最终效果截图:

服务端渲染:打开http://localhost:3000/index

server-remder-ajax

可以看到window.INITIAL_STATE被自动插入了。

我们来对比一下SSR到底对加载性能有什么影响吧。

服务端渲染时performance截图

sercer_render_ajax_performance

纯浏览器端渲染时performance截图

client_render_ajax_performance

同样都是在fast 3G网络模式下,纯浏览器端渲染首屏加载花费时间2.9s,因为client.js加载就花费了2.27s,因为没有client.js就没有Vue,也就没有后面的东西了。

服务端渲染首屏时间花费0.8s,虽然client.js加载扔花费2.27s,但是首屏已经不需要它了,它是为了让Vue在浏览器端进行后续接管。

从这我们可以真正的看到,服务端渲染对于提升首屏的响应速度是很有作用的。

当然有的同学可能会问,在服务端渲染获取初始ajax数据时,我们还延时了1s,在这个时间用户也是看不到页面的。没错,接口的时间我们无法避免,就算是纯浏览器渲染,首页该调接口还是得调,如果接口响应慢,那么纯浏览器渲染看到完整页面的时间会更慢。

完整代码查看github

4. 使用serverBundle和clientManifest进行优化

前面我们创建服务端renderer的方法是:

1
2
3
4
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

serverBundle我们用的是打包出的server.bundle.js文件。这样做的话,在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: ‘source-map’)
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

preload和prefetch有不了解的话可以自行查一下它们的作用哈。

那么我们来修改webpack配置:

webpack.client.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},

plugins: [
new VueSSRClientPlugin(), // 新增
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});

webpack.server.config.js

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
const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
target: 'node',
// 对 bundle renderer 提供 source map 支持
devtool: '#source-map',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
externals: [nodeExternals()], // 新增
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new VueSSRServerPlugin(), // 这个要放到第一个写,否则 CopyWebpackPlugin 不起作用,原因还没查清楚
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});

因为是服务端引用模块,所以不需要打包node_modules中的依赖,直接在代码中require引用就好,所以配置externals: [nodeExternals()]。

两个配置文件会分别生成vue-ssr-client-manifest.json和vue-ssr-server-bundle.json。作为createBundleRenderer的参数。

来看server.js

server.js

1
2
3
4
5
6
7
8
9
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');

const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest
});

效果和第三步就是一样的啦,就不截图了,完整代码查看github

5. 配置一个完整的基于Vue + VueRouter + Vuex的SSR

这里和第四步不一样的是引入了vue-router,更接近于实际开发项目。

在src下新增router目录。

router/index.js

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
import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';

Vue.use(Router);

function createRouter() {
const routes = [
{
path: '/bar',
component: Bar
},
{
path: '/foo',
component: () => import('../components/Foo.vue') // 异步路由
}
];

const router = new Router({
mode: 'history',
routes
});

return router;
}

export default createRouter;

这里我们把Foo组件作为一个异步组件引入,做成按需加载。

在app.js中引入router,并导出:

app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';

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

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

return { app, store, router, App };
}

修改App.vue引入路由组件:

App.vue

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
<template>
<div id="app">
<router-link to="/bar">Goto Bar</router-link>
<router-link to="/foo">Goto Foo</router-link>
<router-view></router-view>
</div>
</template>

<script>
export default {
beforeCreate() {
console.log('App.vue beforeCreate');
},

created() {
console.log('App.vue created');
},

beforeMount() {
console.log('App.vue beforeMount');
},

mounted() {
console.log('App.vue mounted');
}
}
</script>

最重要的修改在entry-server.js中,

entry-server.js

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
import { createApp } from './app.js';

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

router.push(context.url);

router.onReady(() => {
const matchedComponents = router.getMatchedComponents();

console.log(context.url)
console.log(matchedComponents)

if (!matchedComponents.length) {
return reject({ code: 404 });
}

Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData({ store });
}
})).then(() => {
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
context.state = store.state;

// 返回根组件
resolve(app);
});
}, reject);
});
}

这里前面提到的context就起了大作用,它将用户访问的url地址传进来,供vue-router使用。因为有异步组件,所以在router.onReady的成功回调中,去找该url路由所匹配到的组件,获取异步数据那一套还和前面的一样。

于是,我们就完成了一个基本完整的基于Vue + VueRouter + VuexSSR配置,完成代码查看github

最终效果演示:

访问http://localhost:3000/bar:

ssr-router

完整代码查看github

后续

上面我们通过五个步骤,完成了从纯浏览器渲染到完整服务端渲染的同构,代码既可以运行在浏览器端,也可以运行在服务器端。那么,回过头来我们在看一下是否有优化的空间,又或者有哪些扩展的思考。

1. 优化
  • 我们目前是使用renderToString方法,完全生成html后,才会向客户端返回,如果使用renderToStream,应用bigpipe技术可以向浏览器持续不断的返回一个流,那么文件的加载浏览器可以尽早的显示一些东西出来。
    1
    const stream = renderer.renderToStream(context)

返回的值是 Node.js stream:

1
2
3
4
5
6
7
8
9
10
11
12
13
let html = ''

stream.on('data', data => {
html += data.toString()
})

stream.on('end', () => {
console.log(html) // 渲染完成
})

stream.on('error', err => {
// handle error...
})

在流式渲染模式下,当 renderer 遍历虚拟 DOM 树(virtual DOM tree)时,会尽快发送数据。这意味着我们可以尽快获得”第一个 chunk”,并开始更快地将其发送给客户端。

然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文(render context),当流(stream)启动时,这些数据将不可用。这是因为,大量上下文信息(context information)(如头信息(head information)或内联关键 CSS(inline critical CSS))需要在应用程序标记(markup)之前出现,我们基本上必须等待流(stream)完成后,才能开始使用这些上下文数据。

因此,如果你依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。

  • webpack优化

webpack优化又是一个大的话题了,这里不展开讨论,感兴趣的同学可以自行查找一些资料,后续我也可能会专门写一篇文章来讲webpack优化。

2. 思考
  • 是否必须使用vuex?

答案是不用。Vuex只是为了帮助你实现一套数据存储、更新、获取的机制,入股你不用Vuex,那么你就必须自己想一套方案可以将异步获取到的数据存起来,并且在适当的时机将它注入到组件内,有一些文章提出了一些方案,我会放到参考文章里,大家可以阅读一下。

  • 是否使用SSR就一定好?

这个也是不一定的,任何技术都有使用场景。SSR可以帮助你提升首页加载速度,优化搜索引擎SEO,但同时由于它需要在node中渲染整套Vue的模板,会占用服务器负载,同时只会执行beforeCreate和created两个生命周期,对于一些外部扩展库需要做一定处理才可以在SSR中运行等等。

结语

本文通过五个步骤,从纯浏览器端渲染开始,到配置一个完整的基于Vue + vue-router + Vuex的SSR环境,介绍了很多新的概念,也许你看完一遍不太理解,那么结合着源码,去自己手敲几遍,然后再来看几遍文章,相信你一定可以掌握SSR。

最后,本文所有源代码都放在我的github上,如果对你有帮助的话,就来点一个赞吧~~

参考链接