Vue SSR初探

关于 Vue SSR(即Vue 的服务端渲染)大家应该都是有所耳闻,当然了,已经应用到项目中的也不在少数。官网也提供了一个大而全的demo,全家桶都用上了。但对于新手来说,会相对有点难度。本篇博客从一个最基本的 Vue SSR demo开始,通过逐步引入vue-router, vuex,用来学习 Vue SSR 相关的知识(node服务采用的是 egg,其实用express,用什么都可以。为什么用 egg, 不用nuxt, 此处埋点伏笔,O(∩_∩)O哈哈~)。

基本使用

  • 渲染 Vue 实例

与 Vue SSR 官网一致,先体验下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 创建一个 Vue 实例
const Vue = require('vue');
const app = new Vue({
template: `<div>Hello World</div>`
});

// 创建一个renderer
const renderer = require('vue-server-renderer').createRenderer();

// 将 Vue 渲染为 HTML
renderer.renderToString(app, (err, html) => {
if (err) {
throw err;
}
console.log(html);
})

// 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer.renderToString(app).then(html => {
console.log(html)
}).catch(err => {
console.error(err)
})
  • 与服务器集成

如开头所述,我们采用的是 egg。egg 项目创建就不再赘述,可参考 egg 官网

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use strict';
const Controller = require('egg').Controller;
const Vue = require('vue');
const renderer = require('vue-server-renderer').createRenderer();

class HomeController extends Controller {
async index() {
const { ctx } = this;
const app = new Vue({
template: '<div>Hello World</div>',
});

renderer.renderToString(app).then(html => {
console.log(html);
ctx.body = html;
}).catch(err => {
console.error(err);
});
}
}

module.exports = HomeController;

控制台的log为:

1
<div data-server-rendered="true">Hello World</div>
  • 使用模板&模板插值

html模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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">
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
{{{meta}}}
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>{{title}}</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>

Node 服务端代码:

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
'use strict';
const path = require('path');
const Controller = require('egg').Controller;
const Vue = require('vue');
const fs = require('fs');
const renderer = require('vue-server-renderer').createRenderer({
template: require('fs').readFileSync(path.resolve(__dirname, '../public/template.html'), 'utf-8'),
});

class HomeController extends Controller {
async index() {
const { ctx } = this;
const app = new Vue({
template: '<div>Hello World</div>',
});
const context = {
title: 'vue-ssr-demo',
meta: '<meta name="description" content="Vue.js 服务端渲染指南">',
};

renderer.renderToString(app, context).then(html => {
console.log(html);
ctx.body = html;
}).catch(err => {
console.error(err);
});
}

module.exports = HomeController;

渲染的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!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">
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
<meta name="description" content="Vue.js 服务端渲染指南">
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>vue-ssr-demo</title>
</head>
<body>
<div data-server-rendered="true">Hello World</div>
</body>
</html>

实战

构建步骤

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

项目目录结构

此处非标准项目结构,仅为演示使用(所以这个结构一定是不规范的,😂)。代码地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
├── controller
│   └── home.js
├── router.js
└── src
├── App.vue
├── app.js
├── components
│   └── Header.vue
├── entry-client.js
├── entry-server.js
├── index.html
├── index.ssr.html
├── webpack.base.conf.js
├── webpack.client.conf.js
└── webpack.server.conf.js

app.js

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

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

module.exports = createApp;

entry-client.js

客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中:

1
2
3
4
5
6
'use strict';
const createApp = require('./app.js');

const app = createApp();
app.$mount('#app');

entry-server.js

服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。

1
2
3
4
5
'use strict';

const createApp = require('./app.js');
module.exports = createApp;

服务端代码

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
'use strict';
const path = require('path');
const Controller = require('egg').Controller;
const Vue = require('vue');
const fs = require('fs');

const bundle = fs.readFileSync(path.resolve(__dirname, '../public/server.js'), 'utf-8');

// 注意:此处的renderer为 createBundleRenderer 方法的返回对象
// bundleRenderer.renderToString([context, callback]): ?Promise<string>
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
runInNewContext: false, // 推荐
template: fs.readFileSync(path.resolve(__dirname, '../public/index.ssr.html'), 'utf-8'),
});

