代码地址:https://github.com/hbxywdk/vue-ssr-demo
运行
// 安装依赖包
npm install
// 开发模式
npm run dev
// 生产模式
npm run build
npm run start
// 注意:vue 与 vue-server-renderer版本必须 一致。
什么是单页面应用(SPA)
随着React、Vue等框架的流行,越来越多的网站开始使用这些框架编写,React、Vue都有自己的路由,使用了路由制作的网站其实就是单页面应用。
单页面项目打包出来只有一个html文件,看似各个页面之间无刷新切换,其实是通过hash,或者history api来进行路由的显隐,并通过ajax拉取数据来实现响应功能。因为整个webapp就一个html,所以叫单页面。
单页面应用虽然带来了一部分用户体验的提升,但也带来了新的问题:
1.首页白屏问题
因为SPA所有的内容都是由客户端js渲染出的来,就会导致js体积过大,客户端渲染也需要一定的时间,这两者的时间在浏览器上所带来的就是一段时间的白屏等待。
2.SEO问题
由于SPA所有的内容都是由js渲染出来的,html中其实算是空白一片,对于爬虫来说无论爬什么地址爬到的就是一片空白,就像下面这样。
title
什么是服务端渲染
这里说的服务端渲染并不是指传统的jsp那种,而是服务器根据请求的路径,直接读取Vue代码,将需要首屏展示的数据直接由服务端请求并将其注入到HTML中返回给前端,这样前端拿到的就不再是空白一片的页面。
先以Vue官方例子简单了解一下:
// app.js
const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url
},
template: `访问的 URL 是: {{ url }}`
})
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(`
Hello
${html}
`)
})
})
server.listen(8080)
使用express对所有的get请求都做同样的处理,new一个Vue,使用vue-server-renderer的renderToString的方法传入Vue实例,回调函数中的html就是最终得到的DOM结构。
当然我们也可以使用外部html模板:
Hello
下面我们先建一个最基本的项目
一个文章列表页,一个文章详情页。
// router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '../pages/Home.vue'
import Detail from '../pages/Detail.vue'
Vue.use(Router)
export function createRouter () {
return new Router({
mode: 'history', // SSR必须使用history模式
scrollBehavior: () => ({y: 0}),
routes: [
// 主页
{ path: '/', component: Home },
// 详情
{ path: '/detail', component: Detail },
]
})
}
两个页面文件都差不多:
具体方法就不写了,获取文章列表,点击跳转详情,这里没什么难度。
要使用Vue服务端渲染,我们就得引入Vuex,使用Vuex的目的,就是将我们平时写在组件里的首屏请求方法移到Vuex中,比如有一个名为fetchLists的action:
// store中的actions部分
let actions = {
// 获取文章列表
fetchLists ({ commit }, data) {
return axios.get('https://cnodejs.org/api/v1/topics?page=' + data.page)
.then((res) => {
if (res.data.success) {
commit('setLists', res.data.data)
}
})
}
我们为首页组件加上一个名为asyncData的方法
export default {
asyncData (store, route) { // 两个参数为store和当前路由信息,此函数会在组件实例化之前调用,所以无法访问this
return store.dispatch('fetchLists', { page: 1 })
},
name: "home",
// 数据
data() {
return {
page: 1 // 页码
}
},
// 计算属性
computed: {
lists () {
return this.$store.getters.getLists // 文章列表
},
},
mounted() {
},
// 方法
methods: {
},
// 子组件
components: {
}
}
当一个页面请求进入,会根据路径找到对应组件,拿到它的asyncData方法,执行asyncData方法,触发对应的action,从服务端获取数据并注入HTML中返回给前端。
server.js
根据不同的访问路径,返回不同的内容
app.get('*', (req, res) => {
// 未渲染好返回
if (!renderer) {
return res.end('waiting for a moment.')
}
res.setHeader("Content-Type", "text/html")
// 错误处理
const errorHandler = err => {
if (err && err.code === 404) {
res.status(404).end('404 | Page Not Found')
} else {
res.status(500).end('500 | Internal Server Error')
}
}
// 将 Vue 实例渲染为一个 Node.js 流 (stream)
renderer.renderToStream({ url: req.url })
.on('error', errorHandler)
.on('end', () => console.log(`ok`))
.pipe(res)
})
app.listen(3002, () => {
console.log(`server started at localhost:${port}`)
})
入口文件
这里会有两个入口文件:
entry-client.js 客户端使用的入口文件。
import Vue from 'vue'
import { app, store, router } from './app'
// 上面有说过,服务端获取到的数据会以DOM的形式插入HTML中,同时,还会将获取到的数据写入到window.__INITIAL_STATE__中
// 客户端使用 window.__INITIAL_STATE__ 中的数据替换store中的数据
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
// 将Vue实例挂载在#app上
app.$mount('#app')
})
entry-server.js 服务端入口文件
import { app, router, store } from './app'
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
// push对应访问路径
router.push(context.url)
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents() // 返回当前路径匹配到的组件
// 匹配不到的路由,reject(),返回 404
if (!matchedComponents.length) {
reject({ code: 404 })
}
// 执行组件的 asyncData 方法 拿数据 全部数据返回后 为window.__INITIAL_STATE__赋值
Promise.all(matchedComponents.map(component => {
return component.asyncData && component.asyncData(store, router.currentRoute) // 调用组件asyncData方法 传入store与当前路由信息
}))
.then(() => {
// 为window.__INITIAL_STATE__ 赋值 (可理解为window.__INITIAL_STATE__ = store.state)
context.state = store.state
resolve(app)
}).catch(reject)
})
})
}
上面这段代码根据路由信息获取组件的 asyncData 方法拉取数据。
然后是两个webpack配置文件:
webpack.client.config.js 用于打包文件到dist目录下,
webpack.server.config.js 用于生成传递给 createBundleRenderer 的 server bundle
不需要服务端渲染的数据处理
对于不需要服务端渲染的数据,我们可以将其写在mounted钩子函数中,写法和我们的平时写法相同。
mounted() {
axios.get('http://www.test.com')
.then((res) => {
this.test = res.data.RESULT_DATA
})
}
路由切换后的数据获取
当我们把代码运行起来后,点击文章详情,会发现文章详情的对应请求并没有发出,这是因为服务器在收到第一次请求后就已经把所有代码给了客户端,客户端的路由切换,服务端并不会收到请求,所以对应组件的 asyncData 方法并不会被执行。
这里的解决方法就是注册全局mixin.
全局mixin,beforeRouteEnter,切换路由时,调用asyncData方法拉取数据进行客户端渲染(注意beforeRouteEnter无法直接获取到当前组件this,需使用next((vm)=>{ vm即为this }) 获取)
Vue.mixin({
beforeRouteEnter (to, from, next) {
console.log('beforeRouteEnter')
next((vm)=>{
const { asyncData } = vm.$options
if (asyncData) {
asyncData(vm.$store, vm.$route).then(next).catch(next)
} else {
next()
}
})
}
})
最终运行后,查看网页源代码,可以看到网站不再是空白一片了。
讲到这里差不多就讲完了,从头搭建一个服务端渲染的应用是比较复杂的,其实我自己也不能说完全明白,这里我仅把我自己的理解写出来,或许我的描述并不是很好,但是希望对大家能有所帮助。