Vue服务端渲染业务入门实践

作者:威威(沪江前端开发工程师)

本文原创,转载请注明作者及出处。

背景

最近, 产品同学一如往常笑嘻嘻的递来需求文档, 纵使内心万般拒绝, 身体倒是很诚实。 接过需求,好在需求不复杂, 简单构思 后决定用Vue, 得心应手。 切好图, 挽起袖子准备撸代码的时候, SEO同学不知何时已经站到了背后。

"听说你要用Vue?"

"恩..."

"SEO考虑了吗?整个SPA出来,网页的SEO咋办?"

"奥..."

换以前, 估计只能无奈的换个实现方式, 但是Vue 2.0时代的到来, 给你多了一种可能。 你可以对SEO工程师说:用Vue没问题!

想必,很多前端同学都有类似这样的经历, 为了SEO,只能放弃得心应手的框架。 SEO(Search Engine Optimization)顾名思义就是一系列为了提高 网站收录排名,吸引精准用户的方案。 这么看来,SEO确实是有举足轻重的作用。 不过,好消息是,Vue2.0的发布为SEO提供了可能, 这就是SSR(serve side render)。

说起SSR,其实早在SPA (Single Page Application) 出现之前,网页就是在服务端渲染的。服务器接收到客户端请求后,将数据和模板拼接成完整的页面响应到客户端。 客户端直接渲染, 此时用户希望浏览新的页面,就必须重复这个过程, 刷新页面. 这种体验在Web技术发展的当下是几乎不能被接受的,于是越来越多的技术方案涌现,力求 实现无页面刷新或者局部刷新来达到优秀的交互体验。 比如Vue:

- 在客户端管理路由,用户切换路由,无需向服务器重新请求页面和静态资源,只需要使用 ajax 获取数据在客户端完成渲染,这样可以减少了很多不必要的网络传输,缩短了响应时间。

- 声明式渲染(告诉 vue 你要做什么,让它帮你做),把我们从烦人的DOM操作中解放出来,集中处理业务逻辑。

- 组件化视图,无论是功能组件还是UI组件都可以进行抽象,写一次到处用。

- 前后端并行开发,只需要与后端定好数据格式,前期用模拟数据,就可以与后端并行开发了。

- 对复杂项目的各个组件之间的数据传递 vue  - Vuex 状态管理模式

缺点大家自然猜到了, 对,主要的一点就是不利于SEO,或者说对SEO不友好。 来看下面两张图;

SPA页面的源代码

下图SSR页面的源代码

上面两张图就是使用了传统单页应用和SSR的页面源代码, 第一张图中,很明显页面的数据都是通过Ajax异步获取,然而搜索引擎度娘家的爬虫看到这样空旷的源码并不会丝毫留恋. 相反,通过服务端渲染的页面,就有很多对于爬虫来讲有效的连接. 毕竟度娘一家独大,看来服务端渲染确实有探究的必要了。

vue 的服务端渲染是怎么回事?

先看一张Vue官网的服务端渲染示意图

从图上可以看出,ssr 有两个入口文件,client.js 和 server.js, 都包含了应用代码,webpack 通过两个入口文件分别打包成给服务端用的 server bundle 和给客户端用的 client bundle. 当服务器接收到了来自客户端的请求之后,会创建一个渲染器 bundleRenderer,这个 bundleRenderer 会读取上面生成的 server bundle 文件,并且执行它的代码, 然后发送一个生成好的 html 到浏览器,等到客户端加载了 client bundle 之后,会和服务端生成的DOM 进行 Hydration(判断这个DOM 和自己即将生成的DOM 是否相同,如果相同就将客户端的vue实例挂载到这个DOM上, 否则会提示警告)。

怎么实现?

知道了Vue服务端渲染的大致流程,那怎么用代码来实现呢?

1. 创建一个 vue 实例

2. 配置路由,以及相应的视图组件

3. 使用 vuex 管理数据

4. 创建服务端入口文件

5. 创建客户端入口文件

6. 配置 webpack,分服务端打包配置和客户端打包配置

7. 创建服务器端的渲染器,将vue实例渲染成html

首先我们来创建一个 vue 实例

// app.js

importVue from'vue';

importrouter from'./router';

importstore from'./store';

importApp from'./components/app';

let app =newVue({

template:'',

base:'/c/',

components: {

    App

},

    router,

    store

});

export{

     app,

     router,

     store

}

和我们以前写的vue实例差别不大,但是我们不会在这里将app mount到DOM上,因为这个实例也会在服务端去运行,这里直接将 app 暴露出去。

配置 vue 路由

importVue from'vue';

importVueRouter from'vue-router';

