vue ssr简析

639 阅读7分钟

1. bundle文件解析

我们简单来看下构建生成的两个文件都做了什么

1.1 vue-ssr-server-bundle.json

serverBundle有三部分内容:

entry: 服务端打包入口文件
files: 这部分内容其实就是入口文件打包后的代码
maps: 入口文件的sourceMap映射

{
  // 入口文件
  "entry": "server.bundle.js",
  "files": { // server.bundle.js打包为了以下代码
    "0.bundle.js": "exports.ids = [0];\nexports.modules = {\n...."
  },
  // 映射
  "maps": {
    "0.bundle.js": {
      "version": 3,
      "sources": [
        "webpack:///src/components/Foo.vue",
        "webpack:///./src/components/Foo.vue?482d",
        "webpack:///./src/components/Foo.vue?9694",
        "webpack:///./src/components/Foo.vue?4c83",
        "webpack:///./src/components/Foo.vue",
        "webpack:///./src/components/Foo.vue?c0f4",
        "webpack:///./src/components/Foo.vue?1b8e",
        "webpack:///./src/components/Foo.vue?1efa"
      ],
      "names": [],
     }
   }
}

1.2 vue-ssr-client-manifest.json

作用:进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk

publicPath: webpack output中设置的publicPath
all: 打包后的所有静态资源文件路径(包括map文件)
initial: 页面初始化时需要加载的文件(需要注入到模版页面中的资源),会在页面加载时配置到 preload 中,
async: 页面跳转时需要加载的文件(异步路由),会在页面加载时配置到 prefetch 中
modules: 项目的各个模块包含的文件的序号,对应 all 中文件的顺序;moduleIdentifier和 和all数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)

注意

preload:预加载的资源是本页面js,注意只是下载,并不执行,下载js不会堵塞页面渲染,真正执行是在body底部,页面渲染完成后
prefetch:加载的是下一个页面的js, 浏览器空闲时候再加载,不一定加载成功
都是加载资源文件,不是执行

{
  "publicPath": "/", webpack output中设置的publicPath
  "all": [ // 所有打包后的js文件名称
    "0.bundle.js",
    "0.bundle.js.map"
    ...
  ],
  "initial": [ // 需要注入到模版页面中的资源
    "vendor.bundle.js",
    "client.css",
    "client.bundle.js"
  ],
  "async": [ // 异步资源信息
    "0.bundle.js",
    "0.css",
    "1.bundle.js"
  ],
  "modules": { // 原始模块的依赖信息
    "14534400": [ // 模块标识
      4, // 对应all里的文件索引
      3
    ],
  }
}

2. server-entry在哪里执行

const { createBundleRenderer } = require('vue-server-renderer')

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})

let context = {}

const html = await renderer.renderToString(context)

我们看以上代码,这里的context参数就是server-entry.js中的context行参

export default async context => {
  const { app, router, store } = createApp()
  return app
}

3. 模版

3.1 注入

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

注意 <!--vue-ssr-outlet--> 注释 -- 这里将是应用程序 HTML 标记注入的地方

const app = new Vue({
  data: {
    url: req.url
  },
  template: `<div>访问的 URL 是: {{ url }}</div>`
})


const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

renderer.renderToString(app, (err, html) => {
  console.log(html) // html 将是注入应用程序内容的完整页面
})

3.2 插值

<html>
  <head>
    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

我们可以通过传入一个"渲染上下文对象",作为 renderToString 函数的第二个参数,来提供插值数据:

const context = {
  title: 'hello',
  meta: `
    <meta ...>
    <meta ...>
  `
}

renderer.renderToString(app, context, (err, html) => {
  // 页面 title 将会是 "Hello"
  // meta 标签也会注入
})

详细参考:ssr.vuejs.org/zh/guide/

4. 客户端激活

<div id="app" data-server-rendered="true">

添加data-server-rendered 让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。
注意:这里并没有添加 id="app",我们需要自行添加 ID 或其他能够选取到应用程序根元素的选择器,否则应用程序将无法正常激活。

注意:在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):

// 强制使用应用程序的激活模式
app.$mount('#app', true)

注意:在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗

详细参考:ssr.vuejs.org/zh/guide/hy…

5. 编写通用代码

5.1 数据响应

我们在服务器上需要“预取”数据 ("pre-fetching" data) - 即在渲染前,数据已经拿到了。所以将数据进行响应式的过程在服务器上是多余的,默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销

5.2 组件生命周期钩子函数

