共计 8763 个字符,预计需要花费 22 分钟才能阅读完成。
一、为什么应用 SSR?
在传统 vue 单页面利用中,页面的渲染都是由 js 实现,如下图所示,在服务端返回的 html 文件中,body
中只有一个 div
标签和一个 script
标签,页面其余的 dom 构造都将由 bundle.js
生成,而后挂载到 <div id="app"></div>
上。这让搜索引擎爬虫抓取工具无奈爬取页面的内容,如果 SEO 对你的站点很重要,则你可能须要服务器端渲染 (SSR) 解决此问题。
除了 SEO,应用 SSR 还能放慢首屏的出现速度,因为服务端间接返回渲染好的页面 html,不须要 js 就能看到残缺渲染的页面。比起单页利用通常比拟大的 js 文件,这部分代码量很小,所以首屏的达到工夫会更快,白屏的工夫更短。
当然,SSR 的应用也有一些局限性,首先,开发条件受限,在服务端渲染中,created 和 beforeCreate 之外的生命周期钩子不可用。其次,更多的服务器端负载,在服务端中渲染残缺的应用程序,显然会比仅仅提供动态文件的服务器更加占用 CPU 资源。此外,SSR 在部署方面有更多要求。与能够部署在任何动态文件服务器上的齐全动态单页面应用程序(SPA)不同,服务器渲染应用程序,须要处于 Node.js 的运行环境。所以波及到 SSR 技术选型的时候,要综合思考它的优缺点,看看是否有必要应用。
二、根底性能实现
SSR 的实质就服务端返回渲染好的 html 文档。咱们先在我的项目根目录启动一个服务器,而后返回一个 html 文档。这里咱们应用 koa 作为服务端框架。
//server.js | |
const Koa = require('koa') | |
const router = require('koa-router')() | |
const koa = new Koa() | |
koa.use(router.routes()) | |
router.get('/',(ctx)=>{ | |
ctx.body = `<!DOCTYPE html> // 要返回给客户端的 html | |
<html lang="en"> | |
<head><title>Vue SSR</title></head> | |
<body> | |
<div>This is a server render page</div> | |
</body> | |
</html>` | |
}) | |
koa.listen(9000, () => {console.log('server is listening in 9000'); | |
}) |
在命令行启动服务器: node server.js
,而后在浏览器拜访http://localhost:9000/
,服务端回返回的内容如下,浏览会依据这段 html,渲染出页面。
vue-server-renderer
当然,要返回的 html 字符串能够是由 vue 模板生成的,这就须要用到 vue-server-renderer
,它会 基于 Vue 实例生成 html 字符串 ,是 Vue SSR 的外围。在下面的server.js
中稍作批改:
const Koa = require('koa') | |
const router = require('koa-router')() | |
const koa = new Koa() | |
koa.use(router.routes()) | |
const Vue = require('Vue') // 导入 Vue,用于创立 Vue 实例 | |
const renderer = require('vue-server-renderer').createRenderer() // 创立一个 renderer 实例 | |
const app = new Vue({ // 创立 Vue 实例 | |
template: `<div>{{msg}}</div>`, | |
data(){ | |
return {msg: 'This is renderred by vue-server-renderer'} | |
} | |
}) | |
router.get('/',(ctx)=>{ | |
// 调用 renderer 实例的 renderToString 办法,将 Vue 实例渲染成字符串 | |
// 该办法承受两个参数,第一个是 Vue 实例,第二个是一个回调函数,在渲染实现后执行 | |
renderer.renderToString(app, (err, html) => { // 渲染失去的字符串作为回调函数的第二个参数传入 | |
ctx.body = `<!DOCTYPE html> | |
<html lang="en"> | |
<head><title>Vue SSR</title></head> | |
<body> | |
${html} // 将渲染失去的字符串拼接到要返回的后果中 | |
</body> | |
</html>` | |
}) | |
}) | |
koa.listen(9000, () => {console.log('server is listening in 9000'); | |
}) |
重启服务器,再拜访:
这样,咱们就实现了一个极其根底的 Vue SSR。然而不太具备实操性,咱们在理论我的项目开发时,是不可能这样写的,咱们会模块化地搭建我的项目,而后通过打包工具打包成一个或多个 js 文件。
正式一点的应用
搭建一个模块化的 vue 我的项目
咱们模块化地搭建一个简略地 vue 我的项目,用 vue-router
治理路由。
// 打包入口文件 src/main.js | |
import Vue from 'vue' | |
import App from './App.vue' | |
import router from './router' | |
Vue.config.productionTip = false | |
new Vue({ | |
el: '#app', | |
router, | |
render: h => h(App) | |
}) |
// src/App.vue | |
<template> | |
<div id="app"> | |
<div id="nav"> | |
<router-link to="/">Home</router-link> | | |
<router-link to="/about">About</router-link> | |
</div> | |
<router-view/> | |
</div> | |
</template> | |
<style lang="less"> | |
#app{ | |
margin: 0 auto; | |
width: 700px; | |
#nav{ | |
margin-bottom: 20px; | |
text-align: center; | |
} | |
} | |
</style> |
// src/router/index.js | |
import Vue from 'vue' | |
import VueRouter from 'vue-router' | |
import Home from '../views/Home.vue' | |
import About from '../views/About.vue' | |
Vue.use(VueRouter) | |
const routes = [ | |
{ | |
path: '/', | |
name: 'Home', | |
component: Home | |
}, | |
{ | |
path: '/about', | |
name: 'About', | |
component: About | |
} | |
] | |
export default new VueRouter({ | |
mode: 'history', | |
routes | |
}) |
// src/views/Home.vue | |
<template> | |
<div class="home"> | |
<h1>This is home page</h1> | |
</div> | |
</template> |
// src/views/About.vue | |
<template> | |
<div class="about"> | |
<h1>This is an about page</h1> | |
</div> | |
</template> |
以 src/main.js
作为打包入口文件,依照客户端单页面的形式打包,而后在浏览器关上,渲染后果如下:
将我的项目革新成服务端渲染
咱们接下来就把下面这个 demo 革新成服务端渲染。
次要的革新点:服务端渲染须要 Vue 实例,每一次客户端申请页面,服务端渲染都是用一个新的 Vue 实例,不同的用户不能拜访同一个 Vue 实例。所以服务端须要一个生成 Vue 实例的工厂函数,每次渲染由这个工厂函数生成 Vue 实例。
新建一个专门用于服务端渲染的入口文件entry.server.js
:
import {createApp} from './main' | |
export default context => { // 生成 Vue 实例的工厂函数,return new Promise((resolve, reject) => {const app = createApp() | |
const router = app.$router | |
const {url} = context //context 蕴含服务端须要传递给 Vue 实例的一些数据,比方这里的路由 | |
const {fullPath} = router.resolve(url).route | |
if(fullPath !== url){ // 判断以后路由在 Vue 实例中是否存在 | |
return reject({url: fullPath}) | |
} | |
router.push(url) // 设置 Vue 实例的以后路由 | |
router.onReady(() => {const matchedComponents = router.getMatchedComponents() // 判断以后路由是否有对应组件 | |
if(!matchedComponents.length){ | |
return reject({code: 404}) | |
} | |
resolve(app) // 返回 Vue 实例 | |
}, reject) | |
}) | |
} |
将 src/main.js
革新成如下:
import Vue from 'vue' | |
import App from './App.vue' | |
import {createRouter} from './router' | |
Vue.config.productionTip = false | |
export function createApp(){const router = createRouter() | |
const app = new Vue({ | |
router, | |
render: h => h(App) | |
}) | |
return app | |
} |
基于 entry.server.js
打包的 webpack
配置,也要作一些批改:
target: 'node', | |
entry: './src/entry.server.js', | |
output: {path: path.join(__dirname, '../dist'), | |
filename: 'bundle.server.js', | |
libraryTarget: 'commonjs2' | |
}, |
而后,在服务端,咱们就能够通过打包后的 bundle.server.js
进行服务端渲染了。
//server.js 作如下扭转:const renderer = require('vue-server-renderer').createRenderer({ // 基于模板创立一个 renderer 实例 | |
template: require('fs').readFileSync('./index.template.html', 'utf-8') | |
}) | |
const app = require('./dist/bundle.server.js').default // 导入 Vue 实例工厂函数 | |
router.get('/(.*)', async (ctx, next) => { | |
const context = { // 获取路由,用于传递给 Vue 实例 | |
url: ctx.url | |
} | |
let htmlStr | |
await app(context).then( res => { // 生成 Vue 实例,并传递给 renderer 实例生成字符串 | |
renderer.renderToString(res, context, (err,html)=>{if(!err){htmlStr = html} | |
}) | |
}) | |
ctx.body = htmlStr | |
}); |
能够看到,这里咱们曾经实现了服务端的渲染,页面 dom 构造呈现在了由服务器返回的 html 文档中。
客户端激活
咱们曾经窥见了一点在理论我的项目应用 SSR 的曙光,然而这只是第一步。当初每次点击 Home/About 都会从服务端申请 html 资源,单页面的前端路由劣势并没有施展。接下来咱们将加上一步客户端激活,让网页利用同时具备单页面的劣势。这也是 Vue SSR 的官网流程。
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的动态 HTML,使其变为由 Vue 治理的动静 DOM 的过程。 激活原理参考官网。
操作起来很简略,就是在返回的 html 页面中,加上 client bundle,用于在客户端治理以后 html。上面咱们来打包生成它。
新建一个entry.client.js
:
import {createApp} from './main' | |
const app = createApp() | |
const router = app.$router | |
router.onReady(() => {app.$mount('#app') // 服务端渲染默认会生成一个 id 为 app 的 div | |
}) |
打包的 webpack
配置:
entry: './src/entry.client.js', | |
output: {path: path.join(__dirname, '../dist'), | |
filename: 'bundle.client.js' | |
}, |
打包实现后,就是把 bundle.client.js
加到 html 中,之前咱们是基于模板渲染:
const renderer = require('vue-server-renderer').createRenderer({ // 基于模板创立一个 renderer 实例 | |
template: require('fs').readFileSync('./index.template.html', 'utf-8') | |
}) |
所以只须要把 bundle.client.js
加到 index.template.html
就能够了。
//index.template.html | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head><title>Vue SSR</title></head> | |
<body> | |
<!--vue-ssr-outlet--> | |
</body> | |
<script src="bundle.client.js"></script> | |
</html> |
重启服务,再拜访,就能够看到,点击 Home/About 切换路由时,不会再从服务器申请 html 文档了。
三、申请数据
在理论我的项目中,页面往往是由从接口申请的数据填充渲染进去的,上面咱们将用申请的数据来渲染页面。为了不便(省事),就不另外写数据接口了,咱们去申请豆瓣的电影排行前 20 的数据。
具体思路,就是如果一个组件须要申请数据,当它是服务端渲染时,咱们在服务端申请数据,当客户端以 SPA 的路由切换形式应用该组件时,也能在客户端发送 ajax 申请数据。
咱们将借助 vuex 来实现。 因为它将数据挂载在 vue 实例上,传递拜访数据真的很不便。
在服务器端申请数据
回顾咱们客户端渲染惯例的申请数据的场景,在 created 或 mounted 钩子函数中发送 ajax 申请,申请胜利后把返回数据写到实例的 data 中。SSR 的申请数据不能这样,因为 ajax 申请是异步申请,申请收回去之后,数据还没返回,后端就曾经渲染完了,ajax 申请的数据无奈填充到页面中。
所以咱们间接由服务端发送申请获取数据,也就是一个服务器向另一个服务器发送 http 申请,和客户端向服务器发送申请不同,这里咱们应用 axios,这两种它都反对。
对于每个须要申请数据的组件,咱们将在组件上暴露出一个自定义静态方法 asyncData,因为此函数会在组件实例化之前调用,所以它无法访问 this。须要将 store 和路由信息作为参数传递进去。
//Home.vue | |
<template> | |
<div class="movie-list"> | |
<div v-for="(item, index) in list" class="movie"> | |
<img class="cover" :src="item.cover"> | |
<p> | |
<span class="title">{{item.title}}</span> | |
<span class="rate">{{item.rate}}</span> | |
</p> | |
</div> | |
</div> | |
</template> | |
<script> | |
export default { | |
name: 'MovieList', | |
asyncData ({store,route}) { // 自定义静态方法 asyncData | |
return store.dispatch('getTopList') | |
}, | |
/***** | |
在这里,执行 asyncData,就会调用 getTopList 办法去申请数据 | |
并将数据更新到 vue 实例的 $store.state 中 | |
actions: {getTopList (store) {return top20().then((res) => {store.commit('setTopList', res.data.subjects) | |
}) | |
} | |
} | |
*****/ | |
computed: {list () {return this.$store.state.topList} | |
}, | |
created () {if(!this.$store.state.topList){this.$store.dispatch('getTopList') | |
} | |
} | |
} | |
</script> |
在 entry.server.js
中,咱们通过路由取得了与 router.getMatchedComponents() 相匹配的组件,如果组件暴露出 asyncData,就调用这个办法。而后咱们须要将解析实现的状态,附加到渲染上下文 (render context) 中。
import {createApp} from './main' | |
export default context => {return new Promise((resolve, reject) => {const app = createApp() | |
const router = app.$router | |
const store = app.$store | |
... | |
router.onReady(() => {const matchedComponents = router.getMatchedComponents() | |
if(!matchedComponents.length){ | |
return reject({code: 404}) | |
} | |
Promise.all(matchedComponents.map(Component => {if (Component.asyncData) { | |
// 如果组件暴露出 asyncData,就调用这个办法 | |
// 在本例中,就会去申请豆瓣数据,并把数据更新到 app.$store.state | |
return Component.asyncData({ | |
store, | |
route: router.currentRoute | |
}) | |
} | |
})).then(() => { | |
context.state = store.state // 将 app.$store.state 赋值给渲染上下文 context.state,前面同步数据到客户端的时候会用到。resolve(app) | |
}).catch(reject) | |
}, reject) | |
}) | |
}) | |
} |
当在数据更新到 app.$store.state 后,服务端渲染的 html 中就有数据了。可是页面是空白的,并且发送了 ajax 申请。起因是当客户端激活其实通过了二次渲染,也就是当 bundle.client.js 加载并执行后,页面由 bundle.client.js 再次渲染,通常来说,渲染后果会跟之前一样,所以觉察不到。
防止客户端反复申请数据
这里是跨域,所以 ajax 申请没有胜利。如果不是跨域,页面也能呈现内容的,是由客户端发送 ajax 取得的数据渲染而得。但在服务端曾经申请的数据,在客户端应该防止反复申请,怎么同步数据到客户端?
当应用 template 时,context.state 将作为 window.__INITIAL_STATE__
状态,主动嵌入到最终的 HTML 中。而在客户端激活时,在挂载到应用程序之前,客户端的 vm.$store 就应该获取到window.__INITIAL_STATE__
状态。
1. 在 server.js
中,为 renderer.renderToString
办法增加第二个参数context
,context.state 将作为 window.__INITIAL_STATE__
状态,主动嵌入到最终的 HTML 中。
router.get('/(.*)', async (ctx, next) => { | |
const context = {url: ctx.url} | |
let htmlStr | |
await app(context).then( res => {renderer.renderToString(res, context, (err,html)=>{ // 增加第二个参数 context | |
if(!err){htmlStr = html} | |
}) | |
}) | |
ctx.body = htmlStr | |
}); |
-
批改
entry.client.js
:import {createApp} from './main' const app = createApp() const router = app.$router const store = app.$store if (window.__INITIAL_STATE__) { // 如果 window.__INITIAL_STATE__有内容,就存到 app.$store 中 store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => {app.$mount('#app') }) 这样就防止了客户端反复申请数据,最终成果如下,能够看到客户端没有发送 ajax 申请了。
这个我的项目搭建很繁难,次要是整顿一下 Vue SSR 的原理、流程,理论开发能够抉择 nuxt.js 这种比拟成熟的框架。
我的项目地址: https://github.com/alasolala/…
参考资料
解密 Vue SSR
手把手教你搭建 SSR(vue/vue-cli + express)
Vue SSR Guide