什么是服务端渲染(SSR)?
SSR(Server-Side Rendering),在SPA(Single-Page Application)出现之前,网页就是在服务端渲染的。服务器接收到客户端请求后,将数据和模板拼接成完整的页面响应到客户端,客户端将响应结果渲染出来。如果用户需要浏览新的页面,则需要重复这个过程。随着Angular、React和Vue的兴起,SPA开始流行,单页面应用可以在不重载整个页面的情况下,通过ajax和服务器进行交互,高效更新部分页面,这无疑带来了良好的用户体验。然而,对于需要SEO、追求首屏速度的页面,使用SPA是糟糕的。如果我们想使用Vue,又需要考虑到SEO、首屏渲染速度,那该怎么办?好在Vue是支持服务端渲染的,接下来我们主要说的是Vue的服务端渲染。
Vue SSR适用场景及解决的问题
我们主要在管理后台系统和内嵌H5电商页中使用Vue,对于管理后台系统,不需要考虑SEO和首屏渲染时间,所以是否用SPA的方式其实问题不大。而对于电商页,虽然不需要SEO,但是首屏渲染变得十分重要。一般的SPA页面打开时,HTML大体的结构如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/app.js"></script>
</body>
</html>
复制代码
这种情况下,HTML和JS加载成功后通过JS再发起请求,再将响应的内容填入到div容器中,这就存在页面最开始白屏的问题。服务端渲染将这个过程放在了服务端,请求获取响应后服务端将HTML填充好直接返回给浏览器,浏览器将整个完整的HTML直接渲染出来。显而易见,服务端渲染少了在浏览器加载的过程,解决了页面最开始白屏的问题,明显的提高了首屏渲染的速度。
目前我们主要在电商导购页、挖客分享页中使用Vue的SSR,接下来我们主要讲SSR的实现。
实现原理
实现流程
如上图所示有两个入口文件Server entry和Client entry,分别经webpack打包成服务端用的Server Bundle和客户端用的Client Bundle。
服务端:当Node Server收到来自客户端的请求后, BundleRenderer 会读取Server Bundle,并且执行它,而 Server Bundle实现了数据预取并将填充数据的Vue实例挂载在HTML模版上,接下来BundleRenderer将HTML渲染为字符串,最后将完整的HTML返回给客户端。
客户端:浏览器收到HTML后,客户端加载了Client Bundle,通过app.$mount('#app')
的方式将Vue实例挂载在服务端返回的静态HTML上。如:
<div id="app" data-server-rendered="true">
复制代码
data-server-rendered
特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式(Hydration)进行挂载。
目录结构
.
├── build
│ ├── setup-dev-server.js # dev服务器端设置 增加中间件支持
│ ├── webpack.base.config.js # 基本配置
│ ├── webpack.client.config.js # 客户端配置
│ └── webpack.server.config.js # 服务端配置
├── cache_key.js # 根据参数判断是否从缓存中获取
├── package.json # 项目依赖
├── process.debug.json # debug环境下的pm2配置文件
├── process.json # 生产环境下pm2配置文件
├── server.js # express 服务端入口文件
├── src
│ ├── api
│ │ ├── create-api-client.js # 客户端请求相关配置
│ │ ├── create-api-server.js # 服务器请求相关配置
│ │ └── index.js # api请求
│ ├── app.js # 主入口文件
│ ├── config # 相关配置
│ ├── entry-client.js # 客户端入口文件
│ ├── entry-server.js # 服务端入口文件
│ ├── router # 路由
│ ├── store # store
│ ├── templates # 模版
│ └── views
复制代码
相关文件
server.js
// 创建express应用
const app = express()
// 读取模版文件
const template = fs.readFileSync(resolve('./src/templates/index.template.html'), 'utf-8')
// 调用vue-server-renderer的createBundleRenderer方法创建渲染器,并设置HTML模板,之后将服务端预取的数据填充至模板中
function createRenderer (bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
template,
basedir: resolve('./dist'),
runInNewContext: false
}))
}
let renderer
let readyPromise
if (!isDev) {
// 生产环境下,引入由webpack vue-ssr-webpack-plugin插件生成的server bundle
const bundle = require('./dist/vue-ssr-server-bundle.json')
// 引入由 vue-server-renderer/client-plugin 生成的客户端构建 manifest 对象。此对象包含了 webpack 整个构建过程的信息,从而可以让 bundle renderer 自动推导需要在 HTML 模板中注入的内容。
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
// vue-server-renderer创建bundle渲染器并绑定server bundle
renderer = createRenderer(bundle, {
clientManifest
})
} else {
// 开发环境下,使用dev-server来通过回调把内存中的bundle文件取回
// 通过dev server的webpack-dev-middleware和webpack-hot-middleware实现客户端代码的热更新
readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
renderer = createRenderer(bundle, options)
})
}
// 设置静态资源访问
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isDev ? 0 : 1000 * 60 * 60 * 24 * 30
})
// 相关中间件 压缩响应文件 处理静态资源等
app.use(...)
// 设置缓存时间
const microCache = LRU({
maxAge: 1000 * 60 * 1
})
const isCacheable = req => useMicroCache
function render (req, res) {
const s = Date.now()
res.setHeader('Content-Type', 'text/html')
// 错误处理
const handleError = err => {}
// 根据path和query获取cacheKey
let cacheKey = getCacheKey(req.path, req.query)
// 生产环境下默认开启缓存
const cacheable = isCacheable(req)
if (cacheable) {
const hit = microCache.get(cacheKey)
if (hit) {
// 从缓存中获取
console.log(`cache hit! key: ${cacheKey} query: ${JSON.stringify(req.query)}`)
return res.end(hit)
}
}
// 设置请求的url
const context = {
title: '',
url: req.url,
}
// 将Vue实例渲染为字符串,传入上下文对象。
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.end(html)
// 设置缓存
if (cacheable) {
if (!isProd) {
console.log(`set cache, key: ${cacheKey}`)
}
microCache.set(cacheKey, html)
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
// 启动一个服务并监听8080端口
app.get('*', !isDev ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = process.env.PORT || 8080
const server = http.createServer(app)
server.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
复制代码
整个流程大致如下:
- 创建渲染器,设置渲染模版、绑定Server Bundle
- 依次装载一系列Express中间件,用于压缩响应、处理静态资源等
- 渲染器将装载好的Vue的实例渲染为字符串,响应到客户端,并设置缓存(以cacheKey为标识)
- 再次访问时以cacheKey为标识,判断是否从缓存中获取
entry.server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
const { url, req } = context
const fullPath = router.resolve(url).route.fullPath
if (fullPath !== url) {
return reject({ url: fullPath })
}
// 切换路由到请求的url
router.push(url)
// 在路由完成初始导航时调用,可以解析所有的异步进入钩子和路由初始化相关联的异步组件,有效确保服务端渲染时服务端和客户端输出的一致。
router.onReady(() => {
// 获取该路由相匹配的Vue components
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
reject({ code: 404 })
}
// 执行匹配组件中的asyncData
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute,
req
}))).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,
// 并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
if (router.currentRoute.meta) {
context.title = router.currentRoute.meta.title
}
// 返回一个初始化完整的Vue实例
resolve(app)
}).catch(reject)
}, reject)
})
}
复制代码
entry-client.js
import 'es6-promise/auto'
import { createApp } from './app'
const { app, router, store } = createApp()
// 由于服务端渲染时,context.state 作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。在客户端,在挂载到应用程序之前,state为window.__INITIAL_STATE__。
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
// 添加路由钩子函数,用于处理 asyncData.
// 在初始路由 resolve 后执行,
// 以便我们不会二次预取(double-fetch)已有的数据。
// 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。 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)
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
next()
})
.catch(next)
})
// 挂载在DOM上
app.$mount('#app')
})
复制代码
遇到的问题
1. 本地存储
以往在使用SPA时,我们一般使用localStorage和sessionStorage进行部分信息的本地存储,有时候发起请求的时候需要带上这些信息。然而在使用SSR时,我们在asyncData这个钩子中发起请求获取数据,此时并不能获取到window对象下的localStorage这个对象。 我们将信息存储在cookie中,在asyncData获取数据时,通过req.headers获取cookie。
2. 避开服务端与浏览器差异
这个问题其实和第一个问题有些类似,服务端和浏览器最大的差别在于有无window对象。我们可以通过判断去避开:
// 解决移动端300ms延迟问题
if (typeof window !== "undefined") {
const Fastclick = require('fastclick')
Fastclick.attach(document.body)
}
复制代码
其实更好的解决方式是在entry-client.js中:
import FastClick from 'fastclick'
FastClick.attach(document.body)
复制代码
3. not matching
[vue warn]The client-side rendered virtual DOM tree is not matching server-rendered content
复制代码
这个问题是服务端与客户端渲染的HTML不一致导致的。很大可能是出现{{ msg }}
这样的写法中的多余空格导致的,我们要尽力避免在template中使用多余的空格。