class HomeController extends Controller {
async index() {
const { ctx } = this;
const app = new Vue({
data: {
title: 'Vue ssr',
meta: '<meta name="description" content="Vue.js 服务端渲染指南">',
},
});
renderer.renderToString(app).then(html => {
console.log(html);
ctx.body = html;
}).catch(err => {
console.error(err);
ctx.status = 500;
});

}
async web() {
const { ctx } = this;
ctx.body = fs.readFileSync(path.join(__dirname, '../public/index.html'), 'utf-8');
}
}

module.exports = HomeController;

最后渲染到浏览器的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!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">
<!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
<meta name="description" content="Vue.js 服务端渲染指南">
<!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
<title>Vue ssr</title>
</head>
<body>
<div>
<div id="app" data-server-rendered="true"><header><h1>Vue SSR</h1></header> <h1>Hello Vue SSR</h1></div>
</div>
<script type="text/javascript" src="/public/client.js"></script>
</body>
</html>

加入路由

现在将vue-router 引入我们的简单应用。创建一个 router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

'use strict';
import Vue from 'vue';
import Router from 'vue-router';

Vue.use(Router);
// 异步组件引入,实现代码分割
export function createRouter() {
return new Router({
mode: 'history',
routes: [
{
path: '/',
component: () => import('./views/home.vue'),
},
{
path: '/about',
component: () => import('./views/about.vue'),
}
]
});
}

然后在app.js中引入router

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
'use strict';

import Vue from 'vue';
import App from './App.vue';

import { createRouter } from './router';

export function createApp() {
// 创建router实例
const router = createRouter();

const app = new Vue({
// 注入 router 到 根 Vue实例
router,
render: h => h(App),
});
return { app, router };
}

服务端入口 entry-server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use strict';

const { createApp } = require('./app.js');
export default context => {
// 通过返回一个 Promise,服务器等待函数和组件所有内容准备完毕后渲染
return new Promise((resolve, reject) => {

const { app, router } = createApp();
router.push(context.url);
// router 等待异步组件和钩子函数解析完毕后
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Promise 应该 resolve 应用程序实例,以便它可以渲染
resolve(app);
}, reject);
})
}

客户端入口 entry-client.js

1
2
3
4
5
6
7
'use strict';
const { createApp } = require('./app.js');
const { app, router } = createApp();

router.onReady(() => {
app.$mount('#app');
});

服务端代码 home.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
60
61
62
'use strict';
const path = require('path');
const Controller = require('egg').Controller;
const fs = require('fs');

const bundle = require('../public/vue-ssr-server-bundle.json');

// 注意:此处的renderer为 createBundleRenderer 方法的返回对象
// bundleRenderer.renderToString([context, callback]): ?Promise<string>
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
runInNewContext: false, // 推荐
template: fs.readFileSync(path.resolve(__dirname, '../public/index.html'), 'utf-8'),
});

class HomeController extends Controller {
async index() {
const { ctx } = this;
const context = {
url: ctx.request.url,
title: 'Vue SSR',
meta: '<meta name="description" content="Vue.js 服务端渲染指南">',
};
// return new Promise((resolve, reject) => {
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
if (err.code === 404) {
ctx.status = 404;
}
ctx.status = 500;
return;
}
ctx.body = html;
});

}
async web() {
const { ctx } = this;
ctx.body = fs.readFileSync(path.join(__dirname, '../public/index.html'), 'utf-8');
}
async about() {
const { ctx } = this;
console.log('about');
const context = {
url: ctx.request.url,
title: 'Vue SSR',
meta: '<meta name="description" content="Vue.js 服务端渲染指南">',
};
renderer.renderToString(context, (err, html) => {
if (err) {
console.log(err);
if (err.code === 404) {
ctx.status = 404;
}
ctx.status = 500;
}
ctx.body = html;
});
}
}

module.exports = HomeController;

引入 vuex

现在我们继续引入 vuex。我们都知道 vuex 是专门用来管理 vue 应用的状态的。

Vue SSR 的本质是,渲染应用程序的‘快照’,所以我们需要将应用程序依赖的一些异步数据,在渲染之前先预取和解析。

现在我们将数据放置到 Vue 程序的 store中。

store相关的文件参见项目 store 文件

1
2
3
4
5
├── actions.js
├── getters.js
├── index.js
├── mutations.js
└── types.js

接下来修改 app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
'use strict';