importIndexView from'../views/indexView';

importArticleItems from'../views/articleItems';

Vue.use(VueRouter);

constrouter =newVueRouter({

mode:'history',

base:'/c/',

routes: [{

     path:'/:alias',

     component: IndexView

     }, {

     path:'/:alias/list',

     component: ArticleItems

    }]

});

注意这里的 base,在服务端传递 path 给 vue-router 的时候要注意去掉前面的 '/c/',否则会匹配不到。

创建视图组件,这里我们使用单文件组件,下面是 indexView.vue 文件的实例代码

importcourseCover from'../components/courseCover.vue';

importarticleItems from'../components/articleItems';

exportdefault{

computed: {

classData() {

returnthis.$store.state.courseListItems;

},

articleItems() {

returnthis.$store.state.articleItems;

}

},

components: {

courseCover,

articleItems

},

// 服务端获取数据

fetchServerData ({ state, dispatch, commit }) {

let alias = state.route.params.alias;

returnPromise.all([

dispatch('FETCH_ZT', { alias }),

dispatch('FETCH_COURSE_ITEMS'),

dispatch('FETCH_ARTICLE_ITEMS')

])

},

// 客户端获取数据

beforeMount() {

returnthis.$store.dispatch('FETCH_COURSE_ITEMS');

}

}

这里我们暴露一个 fetchServerData 方法用来在服务端渲染时做数据的预加载,具体在哪调用,下面会讲到。 beforeMount 是vue的生命周期钩子函数,当应用在客户端切换到这个视图的时候会在特定的时候去执行,用于在客户端获取数据。

使用 vuex 管理数据,vue2.0 的服务端官方推荐使用STORE来管理数据,和1.0相比 api 有一些调整

importVue from'vue';

importVuex from'vuex';

importaxios from'axios';

Vue.use(Vuex);

let apiHost ='http://localhost:3000';

conststore =newVuex.Store({

state: {

alias:'',

ztData: {},

courseListItems: [],

articleItems: []

},

actions: {

FETCH_ZT: ({ commit, dispatch, state }, { alias }) = {

commit('SET_ALIAS', { alias });

returnaxios.get(`${apiHost}/api/zt`)

.then(response => {

let data = response.data || {};

commit('SET_ZT_DATA', data);

})

},

FETCH_COURSE_ITEMS: ({ commit, dispatch, state }) => {

returnaxios.get(`${apiHost}/api/course_items`).then(response => {

let data = response.data;

commit('SET_COURSE_ITEMS', data);

});

},

FETCH_ARTICLE_ITEMS: ({ commit, dispatch, state }) => {

returnaxios.get(`${apiHost}/api/article_items`)

.then(response => {

let data = response.data;

commit('SET_ARTICLE_ITEMS', data);

})

}

},

mutations: {

SET_COURSE_ITEMS: (state, data) => {

state.courseListItems = data;

},

SET_ALIAS: (state, { alias }) => {

state.alias = alias;

},

SET_ZT_DATA: (state, { ztData }) => {

state.ztData = ztData;

},

SET_ARTICLE_ITEMS: (state, items) => {

state.articleItems = items;

}

}

})

export  default store;

state 使我们应用层的数据,相当于一个仓库,整个应用层的数据都存在这里,与不使用vuex的vue应用有两点不同:

-  Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。

-  Vuex 不允许我们直接对 store 中的数据进行操作。改变 store 中的状态的唯一途径就是显式地提交(commit) mutations。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。

action 响应在view上的用户输入导致的状态变化,并不直接操作数据,异步的逻辑都封装在这里执行,它最终的目的是提交 mutation 来操作数据。 mutation vuex 中修改store 数据的唯一方法,使用 commit 来提交。

创建服务端的入口文件 server-entry.js

// server-entry.js

import{app, router, store} from'./app';

exportdefaultcontext => {

consts = Date.now();

router.push(context.url);

constmatchedComponents = router.getMatchedComponents();

if(!matchedComponents) {

returnPromise.reject({ code:'404'});

}

returnPromise.all(

matchedComponents.map(component => {

if(component.fetchServerData) {

returncomponent.fetchServerData(store);

}

})

).then(() => {

context.initialState = store.state;

returnapp;

})

}

server.js 返回一个函数,该函数接受一个从服务端传递过来的 context 的参数,将 vue 实例通过 promise 返回。 context 一般包含 当前页面的url,首先我们调用 vue-router 的 router.push(url) 切换到到对应的路由, 然后调用 getMatchedComponents 方法返回对应要渲染的组件, 这里会检查组件是否有 fetchServerData 方法,如果有就会执行它。

