Vue服務(wù)端渲染業(yè)務(wù)入門實踐

作者:威威(滬江前端開發(fā)工程師)

本文原創(chuàng),轉(zhuǎn)載請注明作者及出處。

背景

最近, 產(chǎn)品同學(xué)一如往常笑嘻嘻的遞來需求文檔, 縱使內(nèi)心萬般拒絕, 身體倒是很誠實。 接過需求,好在需求不復(fù)雜, 簡單構(gòu)思 后決定用Vue, 得心應(yīng)手。 切好圖, 挽起袖子準(zhǔn)備擼代碼的時候, SEO同學(xué)不知何時已經(jīng)站到了背后。

"聽說你要用Vue?"

"恩..."

"SEO考慮了嗎?整個SPA出來,網(wǎng)頁的SEO咋辦?"

"奧..."

換以前, 估計只能無奈的換個實現(xiàn)方式, 但是Vue 2.0時代的到來, 給你多了一種可能。 你可以對SEO工程師說:用Vue沒問題!

想必,很多前端同學(xué)都有類似這樣的經(jīng)歷, 為了SEO,只能放棄得心應(yīng)手的框架。 SEO(Search Engine Optimization)顧名思義就是一系列為了提高 網(wǎng)站收錄排名,吸引精準(zhǔn)用戶的方案。 這么看來,SEO確實是有舉足輕重的作用。 不過,好消息是,Vue2.0的發(fā)布為SEO提供了可能, 這就是SSR(serve side render)。

說起SSR,其實早在SPA (Single Page Application) 出現(xiàn)之前,網(wǎng)頁就是在服務(wù)端渲染的。服務(wù)器接收到客戶端請求后,將數(shù)據(jù)和模板拼接成完整的頁面響應(yīng)到客戶端。 客戶端直接渲染, 此時用戶希望瀏覽新的頁面,就必須重復(fù)這個過程, 刷新頁面. 這種體驗在Web技術(shù)發(fā)展的當(dāng)下是幾乎不能被接受的,于是越來越多的技術(shù)方案涌現(xiàn),力求 實現(xiàn)無頁面刷新或者局部刷新來達到優(yōu)秀的交互體驗。 比如Vue:

- 在客戶端管理路由,用戶切換路由,無需向服務(wù)器重新請求頁面和靜態(tài)資源,只需要使用 ajax 獲取數(shù)據(jù)在客戶端完成渲染,這樣可以減少了很多不必要的網(wǎng)絡(luò)傳輸,縮短了響應(yīng)時間。

- 聲明式渲染(告訴 vue 你要做什么,讓它幫你做),把我們從煩人的DOM操作中解放出來,集中處理業(yè)務(wù)邏輯。

- 組件化視圖,無論是功能組件還是UI組件都可以進行抽象,寫一次到處用。

- 前后端并行開發(fā),只需要與后端定好數(shù)據(jù)格式,前期用模擬數(shù)據(jù),就可以與后端并行開發(fā)了。

- 對復(fù)雜項目的各個組件之間的數(shù)據(jù)傳遞 vue ?- Vuex 狀態(tài)管理模式

缺點大家自然猜到了, 對,主要的一點就是不利于SEO,或者說對SEO不友好。 來看下面兩張圖;

SPA頁面的源代碼

下圖SSR頁面的源代碼

上面兩張圖就是使用了傳統(tǒng)單頁應(yīng)用和SSR的頁面源代碼, 第一張圖中,很明顯頁面的數(shù)據(jù)都是通過Ajax異步獲取,然而搜索引擎度娘家的爬蟲看到這樣空曠的源碼并不會絲毫留戀. 相反,通過服務(wù)端渲染的頁面,就有很多對于爬蟲來講有效的連接. 畢竟度娘一家獨大,看來服務(wù)端渲染確實有探究的必要了。

vue 的服務(wù)端渲染是怎么回事?

先看一張Vue官網(wǎng)的服務(wù)端渲染示意圖

