SSR服务器渲染的具体实现
前面的文章只是简单说明了什么是SSR,以及SSR的简单实现,这篇文章,更深入去记录一个SSR服务器渲染要实现的东西,是跟小马哥学习的~
在这里,我们要来完成SSR服务器端渲染+webpack打包+客户端渲染的实现,我们可以在webpack打包的时候去实现SSR服务端代码和客户端代码的相互引用。
对 /src/main.js 这个文件进行修改
import Vue from 'vue'
import App from './App'
// import router from './router'
import {createRouter} from './router'
Vue.config.productionTip = false
/* eslint-disable no-new */
//确保每次请求都是最新的实例化对象,使用工厂函数的方式
export function createApp(){
const router = createRouter();
const app = new Vue({
// el: '#app',
router,
components: { App },
template: '<App/>'
})
return { app }
}
因为这里是给后端node用的代码,所以el:'#app'是得不到前端元素的,注销掉。然后再把创建app实例的过程放在函数里面,并export出去,在后面的entry-server.js文件引用
在src下entry-server.js文件
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
//创建app实例
const { app } = createApp()
//拿到创建实例时的路由对象
const router = app.$router
//拿到context中的地址,从而知道要调往哪个组件,利用context来进行前后端的通信
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
//更改路由
router.push(url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
return reject({ code: 404 })
}
resolve(app)
}, reject)
})
}
为了保证同步,使用promise函数来处理。当调用这个函数时,会创建一个新的vue实例,然后通过路由的push()方法,来更改实例的路由状态。更改完成后获取到该路由下将加载的组件,根据所得组件的长度来判断该路由页面是否存在。
在build文件夹下 创建一个服务端打包的资源配置文件 build/webpack.server.config.js
const webpack = require('webpack')
const merge = require('webpack-merge')
//引入的是webpack的主要配置文件
const base = require('./webpack.base.conf')
module.exports = merge(base,{
target:'node',
//配置入口为entry-server.js文件
entry:"./src/entry-server.js",
output: {
filename: 'bundle.server.js',
libraryTarget: 'commonjs2'
},
plugins:[
]
})
记得在webpack.base.conf.js中将entry的配置注释掉,为后续客户端打包的webpack文件共享。在package.json中还要记得加上打包webpack.server.config.js的命令行,并运行npm run server对webpack.server.config.js文件进行打包编译,
在项目根目录下创建一个服务端server.js文件
/* server.js */
const exp = require('express')
const express = exp()
const renderer = require('vue-server-renderer').createRenderer()
//引入打包好的entry-server.js文件
const createApp = require('./dist/bundle.server.js')['default']
// 响应路由请求
express.get('*', (req, res) => {
const context = { url: req.url }
// 创建vue实例,传入请求路由信息
createApp(context).then(app => {
renderer.renderToString(app, (err, html) => {
if (err) { return res.state(500).end('运行时错误') }
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue2.0 SSR渲染页面</title>
</head>
<body>
<div id="app">${html}</div>
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8881, () => {
console.log('服务器已启动!')
})
在/components下创建Home.vue、About.vue组件
/*Home.vue*/
<template>
<div>
我是首页
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
};
},
};
</script>
<style lang="css" scoped>
</style>
/*About.vue*/
<template>
<div>
关于我页面
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
};
},
};
</script>
<style lang="css" scoped>
</style>
路由配置文件/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
// import HelloWorld from '@/components/HelloWorld'
const Home = ()=>import('@/components/Home')
Vue.use(Router)
export function createRouter (){
// 要记得增加mode属性,因为#后面的内容不会发送至服务器,服务器不知道请求的是哪一个路由
mode:"history",
return new Router({
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/About',
name: 'About',
component: About
},
]
})
}
路由配置要记得加上 mode: 'history' 这个配置选项,因为默认的路由方式是通过#后面的数据变化来实现路由跳转的。而#后面的数据是不会发送给服务器的,因此服务端收到的永远是根文件index.html的资源请求,这样就无法根据路由信息来进行服务端渲染了
然后运行 node server 将服务器启动,再访问服务器地址,就可以看到服务器渲染的效果了。
总体走一遍是这样的:在终端输入npm run server命令,首先会走package.json文件,然后会走build/webpack.server.conf.js文件,然后走entry,走到了entry-server.js文件,然后输出了dist/bundle.server.js文件,这就是浏览器输出Vue 组件的过程。这个bundle.server.js文件就是客户端供服务器端使用的文件。然后走node server.js,启动服务器,在server.js中,就把vue实例组件渲染为服务器端的 HTML 字符串,然后放入到html文档中,当服务器收到请求时,就把文档返回。
但是,我们还没成功,因为现在返回过来的只是一个页面的对应信息,并且如果切换至另一个路由就会重新向服务端发起请求,获取页面,还处于web1.0时代。这是因为我们的单页面应用没有加载导致的,下面我们就来配置单页面应用,并将它引入到返回的html页面当中。
客户端渲染部分
在build文件夹中创建webpack.client.config.js客户端配置文件
在src文件夹中创建entry-client.js客户端入口文件
import { createApp } from '../src/main'
const app = createApp()
// 绑定app根元素
window.onload = function() {
app.$mount('#app')
}
在window加载完成后,绑定一个新的app实例,从而实现单页面应用
还要更改一下server.js文件代码
/* server.js */
const exp = require('express')
const express = exp()
const renderer = require('vue-server-renderer').createRenderer()
const createApp = require('./dist/bundle.server.js')['default']
// console.log(createApp('/'));
// 设置静态文件目录
+++ express.use('/', exp.static(__dirname + '/dist'))
+++ const clientBundleFileUrl = '/bundle.client.js'
// 响应路由请求
express.get('*', (req, res) => {
const context = { url: req.url }
console.log(createApp(context));
// 创建vue实例,传入请求路由信息
createApp(context).then(app => {
renderer.renderToString(app, (err, html) => {
if (err) { return res.state(500).end('运行时错误') }
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue2.0 SSR渲染页面</title>
+++ <script src="${clientBundleFileUrl}"></script>
</head>
<body>
${html}
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8881, () => {
console.log('服务器已启动!')
})
这里的改动,主要在于head下面增加了一个脚本标签,用于引入我们的单页面应用,也就是客户端创建的app实例,从而实现单页面应用
需要特别注意的是:一般script标签我们都会放置在body标签内的最下方,来防止长时间的白屏,但是如果这里也这样做,会发现进入页面后会看到大量没有样式的SEO内容,短暂的延迟后,由于script文件的加载完毕,会闪屏至正常的有样式的页面,这样用户的体验非常不好。因此我们将脚本标签放在head中先加载,并且设置window的onload事件,当body的内容加载完毕后再触发脚本,虽然有了白屏时间,但是时间短暂,用户体验相比之下会更好
编写 webpack.client.config.js文件
const webpack = require('webpack')
const path = require('path');
function resolve (dir) {
return path.join(__dirname, '..', dir)
}
module.exports = {
entry:"./src/entry-client.js",
output: {
path: path.resolve(__dirname,'../dist'),
publicPath: '/dist/',
filename: 'bundle.client.js'
},
plugins:[
// new VueSSRServerPlugin()
],
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src'),
}
},
module:{
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
}
]
}
}
因为这个是客户端渲染,所以是需要配置对应的加载器的。
然后还需要在package.json中写对应的打包命令来打包文件,和上面的打包过程类似。
走到这里,虽然我们已经进入了about页面,但是html页面仍然是最初进入的home的SEO信息,这说明我们正在使用单页面应用,没有再经过服务器端的渲染了
但是,如果我们请求的html页面是带有异步请求数据操作的,那以上的操作还不能实现,还需要进一步的改善。
话分两头说,这里我们也是分服务端和客户端两边来说,先说说服务端,服务端需要在渲染阶段前获取到相关的请求信息,然后将信息写入到vue实例当中,再通过vue渲染器渲染成字符串,插入到html文件中。
修改entry-server.js中的内容
/*entry-server.js*/
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
const { app } = createApp()
const router = app.$router
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
// 更改路由
router.push(url)
router.onReady(() => {
// 获取相应路由下的组件
const matchedComponents = router.getMatchedComponents()
// 没有路由匹配 则返回状态码
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 遍历路由下所以的组件,如果有需要服务端渲染的请求,则进行请求
Promise.all(matchedComponents.map( component => {
if (component.serverRequest) {
//未来各组件中如果有serverRequest对象 判断是否需要服务端请求数据,并传入一个store参数
return component.serverRequest(app.$store)
}
})).then(() => {
context.state = app.$store.state;
resolve(app)
}).catch(reject)
}, reject)
})
}
增加了一个Promise.all函数,将异步的请求变为同步状态,当我们指定的任务执行完毕后,vue实例才算是创建完成 我们遍历请求路由下的组件,通过是否有serverRequest这个函数来判断是否需要服务端请求数据,如果需要则执行这个函数,并传入一个store参数,store是vue的Vuex的状态管理参数,下面是它的代码:
/* /store/index.js */
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export function createStore() {
let store = new Vuex.Store({
state: {
homeInfo: ''
},
actions: {
getHomeInfo({ commit }) {
return axios.get('http://localhost:8881/api/getHomeInfo').then((res) => {
commit('setHomeInfo', res.data)
})
}
},
mutations: {
setHomeInfo(state, res) {
state.homeInfo = res
}
}
})
return store
}
更改后的main.js,引入store进入vue实例
import Vue from 'vue'
import App from './App'
// import router from './router'
import {createRouter} from './router'
+++ import {createStore} from './store'
Vue.config.productionTip = false
/* eslint-disable no-new */
export function createApp(){
const router = createRouter();
const store = createStore();
const app = new Vue({
+++ store,
router,
components: { App },
template: '<App/>'
})
return { app }
}
服务器代码更改:增加一个处理‘/api/getHomeInfo’请求的函数
/* server.js */
const exp = require('express')
const express = exp()
const renderer = require('vue-server-renderer').createRenderer()
const createApp = require('./dist/bundle.server.js')['default']
// 设置静态文件目录
express.use('/', exp.static(__dirname + '/dist'))
const clientBundleFileUrl = '/bundle.client.js'
// getHomeInfo请求
express.get('/api/getHomeInfo', (req, res) => {
res.send('SSR发送请求')
})
// 响应路由请求
express.get('*', (req, res) => {
const context = { url: req.url }
// 创建vue实例,传入请求路由信息
createApp(context).then(app => {
renderer.renderToString(app, (err, html) => {
if (err) { return res.state(500).end('运行时错误') }
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue2.0 SSR渲染页面</title>
<script src="${clientBundleFileUrl}"></script>
</head>
<body>
${html}
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8881, () => {
console.log('服务器已启动!')
})
更改后的Home.vue
写了一个serverRequest函数,用于告诉服务端,让服务端来请求数据,然后通过监听store中的数据来获取参数。
为什么要用store来发起请求呢?
服务端和客户端是两个vue实例各自进行自己的渲染,然后拼接在一起的,通过serverRequest发出的请求只有服务端的vue实例可以拿到这个store数据,而客户端的vue实例是拿不到的,
<template>
<div>
我是首页
<div>{{ homeInfo }}</div>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
};
},
serverRequest(store) {
return store.dispatch('getHomeInfo')
},
computed:{
homeInfo(){
return this.$store.state.homeInfo
}
}
};
</script>
<style lang="css" scoped>
</style>
请求路由返回的html文件中,明明是有 'SSR发送请求的' 字样的,说明我们的服务端请求并且渲染到html文件上已经成功了,但是页面上为什么不显示呢?
这是因为客户端的Vue实例脚本加载成功后。{{homeInfo}}被客户端的homeInfo属性覆盖,而客户端的homeInfo是没有值的,是个空的值,因此不显示
那么怎么解决这个问题呢?
普通的办法就是像一般的单页面应用一样,加载到这个组件时,去请求下数据,然后将数据渲染到页面上,对于单页面这是正确的办法,但是对于我们服务端渲染的应用则不然。我们在服务器上已经请求过一次了,再请求一次会浪费多余的资源,所以我们就用到了vue的状态管理
但是服务端和客户端是两个不同的vue实例,store是不相通的。但是我们可以通过一个__INITIAL_STATE__
属性来架起一座链接服务器端和客户端之间的桥梁,让他们的数据相互贯通。
看一下服务端server.js代码,收到客户端对服务器端发出的任意路由信息,然后将路由信息放入到context对象中,传给vue实例创建器用来创建vue的实例,我们借用的就是context属性
再次更改之后的entry.server.js代码
import { createApp } from './main'
export default context => {
return new Promise((resolve, reject) => {
const { app } = createApp()
const router = app.$router
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
router.push(url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(matchedComponents.map( component => {
if (component.serverRequest) {
return component.serverRequest(app.$store)
}
})).then(() => {
context.state = app.$store.state; //更改的代码
resolve(app)
}).catch(reject)
}, reject)
})
}
将路由匹配下的组件的serverRequest函数执行一圈后,服务端vue实例的store已经改变了自己的状态,里面的state属性也不再是默认为空的状态了,此时我们将这个已经收获满满果实的state属性赋值给context对象,然后改造server.js文件:
/* server.js */
const exp = require('express')
const express = exp()
const renderer = require('vue-server-renderer').createRenderer()
const createApp = require('./dist/bundle.server.js')['default']
// console.log(createApp('/'));
// 设置静态文件目录
express.use('/', exp.static(__dirname + '/dist'))
const clientBundleFileUrl = '/bundle.client.js'
// getHomeInfo请求
express.get('/api/getHomeInfo', (req, res) => {
res.send('SSR发送请求')
})
// 响应路由请求
express.get('*', (req, res) => {
const context = { url: req.url }
// 创建vue实例,传入请求路由信息
createApp(context).then(app => {
//context.state属性转为字符串赋值给它,然后再head标签下,客户端vue脚本前,增加一个script标签,内容是,创建一个全局对象,值是state的值,这样我们就成功了一半,已经将服务端请求得出的结果传给了客户端,我们可以看下浏览器接受html文件
let state = JSON.stringify(context.state);
renderer.renderToString(app, (err, html) => {
if (err) { return res.state(500).end('运行时错误') }
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue2.0 SSR渲染页面</title>
<script>window.__INITIAL_STATE__ = ${state}</script> //更改的代码
<script src="${clientBundleFileUrl}"></script>
</head>
<body>
${html}
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8881, () => {
console.log('服务器已启动!')
})
下面,我们将完成桥梁的最后一步,将__INITIAL_STATE__属性同步到客户端vue实例的store上去
更改entry.client.js,使用store的replaceState方法,同步服务端的store到客户端的store,我们看下浏览器的情况:
/* entry-client.js */
import { createApp } from './main'
const { app} = createApp()
const router = app.$router
// 同步服务端信息
if (window.__INITIAL_STATE__) {
app.$store.replaceState(window.__INITIAL_STATE__);
}
window.onload = function() {
app.$mount('#app')
}
最后,通过一个图来说明ssr后端渲染的实现:
后端SSR渲染的目的是完成SEO,用entry-server.js来完成,在这个文件中创建一个vue实例,然后通过webpack打包成bundle.server.js,然后被server.js引用,通过vue-srr-renderer这个插件的renderToString方法转成html字符串,嵌入到html文件中;客户端渲染的目的是实现单页面应用,用entry-client.js来完成,在这个文件中创建一个vue实例,然后通过webpack打包成client.server.js文件,然后被server.js文件整合到html文档中,从而实现等window载入完成后,将新创建的vue实例挂载到页面从而实现单页面应用。所以说,整个过程创建了两个vue实例,各自完成自己的任务。