由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreatecreated 会在服务器端渲染 (SSR) 过程中被调用

应该避免在 beforeCreatecreated 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。因为在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,最好将副作用代码移动到 beforeMount 或 mounted 生命周期中

5.3 特定API

像 window 或 document,这种仅浏览器可用的全局变量,会在 Node.js 中执行时抛出错误

5.4 自定义指令

大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:

    1. 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
    1. 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供"服务器端版本(server-side version)"

详细参考:ssr.vuejs.org/zh/guide/un…

6. 数据预取和渲染

    1. 在开始渲染过程之前,需要先预取和解析好数据
    1. 服务端取到的数据必须同步到客户端 - 否则,客户端应用程序会因为使用与服务器端应用程序数据不同,导致混合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器中。在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,并在 HTML 中序列化和内联预置。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置状态。

注意beforeCreatecreated虽然可以在服务端执行,但是这样拿数据是无效的,因为获取数据是异步的,服务端渲染不会等待beforeCreatecreated的异步操作,而且不支持响应式数据,所以以下操作是无效的

async created () {
  console.log('Posts Created Start')
  const { data } = await axios({
    method: 'GET',
    url: 'https://cnodejs.org/api/v1/topics'
  })
  this.posts = data.data
  console.log('Posts Created End')
}

我们通过vuex获取数据

6.1 serverPrefetch

注意:在服务端获取数据必须返回一个Promise,因为服务端必须等待action执行完后再执行渲染操作

actions: {
  // 在服务端渲染期间务必让 action 返回一个 Promise
  async getPosts ({ commit }) {
    // return new Promise()
    const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
    commit('setPosts', data.data)
  }
}

然后在serverPrefetch钩子中调用

// Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数
serverPrefetch () {
  // 发起 action,返回 Promise
  // this.$store.dispatch('getPosts')
  return this.getPosts()
},

6.2 ssyncData

在路由组件上暴露出一个自定义静态函数 asyncData。

注意: 由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去

asyncData ({ store, route }) {
  // 触发 action 后,会返回 Promise
  return store.dispatch('fetchItem', route.params.id)
},

server-entry.js中添加asyncData

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

6.3 客户端预取数据

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

详细参考:ssr.vuejs.org/zh/guide/da…

7. head管理

建议使用vue-meta

7.1 在页面组件中设置

<template>
  <div>
    <h1>About Page</h1>
  </div>
</template>

<script>
export default {
  name: 'AboutPage',
  metaInfo: {
    title: '关于'
  }
}
</script>

<style>
  
</style>

7.2 main.js中引入

import VueMeta from 'vue-meta'
Vue.use(VueMeta)

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - 拉勾教育'
  }
})

7.3 在服务端渲染入口模块中适配

// entry-server.js
import { createApp } from './app'

export default async context => {
  const { app, router, store } = createApp()
  const meta = app.$meta()
  // 设置服务器端 router 的位置
  router.push(context.url)
  
  context.meta = meta

  // 等到 router 将可能的异步组件和钩子函数解析完
  await new Promise(router.onReady.bind(router))
  context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
  }

  return app
}

7.4 在模板页面中注入 meta 信息

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 使用三个大括号 -->
  {{{ meta.inject().title.text() }}}
  {{{ meta.inject().meta.text() }}}
</head>

详细查看:ssr.vuejs.org/zh/guide/he…

8. 路由

在 Vue 2.5 以下的版本中,服务端渲染时异步组件只能用在路由组件上。
然而在 2.5+ 的版本中,得益于核心算法的升级,异步组件现在可以在应用中的任何地方使用。

注意:所有需要在挂载 app 之前调用 router.onReady,因为路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。除了在服务器入口 (server entry) 中实现外,在客户端入口 (client entry) 我们页需要实现

router.onReady(() => {
  app.$mount('#app')
})

详细查看:ssr.vuejs.org/zh/guide/ro…

9. 构建开发模式

思路

  • 监视代码变化然后自动构建
    • 监听文件变化 chokidar
    • 将打包结果输入内存中 webpack-dev-middleware
    • 热更新 webpack-hot-middleware

9.1 setupDevServer

const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar') // 监听文件变化
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware') // 将打包结果输入内存中,相对于memory-fs更方便一些
const hotMiddleware = require('webpack-hot-middleware') // 热更新,打包后自动更新网页内容

const resolve = file => path.resolve(__dirname, file)

