服務端渲染實現原理機制:在服務端拿數據進行解析渲染,直接生成html片斷返回給前端。而後前端能夠經過解析後端返回的html片斷到前端頁面,大體有如下兩種形式:javascript
一、服務器經過模版引擎直接渲染整個頁面,例如java後端的vm模版引擎,php後端的smarty模版引擎。php
二、服務渲染生成html代碼塊, 前端經過AJAX獲取而後使用js動態添加。css
服務端渲染可以解決兩大問題:html
一、seo問題,有利於搜索引擎蜘蛛抓取網站內容,利於網站的收錄和排名。前端
二、首屏加載過慢問題,例如如今成熟的SPA項目中,打開首頁須要加載不少資源,經過服務端渲染能夠加速首屏渲染。vue
一樣服務端渲染也會有弊端,主要是根據本身的業務場景來選擇適合方式,因爲服務端渲染前端頁面,必將會給服務器增長壓力。java
客戶端請求服務器,服務器根據請求地址得到匹配的組件,在調用匹配到的組件返回 Promise (官方是preFetch方法)來將須要的數據拿到。最後再經過node
<script>window.__initial_state=data</script>
複製代碼
將其寫入網頁,最後將服務端渲染好的網頁返回回去。webpack
接下來客戶端會將vuex將寫入的 initial_state 替換爲當前的全局狀態樹,再用這個狀態樹去檢查服務端渲染好的數據有沒有問題。遇到沒被服務端渲染的組件,再去發異步請求拿數據。說白了就是一個相似React的 shouldComponentUpdate 的Diff操做。ios
Vue2使用的是單向數據流,用了它,就能夠經過 SSR 返回惟一一個全局狀態, 並確認某個組件是否已經SSR過了。
因爲virtual dom的引入,使得vue的服務端渲染成爲了可能,下面是官方 vue-server-renderer提供的渲染流程圖:
能夠看出vue的後端渲染分三個部分組成:頁面的源碼(source),node層的渲染部分和瀏覽器端的渲染部分。
source分爲兩種entry point,一個是前端頁面的入口client entry,主要是實例化Vue對象,將其掛載到頁面中;另一個是後端渲染服務入口server entry,主要是控服務端渲染模塊回調,返回一個Promise對象,最終返回一個Vue對象(通過測試,直接返回Vue對象也是能夠的);
前面的source部分就是業務開發的代碼,開發完成以後經過 webpack 進行構建,生成對應的bundle,這裏再也不贅述client bundle,就是一個可在瀏覽器端執行的打包文件;這裏說下server bundle, vue2提供 vue-server-renderer模塊,模塊能夠提供兩種render: rendererer/bundleRenderer ,下面分別介紹下這兩種render。
renderer接收一個vue對象 ,而後進行渲染,這種對於簡單的vue對象,能夠這麼去作,可是對於複雜的項目,若是使用這種直接require一個vue對象,這個對於服務端代碼的結構和邏輯都不太友好,首先模塊的狀態會一直延續在每一個請求渲染請求,咱們須要去管理和避免此次渲染請求的狀態影響到後面的請求,所以vue-server-renderer提供了另一種渲染模式,經過一個 bundleRenderer去作渲染。
bundleRenderer是較爲複雜項目進行服務端渲染官方推薦的方式,經過webpack以server entry按照必定的要求打包生成一個 server-bundle,它至關於一個能夠給服務端用的app的打包壓縮文件,每一次調用都會從新初始化 vue對象,保證了每次請求都是獨立的,對於開發者來講,只須要專一於當前業務就能夠,不用爲服務端渲染開發更多的邏輯代碼。 renderer生成完成以後,都存在兩個接口,分別是renderToString和renderToStream,一個是一次性將頁面渲染成字符串文件,另一個是流式渲染,適用於支持流的web服務器,能夠是請求服務的速度更快。
上一節咱們大體講了爲何須要使用vue後端渲染,以及vue後端渲染的基本原理,這節內容咱們將從零開始搭建屬於本身的vue後端渲染腳手架,固然不能不參考官方頁面響應的實例vue-hackernews-2.0,從零開始搭建項目,源碼在將在下節與你們共享。
基本環境要求:node版本6.10.1以上,npm版本3.10.10以上,本機環境是這樣的,建議升級到官方最新版本。
使用的技術棧:
1、vue 2.4.2
2、vuex 2.3.1
3、vue-router 2.7.0
4、vue-server-renderer 2.4.2
5、express 4.15.4
6、axios 0.16.2
7、qs 6.5.0
8、q https://github.com/kriskowal/q.git
9、webpack 3.5.0
10、mockjs 1.0.1-beta3
11、babel 相關插件
複製代碼
以上是主要是用的技術棧,在構建過程當中會是用相應的插件依賴包來配合進行壓縮打包,如下是npm init後package.json文件所要添加的依賴包。
"dependencies": {
"axios": "^0.16.2",
"es6-promise": "^4.1.1",
"express": "^4.15.4",
"lodash": "^4.17.4",
"q": "git+https://github.com/kriskowal/q.git",
"qs": "^6.5.0",
"vue": "^2.4.2",
"vue-router": "^2.7.0",
"vue-server-renderer": "^2.4.2",
"vuex": "^2.3.1"
},
"devDependencies": {
"autoprefixer": "^7.1.2",
"babel-core": "^6.25.0",
"babel-loader": "^7.1.1",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-preset-env": "^1.6.0",
"babel-preset-stage-2": "^6.22.0",
"compression": "^1.7.1",
"cross-env": "^5.0.5",
"css-loader": "^0.28.4",
"extract-text-webpack-plugin": "^3.0.0",
"file-loader": "^0.11.2",
"friendly-errors-webpack-plugin": "^1.6.1",
"glob": "^7.1.2",
"less": "^2.7.2",
"less-loader": "^2.2.3",
"lru-cache": "^4.1.1",
"mockjs": "^1.0.1-beta3",
"style-loader": "^0.19.0",
"sw-precache-webpack-plugin": "^0.11.4",
"url-loader": "^0.5.9",
"vue-loader": "^13.0.4",
"vue-style-loader": "^3.0.3",
"vue-template-compiler": "^2.4.2",
"vuex-router-sync": "^4.2.0",
"webpack": "^3.5.0",
"webpack-dev-middleware": "^1.12.0",
"webpack-hot-middleware": "^2.18.2",
"webpack-merge": "^4.1.0",
"webpack-node-externals": "^1.6.0"
}
複製代碼
基本目錄結構以下:
├── LICENSE
├── README.md
├── build
│ ├── setup-dev-server.js
│ ├── vue-loader.config.js
│ ├── webpack.base.config.js
│ ├── webpack.client.config.js
│ └── webpack.server.config.js
├── log
│ ├── err.log
│ └── out.log
├── package.json
├── pmlog.json
├── server.js
└── src
├── App.vue
├── app.js
├── assets
│ ├── images
│ ├── style
│ │ └── css.less
│ └── views
│ └── index.css
├── components
│ ├── Banner.vue
│ ├── BottomNav.vue
│ ├── FloorOne.vue
│ └── Header.vue
├── entry-client.js
├── entry-server.js
├── index.template.html
├── public
│ ├── conf.js
│ └── utils
│ ├── api.js
│ └── confUtils.js
├── router
│ └── index.js
├── static
│ ├── img
│ │ └── favicon.ico
│ └── js
│ └── flexible.js
├── store
│ ├── actions.js
│ ├── getters.js
│ ├── index.js
│ ├── modules
│ │ └── Home.js
│ ├── mutationtypes.js
│ └── state.js
└── views
└── index
├── conf.js
├── index.vue
├── mock.js
└── service.js
複製代碼
使用vue開發項目入口文件通常都會以下寫法:
import Vue from 'vue';
import App from './index.vue';
import router from './router'
import store from './store';
new Vue({
el: '#app',
store,
router,
render: (h) => h(App)
});
複製代碼
這種寫法是程序共享一個vue實例,可是在後端渲染中很容易致使交叉請求狀態污染,致使數據流被污染了。
因此,避免狀態單例,咱們不該該直接建立一個應用程序實例,而是應該暴露一個能夠重複執行的工廠函數,爲每一個請求建立新的應用程序實例,一樣router和store入口文件也須要從新建立一個實例。
爲了配合webpack動態加載路由配置,這裏會改寫常規路由引入寫法,這樣能夠根據路由路徑來判斷加載相應的組件代碼:
import Home from '../views/index/index.vue'
// 改寫成
component: () => ('../views/index/index.vue')
複製代碼
如下是路由的基本寫法router,對外會拋出一個createRouter方法來建立一個新的路由實例:
import Vue from 'vue'
import Router from 'vue-router';
Vue.use(Router)
export function createRouter() {
return new Router({
mode: 'history',
routes: [{
name:'Home',
path: '/',
component: () =>
import ('../views/index/index.vue')
}]
})
}
複製代碼
如下是store狀態管理的基本寫法,對外暴露了一個createStore方法,方便每次訪問建立一個新的實例:
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
import * as actions from './actions'
import getters from './getters'
import modules from './modules/index'
Vue.use(Vuex)
export function createStore() {
return new Vuex.Store({
actions,
getters,
modules,
strict: false
})
}
複製代碼
結合寫好的router和store入口文件代碼來編寫整個項目的入口文件app.js代碼內容,一樣最終也會對外暴露一個createApp方法,在每次建立app的時候保證router,store,app都是新建立的實例,這裏還引入了一個vue路由插件vuex-router-sync,主要做用是同步路由狀態(route state)到 store,如下是app.js完整代碼:
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'
require('./assets/style/css.less');
export function createApp () {
// 建立 router 和 store 實例
const router = createRouter()
const store = createStore()
// 同步路由狀態(route state)到 store
sync(store, router)
// 建立應用程序實例,將 router 和 store 注入
const app = new Vue({
router,
store,
render: h => h(App)
})
// 暴露 app, router 和 store。
return { app, router, store }
}
複製代碼
首頁引入從app文件中暴露出來的createApp方法,在每次調用客戶端的時候,從新建立一個新的app,router,store,部分代碼以下:
import { createApp } from './app'
const { app, router, store } = createApp()
複製代碼
這裏咱們會使用到onReady方法,此方法一般用於等待異步的導航鉤子完成,好比在進行服務端渲染的時候,例子代碼以下:
import { createApp } from './app'
const { app, router, store } = createApp()
router.onReady(() => {
app.$mount('#app')
})
複製代碼
咱們會調用一個新方法beforeResolve,只有在router2.5.0以上的版本纔會有的方法,註冊一個相似於全局路由保護router.beforeEach(),除了在導航確認以後,在全部其餘保護和異步組件已解決以後調用。基本寫法以下:
router.beforeResolve((to, from, next) => {
// to 和 from 都是 路由信息對象
// 返回目標位置或是當前路由匹配的組件數組(是數組的定義/構造類,不是實例)。一般在服務端渲染的數據預加載時時候。
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
})
複製代碼
服務端把要給客戶端的 state 放在了 window.INITIAL_STATE 這個全局變量上面。先後端的 HTML 結構應該是一致的。而後要把 store 的狀態樹寫入一個全局變量(INITIAL_STATE),這樣客戶端初始化 render 的時候可以校驗服務器生成的 HTML 結構,而且同步到初始化狀態,而後整個頁面被客戶端接管。基本代碼以下:
// 將服務端渲染時候的狀態寫入vuex中
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
複製代碼
接下來貼出來完整的客戶端代碼,這裏的Q也能夠不用引入,直接使用babel就能編譯es6自帶的Promise,由於本人使用習慣了,這裏能夠根據自身的需求是否安裝:
import { createApp } from './app'
import Q from 'q'
import Vue from 'vue'
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()
// 將服務端渲染時候的狀態寫入vuex中
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))
})
if (!activated.length) {
return next()
}
// 這裏若是有加載指示器(loading indicator),就觸發
Q.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 中止加載指示器(loading indicator)
next()
}).catch(next)
})
app.$mount('#app')
})
複製代碼
基本編寫和客戶端的差很少,由於這是服務端渲染,涉及到與後端數據交互定義的問題,咱們須要在這裏定義好各組件與後端交互使用的方法名稱,這樣方便在組件內部直接使用,這裏根咱們常規在組件直接使用ajax獲取數據有些不同,代碼片斷以下:
//直接定義組件內部asyncData方法來觸發相應的ajax獲取數據
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
複製代碼
如下是完整的服務端代碼:
import { createApp } from './app'
import Q from 'q'
export default context => {
return new Q.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 })
}
// 對全部匹配的路由組件調用 `asyncData()`
Q.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)
})
}
複製代碼
到這裏src下面主要的幾個文件代碼已經編寫完成,接下里介紹下整個項目的目錄結構以下:
主要幾個文件介紹以下:
咱們還須要編寫在服務端啓動服務的代碼server.js,咱們會使用到部分node原生提供的api,片斷代碼以下:
const Vue = require('vue')
const express = require('express')
const path = require('path')
const LRU = require('lru-cache')
const { createBundleRenderer } = require('vue-server-renderer')
const fs = require('fs')
const net = require('net')
複製代碼
大體思路是,引入前端模版頁面index.template.html,使用express啓動服務,引入webpack打包項目代碼的dist文件,引入緩存模塊(這裏不作深刻介紹,後期會單獨詳細介紹),判斷端口是否被佔用,自動啓動其餘接口服務。
引入前端模版文件而且設置環境變量爲production,片斷代碼以下:
const template = fs.readFileSync('./src/index.template.html', 'utf-8')
const isProd = process.env.NODE_ENV === 'production'
複製代碼
vue-server-renderer插件的具體使用,經過讀取dist文件夾下的目錄文件,來建立createBundleRenderer函數,而且使用LRU來設置緩存的時間,經過判斷是生產環境仍是開發環境,調用不一樣的方法,代碼片斷以下:
const resolve = file => path.resolve(__dirname, file)
function createRenderer (bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
template,
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
basedir: resolve('./dist'),
runInNewContext: false
}))
}
let renderer;
let readyPromise
if (isProd) {
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
clientManifest
})
} else {
readyPromise = require('./build/setup-dev-server')(server, (bundle, options) => {
renderer = createRenderer(bundle, options)
})
}
複製代碼
使用express啓動服務,代碼片斷以下:
const server = express();
//定義在啓動服務錢先判斷中間件中的緩存是否過時,是否直接調用dist文件。
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
})
server.use('/dist', serve('./dist', true))
server.get('*', (req, res) => {
const context = {
title: 'hello',
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(html)
})
})
複製代碼
判斷端口是否被佔用,片斷代碼以下:
function probe(port, callback) {
let servers = net.createServer().listen(port)
let calledOnce = false
let timeoutRef = setTimeout(function() {
calledOnce = true
callback(false, port)
}, 2000)
timeoutRef.unref()
let connected = false
servers.on('listening', function() {
clearTimeout(timeoutRef)
if (servers)
servers.close()
if (!calledOnce) {
calledOnce = true
callback(true, port)
}
})
servers.on('error', function(err) {
clearTimeout(timeoutRef)
let result = true
if (err.code === 'EADDRINUSE')
result = false
if (!calledOnce) {
calledOnce = true
callback(result, port)
}
})
}
const checkPortPromise = new Promise((resolve) => {
(function serverport(_port) {
let pt = _port || 8080;
probe(pt, function(bl, _pt) {
// 端口被佔用 bl 返回false
// _pt:傳入的端口號
if (bl === true) {
// console.log("\n Static file server running at" + "\n\n=> http://localhost:" + _pt + '\n');
resolve(_pt);
} else {
serverport(_pt + 1)
}
})
})()
})
checkPortPromise.then(data => {
uri = 'http://localhost:' + data;
console.log('啓動服務路徑'+uri)
server.listen(data);
});
複製代碼
到這裏,基本的代碼已經編寫完成,webpack打包配置文件基本和官方保持不變,接下來能夠嘗試啓動本地的項目服務,這裏簡要的使用網易嚴選首頁做爲demo示例,結果以下:
上一節大體介紹了服務端和客戶端入口文件代碼內容,如今已經能夠正常運行你的後端渲染腳手架了,這一節,跟你們分享下如何使用axios作ajax請求,如何使用mockjs作本地假數據,跑通本地基本邏輯,爲之後先後端連調作準備。
須要用npm安裝axios,mockjs依賴包,因爲mockjs只是代碼開發的輔助工具,因此安裝的時候我會加--save-dev來區分,具體能夠根據本身的需求來定,固然,若是有mock服務平臺的話,能夠直接走mock平臺造假數據,本地直接訪問mock平臺的接口,例如可使用阿里的Rap平臺管理工具生成。
npm install axios --save
npm install mockjs --save-dev
複製代碼
其餘請求方式,代碼示例以下:
axios.request(config);
axios.get(url[,config]);
axios.delete(url[,config]);
axios.head(url[,config]);
axios.post(url[,data[,config]]);
axios.put(url[,data[,config]])
axios.patch(url[,data[,config]])
複製代碼
具體詳細能夠點擊查看axios基本使用介紹
import axios from 'axios'
import qs from 'qs'
import Q from 'q'
/** * 兼容 不支持promise 的低版本瀏覽器 */
require('es6-promise').polyfill();
import C from '../conf'
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
axios.defaults.withCredentials = true
function ajax(url, type, options) {
return Q.Promise((resolve, reject) => {
axios({
method: type,
url: C.HOST + url,
params: type === 'get' ? options : null,
data: type !== 'get' ? qs.stringify(options) : null
})
.then((result) => {
if (result && result.status === 401) {
// location.href = '/views/401.html'
}
if (result && result.status === 200) {
if (result.data.code === 200) {
resolve(result.data.data);
} else if (result.data.code === 401) {
reject({
nopms: true,
msg: result.data.msg
});
} else {
reject({
error: true,
msg: result.data.msg
});
}
} else {
reject({
errno: result.errno,
msg: result.msg
});
}
})
.catch(function(error) {
console.log(error, url);
});
})
}
const config = {
get(url, options) {
const _self = this;
return Q.Promise((resolve, reject) => {
ajax(url, 'get', options)
.then((data) => {
resolve(data);
}, (error) => {
reject(error);
});
})
},
post(url, options) {
const _self = this;
return Q.Promise((resolve, reject) => {
ajax(url, 'post', options)
.then((data) => {
resolve(data);
}, (error) => {
reject(error);
});
})
},
put(url, options) {
const _self = this;
return Q.Promise((resolve, reject) => {
ajax(url, 'put', options)
.then((data) => {
resolve(data);
}, (error) => {
reject(error);
});
})
},
delete(url, options) {
const _self = this;
return Q.Promise((resolve, reject) => {
ajax(url, 'delete', options)
.then((data) => {
resolve(data);
}, (error) => {
reject(error);
});
})
},
jsonp(url, options) {
const _self = this;
return Q.Promise((resolve, reject) => {
ajax(url, 'jsonp', options)
.then((data) => {
resolve(data);
}, (error) => {
reject(error);
});
})
}
};
export default config;
複製代碼
一、在public下新建conf.js全局定義請求url地址,代碼以下:
module.exports = {
HOST: "http://www.xxx.com",
DEBUGMOCK: true
};
複製代碼
二、在views/index根目錄下新建conf.js,定義組件mock的請求路徑,而且定義是否開始單個組件使用mock數據仍是線上接口數據,代碼以下:
const PAGEMOCK = true;
const MODULECONF = {
index: {
NAME: '首頁',
MOCK: true,
API: {
GET: '/api/home',
}
}
};
複製代碼
三、在組件內部定義mockjs來編寫mock假數據,代碼以下:
import Mock from 'mockjs';
const mData = {
index: {
API: {
GET: {
"code": 200,
"data": {
"pin": 'wangqi',
"name": '王奇'
}
}
}
}
}
複製代碼
以上就是基本的流程,若是有更好更靈活的使用方案,但願可以參與溝通而且分享,項目工做流已經在github上分享,而且會繼續維護更新,點擊查看詳情