vue服务器端渲染(SSR)实战

4,217 阅读5分钟

什么是服务端渲染(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的实现。

实现原理

实现流程

Vue 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}`)
})

整个流程大致如下:

  1. 创建渲染器,设置渲染模版、绑定Server Bundle
  2. 依次装载一系列Express中间件,用于压缩响应、处理静态资源等
  3. 渲染器将装载好的Vue的实例渲染为字符串,响应到客户端,并设置缓存(以cacheKey为标识)
  4. 再次访问时以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中使用多余的空格。