从 0 开始,搭建 Vue2.0 的 SSR 服务端渲染
前言
这篇教程主要通过 3 个项目,一步一步将 ssr 的原理及实现过程展现出来,供不知道如何开发 vue 服务端渲染的同学学习参考,另外也加深下自己的认识,文章有纰漏的地方,请大家多多指出。
技术栈
框架是 vue(版本:2.5.16),node 上使用 express 框架,通过 webpack 和 gulp 进行打包操作
我们为什么要使用服务的渲染?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script type=text/javascript src=./static/js/bundle.js></script>
</body>
</html>
上面是一个典型的 vue 应用,从返回的 html 页面可以看到,页面中只有 app 容器和一个 js 包的加载地址,并且无论你请求的路由是那种
localhost:8080/home
localhost:8080/animal
localhost:8080/people
都只会返回同样的信息:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script type=text/javascript src=./static/js/bundle.js></script>
</body>
</html>
虽然我们知道js会根据访问路由渲染出我们看到的信息,但是对于爬虫来说,它仅仅获取到了2个标签,而没有页面真实呈现内容的信息
这样就会有一个明显的缺点:缺少SEO
服务端渲染正是用来解决这个问题,当你请求不同路由时
localhost:8080/home
会返回给你相应的结果:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="app">
<div>this is home page</div>
</div>
<script type=text/javascript src=./static/js/bundle.js></script>
</body>
</html>
项目一
在项目一中,我们创建一个最原始的 SSR 项目,便于理解 SSR 的原理
首先创建一个文件夹 ssr,然后进入 ssr
$ cd ssr
$ npm init
创建 server.js 文件,下载相应插件
$ npm i vue
$ npm i express
$ npm i vue-server-renderer
server.js 文件的内容为:
/* server.js */
const Vue = require('vue')
const express = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
// 创建Vue实例
const app= new Vue({
template: '<div>hello world</div>'
})
// 响应路由请求
express.get('/', (req, res) => {
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>
${html}
</body>
</html>
`)
})
})
// 服务器监听地址
express.listen(8080, () => {
console.log('服务器已启动!')
})
创建 Vue 实例只需要模板属性即可
ssr 文件夹目录结构:
/* ssr目录结构 */
| - node_modules
- package.json
- package-lock.json
- server.js
启动服务:
$ node server
打开浏览器,地址栏输入:
localhost:8080
我们可以看到,页面加载成功:
localhost:8080
并且打开谷歌浏览器的开发者工具,查看 Network => Doc => localhost => Response
localhost
我们可以看到 Vue 实例中的模板已经被渲染到了 html 页面并返回到了客户端
服务端渲染的核心就在于:通过 vue-server-renderer 插件的 renderToString()
方法,将 Vue 实例转换为字符串插入到 html 文件
项目二
我们在项目一的基础上进行改造,加入路由功能,并且划分清服务端与客户端各自所负责的范围
通过项目一,我们知道了输入指定路由,会从服务端返回相关路由拥有 seo 内容的页面。那加入了路由后,我们每切换一个页面,是不是仍然像项目一那样请求服务器,然后服务器渲染出对应的页面返回给我们呢?
答案当然是否定的,不要被项目一的成功冲昏了头脑,如果真的那样做了,我们实际上就倒退回了 web1.0 的时代,那个时代每次进入新的路由就会重新请求服务器,造成大量的资源浪费
我们使用服务端渲染是为了弥补单页面应用 SEO 能力不足的问题
因此,实际上我们第一次在浏览器地址栏输入url,并且得到返回页面之后,所有的操作仍然是单页面应用在控制。我们所做的服务端渲染,只是在平时返回的单页面应用html上增加了对应路由的页面信息,好让爬虫获取到而已
明白了这一点,我就可以将项目一分为二,也就是分为服务端渲染和客户端渲染。服务端渲染就是项目一所做的,根据vue实例获取对应路由的 seo 信息,然后添加到返回的单页面应用 html上;客户端渲染就是平时我们所熟悉的单页面应用,
公共部分
无论是服务端渲染还是客户端渲染,他们都需要一个 vue 实例,因此我们先从这里说起
下载相应插件
{
"name": "ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"server": "webpack --config ./webpack/webpack.server.js",
"client": "webpack --config ./webpack/webpack.client.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.16.0",
"babel": "^6.23.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
"body-parser": "^1.18.3",
"compression": "^1.7.2",
"express": "^4.15.4",
"express-http-proxy": "^1.2.0",
"gulp": "^3.9.1",
"gulp-shell": "^0.6.5",
"http-proxy-middleware": "^0.18.0",
"less": "^3.0.4",
"less-loader": "^4.1.0",
"shell": "^0.5.0",
"superagent": "^3.8.3",
"vue": "^2.2.2",
"vue-meta": "^1.5.0",
"vue-router": "^2.2.0",
"vue-server-renderer": "^2.2.2",
"vue-ssr-webpack-plugin": "^3.0.0",
"vuex": "^2.2.1",
"vuex-router-sync": "^4.2.0"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^6.4.1",
"babel-preset-es2015": "^6.24.1",
"css-loader": "^0.28.4",
"style-loader": "^0.18.2",
"vue-loader": "^11.1.4",
"vue-template-compiler": "^2.2.4",
"webpack": "^2.7.0"
}
}
$ npm i
插件很多,不一一列举了,直接复制 package.json 文件内容,安装即可
ssr 文件夹目录结构:
/* ssr目录结构 */
| - node_modules
| - src
| - routes
- animal.vue
- home.vue
- people.vue
- App.vue
- main.js
- route.js
- package.json
- package-lock.json
- server.js
创建 src 文件夹,用于存放 vue 实例相关的文件
main.js 作为创建 vue 实例的引用文件:
/* main.js */
import Vue from 'vue'
import createRouter from './route.js'
import App from './App.vue'
// 导出一个工厂函数,用于创建新的vue实例
export function createApp() {
const router = createRouter()
const app = new Vue({
router,
render: h => h(App)
})
return app
}
main.js 文件导出的是一个工厂函数,使用这个工厂函数会创建一个新的 vue 实例,这样可以隔离开各个客户端的请求。每次客户端的请求,都会创建一个新的 vue 实例,接着对这个实例进行路由渲染,然后返回给客户端
route.js 作为 vue 实例创建路由的引用文件:
/* route.js */
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
export default function createRouter() {
let router = new VueRouter({
// 要记得增加mode属性,因为#后面的内容不会发送至服务器,服务器不知道请求的是哪一个路由
mode: 'history',
routes: [
{
alias: '/',
path: '/home',
component: require('./routes/home.vue')
},
{
path: '/animal',
component: require('./routes/animal.vue')
},
{
path: '/people',
component: require('./routes/people.vue')
}
]
})
return router
}
这里的路由配置要记得加上 mode: 'history'
这个配置选项,因为默认的路由方式是通过 #
后面的数据变化来实现路由跳转的。而 #
后面的数据是不会发送给服务器的,因此服务端收到的永远是根文件 index.html 的资源请求,这样就无法根据路由信息来进行服务端渲染了
App.vue 作为 vue 实例的根组件:
<!-- App.vue -->
<template>
<div>
<h2>欢迎来到SSR渲染页面</h2>
<router-view></router-view>
</div>
</template>
<script>
export default {
mounted() {
}
}
</script>
<style>
</style>
创建 routes 文件夹,存放 vue 实例的路由文件,里面的 animal.vue、home.vue、people.vue 大同小异
home.vue 文件:
<!-- home.vue -->
<template>
<div>
home
</div>
</template>
<script>
export default {
mounted() {
}
}
</script>
<style scoped>
</style>
服务端渲染部分
在 ssr 文件夹下创建 entry 入口文件夹,作为 webpack 入口文件的存放位置
在 entry 文件夹里面创建 entry-server.js 服务端入口文件
在 ssr 文件夹下创建 webpack 文件夹,作为 webpack 配置文件的存放位置
在 webpack 文件夹中创建 webpack.server.js 服务端配置文件
在 ssr 文件夹下创建 dist 文件夹,作为打包文件的存放位置
ssr 文件夹目录结构:
/* ssr目录结构 */
| - dist
| - node_modules
| - entry
- entry-server.js
| - src
| - routes
- animal.vue
- home.vue
- people.vue
- App.vue
- main.js
- route.js
| - webpack
- webpack.server.js
- .babelrc
- package.json
- package-lock.json
- server.js
创建 .babelrc
文件夹用于配置 babel
{
"presets": [
"babel-preset-env"
],
"plugins": [
"transform-runtime"
]
}
更改 server.js 文件为:
/* server.js */
const express = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
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>
${html}
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8080, () => {
console.log('服务器已启动!')
})
监听到路由请求后,将路由信息传入并创建一个新的 vue 实例。因为创建 vue 实例涉及到很多步骤,所以这里是一个 promise 回调函数。当实例创建完成后,将返回的实例信息传入渲染器中进行处理,处理结束后得到的字符串放入 html 中返回给客户端
entry-server.js:
/* entry-server.js */
import { createApp } from '../src/main'
export default context => {
return new Promise((resolve, reject) => {
const app = createApp()
// 更改路由
app.$router.push(context.url)
// 获取相应路由下的组件
const matchedComponents = app.$router.getMatchedComponents()
// 如果没有组件,说明该路由不存在,报错404
if (!matchedComponents.length) { return reject({ code: 404 }) }
resolve(app)
})
}
上面说过,因为这里会有很多处理步骤,所以为了保证同步,使用 promise 函数来处理。当调用这个函数时,会创建一个新的 vue 实例,然后通过路由的 push()
方法,来更改实例的路由状态。更改完成后获取到该路由下将加载的组件,根据所得组件的长度来判断该路由页面是否存在
webpack.server配置:
/* webpack.server.js */
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
module.exports = {
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
target: 'node',
entry: ['babel-polyfill', path.join(projectRoot, 'entry/entry-server.js')],
output: {
libraryTarget: 'commonjs2',
path: path.join(projectRoot, 'dist'),
filename: 'bundle.server.js',
},
module: {
rules: [{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
loader: 'babel-loader',
include: projectRoot,
exclude: /node_modules/,
options: {
presets: ['es2015']
}
},
{
test: /\.less$/,
loader: "style-loader!css-loader!less-loader"
}
]
},
plugins: [],
resolve: {
alias: {
'vue$': 'vue/dist/vue.runtime.esm.js'
}
}
}
打包文件并开启服务器:
$ npm run server
$ node server
浏览器输入
localhost:8080
可以看到:
我们成功的进入页面,并且页面上加载这默认路由的对应信息,我们切换至 /animal
试一试:
并且打开谷歌浏览器的开发者工具,查看 Network => Doc => animal=> Response
我们可以看到,服务端返回的 html 文件中,已经有了对应页面的 SEO 信息了
那么我们已经成功了吗?
当然,还没有,因为现在返回过来的只是一个页面的对应信息,并且如果切换至另一个路由就会重新向服务端发起请求,获取页面,还处于 web1.0 时代。这是因为我们的单页面应用没有加载导致的,下面我们就来配置单页面应用,并将它引入到返回的 html 页面当中
客户端渲染部分
在 entry 文件夹中创建 entry-client.js 客户端入口文件
在 webpack 文件夹中创建 webpack.client.js 客户端配置文件
entry-client.js:
/* entry-client.js */
import { createApp } from '../src/main'
const app = createApp()
// 绑定app根元素
window.onload = function() {
app.$mount('#app')
}
这里比较简单,提到一个小技巧,就是要在 window 加载完成后再绑定 app 根元素启动应用,这个要结合服务端返回的 html 页面一起看
更改 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']
// 设置静态文件目录
express.use('/', exp.static(__dirname + '/dist'))
const clientBundleFileUrl = '/bundle.client.js'
// 响应路由请求
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>
<div id="app">${html}</div>
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8080, () => {
console.log('服务器已启动!')
})
这里的改动,主要在于 head 下面增加了一个脚本标签,用于引入我们的单页面应用,这点和平时我们使用单页面应用的方法一样
需要特别注意的是:一般 script 标签我们都会放置在 body 标签内的最下方,来防止长时间的白屏,但是如果这里也这样做,会发现进入页面后会看到大量没有样式的 SEO 内容,短暂的延迟后,由于 script 文件的加载完毕,会闪屏至正常的有样式的页面,这样用户的体验非常不好。因此我们将脚本标签放在 head 中先加载,并且设置 window 的 onload 事件,当 body 的内容加载完毕后再触发脚本,虽然有了白屏时间,但是时间短暂,用户体验相比之下会更好
webpack.client.js :
/* webpack.client.js */
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
module.exports = {
entry: ['babel-polyfill', path.join(projectRoot, 'entry/entry-client.js')],
output: {
path: path.join(projectRoot, 'dist'),
filename: 'bundle.client.js',
},
module: {
rules: [{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
include: projectRoot,
exclude: /node_modules/,
options: {
presets: ['es2015']
}
}
]
},
plugins: [],
resolve: {
alias: {
'vue$': 'vue/dist/vue.runtime.esm.js'
}
}
};
大同小异
App.vue 改为:
<!-- App.vue -->
<template>
<div>
<h2>欢迎来到SSR渲染页面</h2>
<router-link to="/home">home</router-link>
<router-link to="/animal">animal</router-link>
<router-link to="/people">people</router-link>
<router-view></router-view>
</div>
</template>
<script>
export default {
mounted() {
}
}
</script>
<style>
</style>
增加了 3 个路由标签,用于测试
打包文件并开启服务器
$ npm run server
$ npm run client
$ node server
浏览器输入
localhost:8080/people
可以看到:
点击 home 链接:
切换到 home 内容,并且浏览器没有再次请求服务器,一切都在浏览器本地完成,打开谷歌浏览器的开发者工具,查看 Network => Doc => home=> Response
可以看到,虽然我们已经进入了 people 页面,但是 html 页面仍然是最初进入的 home 的 SEO 信息,这说明我们正在使用单页面应用,没有再经过服务器端的渲染了
至此,项目二结束,我们请求任意路由页面,可以得到相关的 SEO 信息,并且返回给我们的是一个单页面应用,我们可以在上面高性能的切换路由或者进行别的操作,而不必浪费大量的资源再次请求服务器了
项目三
经过项目二,我的项目已经初具规模,但是还缺少了一个重要的东西。聪明的你可能已经想到了,我们现在虽然能够得到 SEO 信息,但是都是我们写死的静态页面的信息,动态从服务器请求的数据并没有获取到,项目三将完成这一重要功能,实现完全的服务端渲染
我们将项目二的文件夹复制一份,在其基础上进行项目三的改造
思路
话分两头说,这里我们也是分服务端和客户端两边来说,先说说服务端
服务端需要在渲染阶段前获取到相关的请求信息,然后将信息写入到 vue 实例当中,再通过 vue 渲染器渲染成字符串,插入到 html 文件中
entry.server.js:
/* entry-server.js */
import { createApp } from '../src/main'
export default context => {
return new Promise((resolve, reject) => {
const app = createApp()
// 更改路由
app.$router.push(context.url)
// 获取相应路由下的组件
const matchedComponents = app.$router.getMatchedComponents()
// 如果没有组件,说明该路由不存在,报错404
if (!matchedComponents.length) { return reject({ code: 404 }) }
// 遍历路由下所以的组件,如果有需要服务端渲染的请求,则进行请求
Promise.all(matchedComponents.map(component => {
if (component.serverRequest) {
return component.serverRequest(app.$store)
}
})).then(() => {
resolve(app)
}).catch(reject)
})
}
我们增加了一个 Promise.all 函数,将异步的请求变为同步状态,当我们指定的任务执行完毕后,vue 实例才算是创建完成
我们遍历请求路由下的组件,通过是否有 serverRequest 这个函数来判断是否需要服务端请求数据,如果需要则执行这个函数,并传入一个 store 参数,store 是 vue 的 Vuex 的状态管理参数,下面是它的代码:
/* store.js */
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export default function createStore() {
let store = new Vuex.Store({
state: {
homeInfo: ''
},
actions: {
getHomeInfo({ commit }) {
return axios.get('http://localhost:8080/api/getHomeInfo').then((res) => {
commit('setHomeInfo', res.data)
})
}
},
mutations: {
setHomeInfo(state, res) {
state.homeInfo = res
}
}
})
return store
}
通过 Vue 的 axios 来发起请求
改造后的 main.js:
/* main.js */
import Vue from 'vue'
import createRouter from './route.js'
import App from './App.vue'
import createStore from './store'
// 导出一个工厂函数,用于创建新的vue实例
export function createApp() {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return app
}
引入 store 进入 vue 实例
改造后的 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']
// 设置静态文件目录
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>
<div id="app">${html}</div>
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8080, () => {
console.log('服务器已启动!')
})
增加了一个处理 '/api/getHomeInfo' 请求的函数
改造后的 home.vue:
<!-- home.vue -->
<template>
<div>
home
<div>{{ homeInfo }}</div>
</div>
</template>
<script>
export default {
serverRequest(store) {
return store.dispatch('getHomeInfo')
},
mounted() {
},
computed: {
homeInfo() {
return this.$store.state.homeInfo
}
}
}
</script>
<style scoped>
</style>
可以看到,这里我们写了一个 serverRequest 函数,用于告诉服务端,让服务端来请求数据,然后通过监听 store 中的数据来获取参数
看到这里,大家可能有疑问,为什么要用 store 来发起请求呢?通过项目二我们知道,服务端和客户端是两个 vue 实例各自进行自己的渲染,然后拼接在一起的,通过 serverRequest 发出的请求只有服务端的 vue 实例可以拿到这个 store 数据,而客户端的 vue 实例是拿不到的,可以看下图:
请求路由返回的 html 文件中,明明是有 'SSR发送请求的' 字样的,说明我们的服务端请求并且渲染到 html 文件上已经成功了,但是页面上为什么不显示呢?
<template>
<div>
home
<div>{{ homeInfo }}</div>
</div>
</template>
这是因为客户端的vue实例脚本加载成功后,{{ homeInfo }}
被客户端的 homeInfo
属性覆盖,而客户端的 homeInfo 是没有值的,是个空的属性,因此不显示
那么怎么解决这个问题呢?
普通的办法就是像一般的单页面应用一样,加载到这个组件时,去请求下数据,然后将数据渲染到页面上,对于单页面这是正确的办法,但是对于我们服务端渲染的应用则不然。我们在服务器上已经请求过一次了,再请求一次会浪费多余的资源,所以我们就用到了 vue 的状态管理
你可能会问了,你上面不才说服务端和客户端是两个不同的 vue 实例,store 是不相通的吗?没错,下面我们就通过一个 __INITIAL_STATE__
属性来架起一座连接服务端与客户端之间的桥梁,让他们的数据相互贯通
__INITIAL_STATE__
属性
我们先看到 server.js 文件中有这么一句话:
// 响应路由请求
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>
收到客户端对服务器发出的任意路由信息,然后将路由信息放入 context 对象中,传给 vue 实例创建器用以创建 vue 实例,我们借用的正是 context 属性
下面我们对 entry.server.js 文件进行改造:
/* entry-server.js */
import { createApp } from '../src/main'
export default context => {
return new Promise((resolve, reject) => {
const app = createApp()
// 更改路由
app.$router.push(context.url)
// 获取相应路由下的组件
const matchedComponents = app.$router.getMatchedComponents()
// 如果没有组件,说明该路由不存在,报错404
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)
})
}
这里的 context 对象,就是刚才我们传入的那个 context 对象,我们在将路由匹配下的组件的 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']
// 设置静态文件目录
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 => {
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>
<div id="app">${html}</div>
</body>
</html>
`)
})
}, err => {
if(err.code === 404) { res.status(404).end('所请求的页面不存在') }
})
})
// 服务器监听地址
express.listen(8080, () => {
console.log('服务器已启动!')
})
我们创建新属性 state,并将 context.state 属性转为字符串赋值给它,然后再 head 标签下,客户端 vue 脚本前,增加一个 script 标签,内容是,创建一个全局对象,值是 state 的值,这样我们就成功了一半,已经将服务端请求得出的结果传给了客户端,我们可以看下浏览器接受 html 文件的图片:
下面,我们将完成桥梁的最后一步,将 __INITIAL_STATE__
属性同步到客户端 vue 实例的 store 上去
改造 entry.client.js:
/* entry-client.js */
import { createApp } from '../src/main'
const app = createApp()
// 同步服务端信息
if (window.__INITIAL_STATE__) {
app.$store.replaceState(window.__INITIAL_STATE__)
}
// 绑定app根元素
window.onload = function() {
app.$mount('#app')
}
使用 store 的 replaceState 方法,同步服务端的 store 到客户端的 store,我们看下浏览器的情况:
是的,很嗨!我们不光服务器端的 seo 有了请求的内容,并且通过同步状态,不用花费多的请求,就让客户端也获取了相应的数据,至此项目三结束,我们已经实现了完整的服务端渲染项目
撒花 =★,°:.☆( ̄▽ ̄)/$:.°★ 。
补充
正真的项目开发中,光有上面的实现内容,只能算是一个会动的骨架(有点吓人),实际上还有很多自定义的内容可以加上去,比方说 vue 的 vue-meta 来管理 head 的 seo 相关标签,以及通过 gulp、webpack、nodemon 之类的进一步的完善自动化 SSR 开发构建环境等等,等待着大家去探索
结语
这篇文章用了端午三天时间完成(第一次写文章是这样的...)
关于 ssr 以及前端别的很多内容其实早就想写了,但是一坐到电脑面前就不知所措,这回放假才鼓起了勇气,还是很开心的,加深了我对 ssr 以及相关知识的认识
希望能够帮助到刚接触 vue 服务端渲染像我当时一样不知所措的人
代码下载地址
https://github.com/tomashi/Vue2.0-SSR