從圖上可以看出,ssr 有兩個入口文件,client.js 和 server.js, 都包含了應(yīng)用代碼,webpack 通過兩個入口文件分別打包成給服務(wù)端用的 server bundle 和給客戶端用的 client bundle. 當(dāng)服務(wù)器接收到了來自客戶端的請求之后,會創(chuàng)建一個渲染器 bundleRenderer,這個 bundleRenderer 會讀取上面生成的 server bundle 文件,并且執(zhí)行它的代碼, 然后發(fā)送一個生成好的 html 到瀏覽器,等到客戶端加載了 client bundle 之后,會和服務(wù)端生成的DOM 進行 Hydration(判斷這個DOM 和自己即將生成的DOM 是否相同,如果相同就將客戶端的vue實例掛載到這個DOM上, 否則會提示警告)。

怎么實現(xiàn)?

知道了Vue服務(wù)端渲染的大致流程,那怎么用代碼來實現(xiàn)呢?

1. 創(chuàng)建一個 vue 實例

2. 配置路由,以及相應(yīng)的視圖組件

3. 使用 vuex 管理數(shù)據(jù)

4. 創(chuàng)建服務(wù)端入口文件

5. 創(chuàng)建客戶端入口文件

6. 配置 webpack,分服務(wù)端打包配置和客戶端打包配置

7. 創(chuàng)建服務(wù)器端的渲染器,將vue實例渲染成html

首先我們來創(chuàng)建一個 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上,因為這個實例也會在服務(wù)端去運行,這里直接將 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,在服務(wù)端傳遞 path 給 vue-router 的時候要注意去掉前面的 '/c/',否則會匹配不到。

創(chuàng)建視圖組件,這里我們使用單文件組件,下面是 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

},

// 服務(wù)端獲取數(shù)據(jù)

fetchServerData ({ state, dispatch, commit }) {

let alias = state.route.params.alias;

returnPromise.all([

dispatch('FETCH_ZT', { alias }),

dispatch('FETCH_COURSE_ITEMS'),

dispatch('FETCH_ARTICLE_ITEMS')

])

},

// 客戶端獲取數(shù)據(jù)

beforeMount() {

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

}

}

這里我們暴露一個 fetchServerData 方法用來在服務(wù)端渲染時做數(shù)據(jù)的預(yù)加載,具體在哪調(diào)用,下面會講到。 beforeMount 是vue的生命周期鉤子函數(shù),當(dāng)應(yīng)用在客戶端切換到這個視圖的時候會在特定的時候去執(zhí)行,用于在客戶端獲取數(shù)據(jù)。

使用 vuex 管理數(shù)據(jù),vue2.0 的服務(wù)端官方推薦使用STORE來管理數(shù)據(jù),和1.0相比 api 有一些調(diào)整

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 使我們應(yīng)用層的數(shù)據(jù),相當(dāng)于一個倉庫,整個應(yīng)用層的數(shù)據(jù)都存在這里,與不使用vuex的vue應(yīng)用有兩點不同:

- ?Vuex 的狀態(tài)存儲是響應(yīng)式的。當(dāng) Vue 組件從 store 中讀取狀態(tài)的時候,若 store 中的狀態(tài)發(fā)生變化,那么相應(yīng)的組件也會相應(yīng)地得到高效更新。

- ?Vuex 不允許我們直接對 store 中的數(shù)據(jù)進行操作。改變 store 中的狀態(tài)的唯一途徑就是顯式地提交(commit) mutations。這樣使得我們可以方便地跟蹤每一個狀態(tài)的變化,從而讓我們能夠?qū)崿F(xiàn)一些工具幫助我們更好地了解我們的應(yīng)用。

action 響應(yīng)在view上的用戶輸入導(dǎo)致的狀態(tài)變化,并不直接操作數(shù)據(jù),異步的邏輯都封裝在這里執(zhí)行,它最終的目的是提交 mutation 來操作數(shù)據(jù)。 mutation vuex 中修改store 數(shù)據(jù)的唯一方法,使用 commit 來提交。