下面这行代码将服务端获取到的数据挂载到 context 对象上,后面会把这些数据直接发送到浏览器端与客户端的vue 实例进行数据(状态)同步。

`context.initialState = store.state`

创建客户端入口文件 client-entry.js

// client-entry.js

import{ app, store } from'./app';

import'./main.scss';

store.replaceState(window.__INITIAL_STATE__);

app.$mount('#app');

客户端入口文件很简单,同步服务端发送过来的数据,然后把 vue 实例挂载到服务端渲染的 DOM 上。

配置 webpack

// webpack.server.config.js

constbase = require('./webpack.base.config');// webpack 的通用配置

module.exports = Object.assign({}, base, {

target:'node',

entry:'./src/server-entry.js',

output: {

filename:'server-bundle.js',

libraryTarget:'commonjs2'

},

externals: Object.keys(require('../package.json').dependencies),

plugins: [

newwebpack.DefinePlugin({

'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ||'development'),

'process.env.VUE_ENV':'"server"'

})

]

})

注意这里添加了 target: 'node' 和 libraryTarget: 'commonjs2',然后入口文件改成我们的 server-entry.js, 客户端的 webpack 和以前一样,这里就不贴了。

分别打包服务端代码和客户端代码

因为有两个 webpack 配置文件,执行 webpack 时候就需要指定 --config 参数来编译不同的 bundle。 我们可以配置两个 npm script

"packclient": "webpack --config webpack.client.config.js",

"packserver": "webpack --config webpack.server.config.js"

然后在命令行运行

npm run packclient

npm run packserver

就会生成两个文件 client-bundle.js 和 server-bundle.js

创建服务端渲染器

// controller.js

constserialize =require('serialize-javascript');

// 因为我们在vue-router 的配置里面使用了 `base: '/c'`,这里需要去掉请求path中的 '/c'

let url =this.url.replace(/\/c/,'');

let context = {url:this.url };

// 创建渲染器

let bundleRenderer = createRenderer(fs.readFileSync(resolve('./dist/server-bundle.js'),'utf-8'))

lethtml =yieldnewPromise((resolve, reject) =>{

// 将vue实例编译成一个字符串

bundleRenderer.renderToString(

context,// 传递context 给 server-bundle.js 使用

(err, html) => {

if(err) {

console.error('server render error', err);

resolve('');

}

/**

* 还记得在 server-entry.js 里面 `context.initialState = store.state` 这行代码么?

* 这里就直接把数据发送到浏览器端啦

**/

html +=`

// 将服务器获取到的数据作为首屏数据发送到浏览器

window.__INITIAL_STATE__ =${serialize(context.initialState, { isJSON:true})}

`;

resolve(html);

}

)

})

yieldthis.render('ssr', html);

// 创建渲染器函数

functioncreateRenderer(code){

returnrequire('vue-server-renderer').createBundleRenderer(code);

}

在 node 的 views 模板文件中只需要将上面的 html 输出就可以了

// ssr.html

{% extends'layout.html'%}

{% block body %}

{{ html | safe }}

{% endblock %}
<script src="/public/client.js">

这样,一个简单的服务端渲染就结束了,限于篇幅,详细的代码请参考Github代码库。

https://github.com/pangz1/vue-ssr

小结

整个demo包含了:

- vue + vue-router + vuex 的使用

- 服务端数据获取

- 客户端数据同步以及DOM hydration。

没有涉及:

- 流式渲染

- 组件缓存

对Vue的服务端渲染有更深一步的认识,实际在生产环境中的应用可能还需要考虑很多因素。

选择Vue的服务端渲染方案,是情理之中的选择,不是对新技术的盲目追捧,而是一切为了需要。 Vue 2.0的SSR方案只是提供了一种可能,多了一种选择,框架本身在于服务开发者,根据不同的场景选择不同的方案,才会事半功倍。

文章仅代表个人观点,有不妥当地方烦请大家指出,共同进步!


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容

  • 在实现 egg + vue 服务端渲染工程化实现之前,我们先来看看前面两篇关于Webpack构建和Egg的文章: ...
    hubcarl阅读 6,002评论 0 19
  • 一只纯洁的白鸽从天边的白昼/ 飞到了我的枕边/ 它在寻找金黄的麦子/ 有三万公里疾驰/ 才将那滴沙哑的眼泪锁住我/...
    孟章君阅读 660评论 0 0
  • 人物篇之郑板桥 世人皆拿郑板桥的难得糊涂慰藉意淫。 难得糊涂岂是真糊涂? 本来就在糊涂中何须再借糊涂语? 不解郑板...
    纵情嬉戏天地间阅读 306评论 0 1