关于 Vue SSR(即Vue 的服务端渲染)大家应该都是有所耳闻,当然了,已经应用到项目中的也不在少数。官网也提供了一个大而全的demo,全家桶都用上了。但对于新手来说,会相对有点难度。本篇博客从一个最基本的 Vue SSR demo开始,通过逐步引入vue-router, vuex,用来学习 Vue SSR 相关的知识(node服务采用的是 egg,其实用express,用什么都可以。为什么用 egg, 不用nuxt, 此处埋点伏笔,O(∩_∩)O哈哈~)。
基本使用
与 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
| const Vue = require('vue'); const app = new Vue({ template: `<div>Hello World</div>` });
const renderer = require('vue-server-renderer').createRenderer();
renderer.renderToString(app, (err, html) => { if (err) { throw err; } console.log(html); })
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"> {{{meta}}} <title>{{title}}</title> </head> <body> </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"> <meta name="description" content="Vue.js 服务端渲染指南"> <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');
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"> <meta name="description" content="Vue.js 服务端渲染指南"> <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() { const router = createRouter();
const app = new 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 => { return new Promise((resolve, reject) => {
const { app, router } = createApp(); router.push(context.url); router.onReady(() => { const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { return reject({ code: 404 }) } 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');
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 服务端渲染指南">', }; 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) { const router = createRouter(); sync(store, router); const app = new 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 => { 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(Component => { if(Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute, }); } })).then(() => { 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(() => { 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'); }, 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, 并未对于性能这块进行优化,大家可以慢慢在项目使用中不断踩坑,不断积累。