import Vue from 'vue';
import App from './App.vue';

import { createRouter } from './router';
import store from './store';
import { sync } from 'vuex-router-sync';

export function createApp(ssrContext) {
// 创建router实例
const router = createRouter();
sync(store, router);
const app = new Vue({
// 注入 router 到 根 Vue实例
router,
store,
render: h => h(App),
});
return { app, router, store };
}

服务端入口 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
33
34
35
36
37
38
39
40
41
'use strict';

import { createApp } from './app';
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
// 设置服务端 router 位置
router.push(context.url);

// 等到router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
// 匹配不到路由,执行 reject 函数,并返回404
return reject({ code: 404 })
}

// Promise 应该 resolve 应用程序实例,以便它可以渲染
// 对所有匹配的路由组件调用 asyncData
Promise.all(matchedComponents.map(Component => {
if(Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute,
});
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// store 现在已经填充入渲染应用程序所需的状态。
// 将状态附加到上下文,并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state;
resolve(app);
}).catch(reject);
}, reject);
});
}

客户端入口 entry-client.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
'use strict';
import Vue from 'vue';
const { createApp } = require('./app.js');

Vue.mixin({
beforeRouteUpdate(to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to,
}).then(() => {
next();
}).catch(next)
} else {
next();
}
}
})

const { app, router, store } = createApp();

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));
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _);
if (!asyncDataHooks.length) {
return next();
}
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
next();
})
.catch(next);
})
app.$mount('#app');
});

然后在我们的组件中使用 Home.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
<template>
<div id="home-container">
<h1>HOME</h1>
<h2>{{count}}</h2>
<button @click='addCount'>点击增加</button>
</div>
</template>

<script>
import {mapState} from 'vuex';
export default {
asyncData({ store }) {
console.log('action asyncData');
return store.dispatch('setInitCount');
},
// 如果通过mapState方式,取值,始终是拿到的count为0,并不是 asyncData执行后的值
// 有点诡异
// computed: mapState(['count']),
computed: {
count() {
return this.$store.state.count;
}
},
methods: {
addCount() {
console.log(this.$store);
this.$store.dispatch('increment');
}
},
};

</script>

然后我们就可以跑这个简单的集成了vue,vue-router,vuex的 ssr demo了。

Egg + Vue 服务端渲染

在文章开始,说过了,我们为什么采用的 egg 作为服务端的应用的框架,而不是 Nuxt 呢。是因为目前我们在使用 egg 在做 node 服务开发,对于企业级的开发来讲,这种约定优于配置的开发原则,使得项目结构及其规范。同时,阿里的同学实现了一套基于webpack 的前端工程化方案: easywebpack,提供了Vue/Weex/React应用的一系列样板项目。
比如创建服务端渲染的 Vue 单页面应用,项目 app 结构为:

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
├── controller
│   └── app.js
├── middleware
│   └── access.js
├── mocks
│   └── article
│   └── list.js
├── router.js
└── web
├── asset
│   ├── css
│   │   └── bootstrap.min.css
│   └── images
│   ├── favicon.ico
│   ├── loading.gif
│   └── logo.png
├── component
│   └── layout
│   └── app
│   ├── app.css
│   ├── content
│   │   ├── content.css
│   │   └── content.vue
│   ├── footer
│   │   ├── footer.css
│   │   └── footer.vue
│   ├── header
│   │   ├── header.css
│   │   └── header.vue
│   ├── index.vue
│   └── main.vue
├── framework
│   ├── app.js
│   ├── utils
│   │   └── utils.js
│   └── vue
│   ├── directive
│   │   └── index.js
│   └── filter
│   └── index.js
├── page
│   └── app
│   ├── index.js
│   ├── index.vue
│   ├── router
│   │   ├── detail.vue
│   │   ├── index.js
│   │   └── list.vue
│   └── store
│   ├── actions.js
│   ├── getters.js
│   ├── index.js
│   ├── mutation-type.js
│   └── mutations.js
└── view
└── layout.html

可以使方便我们快速创建项目,并进行开发。当然,如果你想从零创建一个完整的 Vue SSR项目,也可以类似本篇博客这样,一点点的搭自己的模板。当然,本篇文章着重于实现简单的Vue SSR, 并未对于性能这块进行优化,大家可以慢慢在项目使用中不断踩坑,不断积累。