服务端渲染实现原理机制:在服务端拿数据进行解析渲染,直接生成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上分享,而且会继续维护更新,点击查看详情