module.exports = (server, callback) => {
  let ready
  const onReady = new Promise(r => ready = r)

  // 监视构建 -> 更新 Renderer

  let template
  let serverBundle
  let clientManifest

  const update = () => {
    if (template && serverBundle && clientManifest) {
      ready() // resolve
      callback(serverBundle, template, clientManifest)
    }
  }

  // 监视构建 template -> 调用 update -> 更新 Renderer 渲染器
  const templatePath = path.resolve(__dirname, '../index.template.html')
  template = fs.readFileSync(templatePath, 'utf-8')
  update()
  // fs.watch、fs.watchFile
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })

  // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  const serverConfig = require('./webpack.server.config')
  const serverCompiler = webpack(serverConfig)
  const serverDevMiddleware = devMiddleware(serverCompiler, {
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  // 添加一个钩子,'.hooks.done.tap',代表编译结束。 'server'是起一个名字
  // 编译结束触发回调函数
  serverCompiler.hooks.done.tap('server', () => {
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    update()
  })
  // 读取硬盘,上面为读取内存
  // serverCompiler.watch({ 
  //   // 监视打包的可选配置参数 
  // }, (err, stats) => { 
  //   // console.log('err => ', err) 
  //   // console.log('stats => ', stats) 
  //   if (err) throw err 
  //   if (stats.hasErrors()) return
  //   // read bundle generated by vue-ssr-webpack-plugin 
  //   serverBundle = JSON.parse(fs.readFileSync('./dist/vue-ssr-server-bundle.json', 'utf-8')) 
  //   // 更新 Renderer 
  //   update() 
  // })

  // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  const clientConfig = require('./webpack.client.config')
  clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本, quiet去掉日志输出, reload=true如果页面卡死,强制刷新页面
    clientConfig.entry.app
  ]
  clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash,所以去掉hash
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  })
  clientCompiler.hooks.done.tap('client', () => {
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
  })
  server.use(hotMiddleware(clientCompiler, {
    log: false // 关闭它本身的日志输出
  }))

  // server.use('/dist', express.static('./dist'))读取的是物理磁盘中的文件,读不到内存文件,所以需要将 clientDevMiddleware 挂载到 Express 服务中
  // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  server.use(clientDevMiddleware)

  return onReady
}

9.2 方式2

const Router = require("koa-router")
const axios = require("axios")
const config = require("../../config/webpack.config")
const path = require("path")
const fs = require("fs")
// 相当于fs,但是不写入磁盘中
const MemoryFS = require("memory-fs")
const webpack = require("webpack")
const VueServerRenderer = require("vue-server-renderer")

const serverRender = require("./server-render")
const serverConfig = require("../../build/webpack.server.config")

// 运行webpack
const serverCompiler = webpack(serverConfig)
const mfs = new MemoryFS()
// 输出到mfs
serverCompiler.outputFileSystem = mfs

// 记录每次打包生成文件
let bundle
serverCompiler.watch({}, (err, stats) => {
  // 监听,修改文件时重新打包
  if (err) throw err // 配置文件错误
  if(stats.hasErrors()) return // 我们自己的代码错误
  stats = stats.toJson()
  // 报出错误(不是webpack打包的错误)
  stats.errors.forEach(err => console.log(err))
  // 报出警告
  stats.warnings.forEach(warn => console.warn(warn))

  const bundlePath = path.join(
      // 拼接输出路径
      serverConfig.output.path,
      "vue-ssr-server-bundle.json"
  )
  // 读取文件
  bundle = JSON.parse(mfs.readFileSync(bundlePath, "utf-8"))
  console.log("new bundle generated")
})

const handleSSR = async ctx => {
  if (!bundle) {
    ctx.body = "你等一会,别着急......"
    return
  }

  const clientManifestResp = await axios.get(
    // 获取vue-ssr-client-manifest.json
    `${config.cdnUrl}vue-ssr-client-manifest.json`
  )
  const clientManifest = clientManifestResp.data

  const template = fs.readFileSync(
    // 读取模版
    path.join(__dirname, "../server.template.ejs"),
    "utf-8"
  )

  // 创建一个 Renderer 实例
  const renderer = VueServerRenderer.createBundleRenderer(bundle, {
    inject: false, // 不使用vue自己的模版
    clientManifest // 带有script标签的js文件引用
  })

  await serverRender(ctx, renderer, template)
}

const router = new Router()
router.get("*", handleSSR)

module.exports = router

10. 官方demo

vue-hackernews-2.0