創(chuàng)建服務(wù)端的入口文件 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 返回一個函數(shù),該函數(shù)接受一個從服務(wù)端傳遞過來的 context 的參數(shù),將 vue 實例通過 promise 返回。 context 一般包含 當(dāng)前頁面的url,首先我們調(diào)用 vue-router 的 router.push(url) 切換到到對應(yīng)的路由, 然后調(diào)用 getMatchedComponents 方法返回對應(yīng)要渲染的組件, 這里會檢查組件是否有 fetchServerData 方法,如果有就會執(zhí)行它。

下面這行代碼將服務(wù)端獲取到的數(shù)據(jù)掛載到 context 對象上,后面會把這些數(shù)據(jù)直接發(fā)送到瀏覽器端與客戶端的vue 實例進行數(shù)據(jù)(狀態(tài))同步。

`context.initialState = store.state`

創(chuàng)建客戶端入口文件 client-entry.js

// client-entry.js

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

import'./main.scss';

store.replaceState(window.__INITIAL_STATE__);

app.$mount('#app');

客戶端入口文件很簡單,同步服務(wù)端發(fā)送過來的數(shù)據(jù),然后把 vue 實例掛載到服務(wù)端渲染的 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 和以前一樣,這里就不貼了。

分別打包服務(wù)端代碼和客戶端代碼

因為有兩個 webpack 配置文件,執(zhí)行 webpack 時候就需要指定 --config 參數(shù)來編譯不同的 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

創(chuàng)建服務(wù)端渲染器

// controller.js

constserialize =require('serialize-javascript');

// 因為我們在vue-router 的配置里面使用了 `base: '/c'`,這里需要去掉請求path中的 '/c'

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

let context = {url:this.url };

// 創(chuàng)建渲染器

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` 這行代碼么?

* 這里就直接把數(shù)據(jù)發(fā)送到瀏覽器端啦

**/

html +=`

// 將服務(wù)器獲取到的數(shù)據(jù)作為首屏數(shù)據(jù)發(fā)送到瀏覽器

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

`;

resolve(html);

}

)

})

yieldthis.render('ssr', html);

// 創(chuàng)建渲染器函數(shù)

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">

這樣,一個簡單的服務(wù)端渲染就結(jié)束了,限于篇幅,詳細(xì)的代碼請參考Github代碼庫。

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

小結(jié)

整個demo包含了:

- vue + vue-router + vuex 的使用

- 服務(wù)端數(shù)據(jù)獲取

- 客戶端數(shù)據(jù)同步以及DOM hydration。

沒有涉及:

- 流式渲染

- 組件緩存

對Vue的服務(wù)端渲染有更深一步的認(rèn)識,實際在生產(chǎn)環(huán)境中的應(yīng)用可能還需要考慮很多因素。

選擇Vue的服務(wù)端渲染方案,是情理之中的選擇,不是對新技術(shù)的盲目追捧,而是一切為了需要。 Vue 2.0的SSR方案只是提供了一種可能,多了一種選擇,框架本身在于服務(wù)開發(fā)者,根據(jù)不同的場景選擇不同的方案,才會事半功倍。

文章僅代表個人觀點,有不妥當(dāng)?shù)胤綗┱埓蠹抑赋?,共同進步!


最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 在實現(xiàn) egg + vue 服務(wù)端渲染工程化實現(xiàn)之前,我們先來看看前面兩篇關(guān)于Webpack構(gòu)建和Egg的文章: ...
    hubcarl閱讀 6,145評論 0 19
  • 一只純潔的白鴿從天邊的白晝/ 飛到了我的枕邊/ 它在尋找金黃的麥子/ 有三萬公里疾馳/ 才將那滴沙啞的眼淚鎖住我/...
    孟章君閱讀 897評論 0 0
  • 人物篇之鄭板橋 世人皆拿鄭板橋的難得糊涂慰藉意淫。 難得糊涂豈是真糊涂? 本來就在糊涂中何須再借糊涂語? 不解鄭板...
    縱情嬉戲天地間閱讀 335評論 0 1

友情鏈接更多精彩內(nèi)容