晴明的博客园 GitHub      CodePen      CodeWars     

[vue] 同构 (ssr)

个人示例
官方示例

编写通用代码

编写通用代码

"通用"代码 - 即运行在服务器和客户端的代码。由于用例和平台 API 的差异,当运行在不同环境中时,代码将不会完全相同。

服务器上的数据响应

在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。

因为实际的渲染过程需要确定性,所以我们也将在服务器上“预取”数据("pre-fetching" data) - 这意味着在我们开始渲染时,我们的应用程序就已经解析完成其状态。也就是说,将数据进行响应式的过程在服务器上是多余的,所以默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销。

组件生命周期钩子函数

由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。

此外还需要注意的是,应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。
在纯客户端(client-side only)的代码中,可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,应将副作用代码移动到 beforeMount 或 mounted 生命周期中。

访问特定平台(Platform-Specific) API

通用代码不可接受特定平台的 API,因此如果代码中直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

对于共享于服务器和客户端,但用于不同平台 API 的任务(task),建议将平台特定实现包含在通用 API 中,或者使用为执行此操作的 library。例如,axios 是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。

对于仅浏览器可用的 API,通常方式是,在「纯客户端(client-only)」的生命周期钩子函数中惰性访问(lazily access)?它们。

自定义指令

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

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

源码结构

避免状态单例

当编写纯客户端(client-only)代码时,可以每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。
当代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染(cross-request state pollution)。

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  });
  renderer.renderToString(app,(err, html) => {
    res.end(html);
  })
})

因此,不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

// app.js
const Vue = require('vue');
module.exports = function createApp (context) {
  return new Vue({
    data: {
      url: context.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })
}


// server.js
const createApp = require('./app');
server.get('*', (req, res) => {
  const context = { url: req.url }
  const app = createApp(context)
  renderer.renderToString(app,(err, html) => {
    res.end(html);
  })
})

同样的规则也适用于 router、store 和 event bus 实例。不应该直接从模块导出并将其导入到应用程序中,而是需要在 createApp 中创建一个新的实例,并从根 Vue 实例注入。

在使用带有 { runInNewContext: true } 的 bundle renderer 时,可以消除此约束,但是由于需要为每个请求创建一个新的 vm 上下文,因此伴随有一些显著性能开销。

构建步骤

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # universal entry
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器

路由和代码分割

异步组件现在可以在vue应用中的任何地方使用。

import Foo from './Foo.vue'
// 改为这样:
const Foo = () => import('./Foo.vue')

需要注意的是,需要在挂载 app 之前调用 router.onReady,因为路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。

// entry-client.js
import { createApp } from './app'
const { app, router } = createApp()
router.onReady(() => {
  app.$mount('#app')
})

router.getMatchedComponents(location?)返回目标位置或是当前路由匹配的组件数组(是数组的定义/构造类,不是实例)。通常在服务端渲染的数据预加载时时候。

数据预取和状态

数据预取存储容器(Data Store)

在服务器端渲染(SSR)期间,本质上是在渲染应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

另一个需要关注的问题是在客户端,在挂载(mount)到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。

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

使用vuex

带有逻辑配置的组件(Logic Collocation with Components)

在哪里放置「dispatch 数据预取 action」的代码?

需要通过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。
所以在路由组件中放置数据预取逻辑,是很自然的事情。

客户端数据预取(Client Data Fetching)

在客户端,处理数据预取有两种不同方式:

1 在路由导航之前解析数据:

使用此策略,应用程序会等待视图所需数据全部解析之后,再传入数据并处理当前视图。好处在于,可以直接在数据准备就绪时,传入视图渲染完整内容,但是如果数据预取需要很长时间,用户在当前视图会感受到"明显卡顿"。因此,如果使用此策略,建议提供一个数据加载指示器(data loading indicator)。
可以通过检查匹配的组件,并在全局路由钩子函数中执行 asyncData 函数,来在客户端实现此策略。
在初始路由准备就绪之后,就应该注册此钩子,这样就不必再次获取服务器提取的数据。

//Item.vue

<template>
  <div>{{ item.title }}</div>
</template>
<script>
export default {
  //暴露出一个自定义静态函数 asyncData。
  //注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。
  //需要将 store 和路由信息作为参数传递进去:
  asyncData({ store, route }) {
    // 触发 action 后,会返回 Promise
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item() {
      return this.$store.state.items[this.$route.params.id]
    }
  }
}
</script>
  // entry-client.js
  // ...忽略无关代码
  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))
      })
      if (!activated.length) {
        return next()
      }
      // 这里如果有加载指示器(loading indicator),就触发
      Promise.all(activated.map(c => {
        if (c.asyncData) {
          return c.asyncData({ store, route: to })
        }
      })).then(() => {
        // 停止加载指示器(loading indicator)
        next()
      }).catch(next)
    })
    app.$mount('#app')
  })

如果只是为了一个loading效果,个人觉得太过繁琐。

2 匹配要渲染的视图后,再获取数据

此策略将客户端数据预取逻辑,放在视图组件的 beforeMount 函数中。当路由导航被触发时,可以立即切换视图,因此应用程序具有更快的响应速度。然而,传入视图在渲染时不会有完整的可用数据。因此,对于使用此策略的每个视图组件,都需要具有条件加载状态。

可以通过纯客户端(client-only)的全局 mixin 来实现:

  Vue.mixin({
    beforeMount () {
      const { asyncData } = this.$options
      if (asyncData) {
        // 将获取数据操作分配给 promise
        // 以便在组件中,我们可以在数据准备就绪后
        // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
        this.dataPromise = asyncData({
          store: this.$store,
          route: this.$route
        })
      }
    }
  })

当路由组件重用(同一路由,但是 params 或 query 已更改,例如,从 user/1 到 user/2)时,应该也调用 asyncData 函数。
也可以通过纯客户端(client-only)的全局 mixin 来处理这个问题:

Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

Store 代码拆分(Store Code Splitting)

在大型应用程序中,Vuex store 可能会分为多个模块。
当然,也可以将这些模块代码,分割到相应的路由组件 chunk 中。

可以在路由组件的 asyncData 钩子函数中,使用 store.registerModule 惰性注册(lazy-register)这个模块:

// store/modules/foo.js
export default {
  namespaced: true,
  // 重要信息:state 必须是一个函数,
  // 因此可以创建多个实例化该模块
  state: () => ({
    count: 0
  }),
  actions: {
    inc: ({ commit }) => commit('inc')
  },
  mutations: {
    inc: state => state.count++
  }
}


// Foo.vue 路由组件
<template>
  <div>{{ fooCount }}</div>
</template>
<script>
// 在这里导入模块,而不是在 `store/index.js` 中
import fooStoreModule from '../store/modules/foo'
export default {
  asyncData ({ store }) {
    store.registerModule('foo', fooStoreModule)
    return store.dispatch('foo/inc')
  },
  // 重要信息:当多次访问路由时,
  // 避免在客户端重复注册模块。
  destroyed () {
    this.$store.unregisterModule('foo')
  },
  computed: {
    fooCount () {
      return this.$store.state.foo.count
    }
  }
}
</script>

由于模块现在是路由组件的依赖,所以它将被 webpack 移动到路由组件的异步 chunk 中。

客户端激活 (Client-side Hydration)

所谓客户端激活(注水),指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,用下面这行挂载 (mount) 应用程序:

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

由于服务器已经渲染好了 HTML,显然无需将其丢弃再重新创建所有的 DOM 元素。相反,需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果检查服务器渲染的输出结果,会注意到应用程序的根元素有一个特殊的属性:

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

data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。

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

一些需要注意的坑

使用「SSR + 客户端混合」时,需要了解的一件事是,浏览器可能会更改的一些特殊的 HTML 结构。

例如,当在 Vue 模板中写入:

<table>
  <tr><td>hi</td></tr>
</table>

浏览器会在 <table> 内部自动注入 <tbody>,然而,由于 Vue 生成的虚拟 DOM (virtual DOM) 不包含 <tbody>,所以会导致无法匹配。
为能够正确匹配,应确保在模板中写入有效的 HTML。

Bundle Renderer 指引

传入 BundleRenderer

之前假设打包的服务器端代码,将由服务器通过 require 直接使用。
这是可行的,然而在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

  1. 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')
  2. 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  3. 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。
  4. 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

bundle renderer 在调用 renderToString 时,它将自动执行「由 bundle 创建的应用程序实例」所导出的函数(传入上下文作为参数),然后渲染它。
推荐将 runInNewContext 选项设置为 false 或 'once'。

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
})
// 在服务器处理函数中……
server.get('*', (req, res) => {
  const context = { url: req.url }
  // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  renderer.renderToString(context, (err, html) => {
    // 处理异常……
    res.end(html)
  })
})

构建配置

服务器配置(Server Config)

扩展说明(Externals Caveats)

请注意,在 externals 选项中,将 CSS 文件列入白名单。这是因为从依赖模块导入的 CSS 还应该由 webpack 处理。如果导入依赖于 webpack 的任何其他类型的文件(例如 *.vue, *.sass),那么也应该将它们添加到白名单中。

如果使用 runInNewContext: 'once' 或 runInNewContext: true,那么还应该将修改 global 的 polyfill 列入白名单,例如 babel-polyfill。这是因为当使用新的上下文模式时,server bundle 中的代码具有自己的 global 对象。
由于在使用 Node 7.6+ 时,在服务器并不真正需要它,所以实际上只需在客户端 entry 导入它。

const nodeExternals = require('webpack-node-externals')
 
  target: 'node',
  externals: nodeExternals({
    // 不要外置化 webpack 需要处理的依赖模块。
    // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),

客户端配置(Client Config)

vue-server-renderer/server-plugin 和vue-server-renderer/client-plugin 似乎是把某些webpack的插件集成掉了。

自动生成的html可能看起来是这样的

<html>
  <head>
    <!-- 用于当前渲染的 chunk 会被资源预加载(preload) -->
    <link rel="preload" href="/manifest.js" as="script">
    <link rel="preload" href="/main.js" as="script">
    <link rel="preload" href="/0.js" as="script">
    <!-- 未用到的异步 chunk 会被数据预取(preload)(次要优先级) -->
    <link rel="prefetch" href="/1.js" as="script">
  </head>
  <body>
    <!-- 应用程序内容 -->
    <div data-server-rendered="true"><div>async</div></div>
    <!-- manifest chunk 优先 -->
    <script src="/manifest.js"></script>
    <!-- 在主 chunk 之前注入异步 chunk -->
    <script src="/0.js"></script>
    <script src="/main.js"></script>
  </body>
</html>

手动资源注入(Manual Asset Injection)

可以在创建 renderer 并手动执行资源注入时,传入 inject: false进行手动资源注入。

ontext.renderStyles()
这将返回内联 <style> 标签包含所有关键 CSS(critical CSS) ,其中关键 CSS 是在要用到的 *.vue 组件的渲染过程中收集的。有关更多详细信息,请查看 CSS 管理。
如果提供了 clientManifest,返回的字符串中,也将包含着 <link rel="stylesheet"> 标签内由 webpack 输出(webpack-emitted)的 CSS 文件(例如,使用 extract-text-webpack-plugin 提取的 CSS,或使用 file-loader 导入的 CSS)

context.renderState(options?: Object)
此方法序列化 context.state 并返回一个内联的 script,其中状态被嵌入在 window.INITIAL_STATE 中。

上下文状态键(context state key)和 window 状态键(window state key),都可以通过传递选项对象进行自定义:

  context.renderState({
    contextKey: 'myCustomState',
    windowKey: '__MY_STATE__'
  })
  // -> <script>window.__MY_STATE__={...}</script>

context.renderScripts()
需要 clientManifest
此方法返回引导客户端应用程序所需的 <script> 标签。当在应用程序代码中使用异步代码分割(async code-splitting)时,此方法将智能地正确的推断需要引入的那些异步 chunk。

context.renderResourceHints()
需要 clientManifest
此方法返回当前要渲染的页面,所需的 <link rel="preload/prefetch"> 资源提示(resource hint)。
默认情况下会:
预加载页面所需的 JavaScript 和 CSS 文件.
预取异步 JavaScript chunk,之后可能会用于渲染.
使用 shouldPreload 选项可以进一步自定义要预加载的文件。

context.getPreloadFiles()
需要 clientManifest
此方法不返回字符串 - 相反,它返回一个数组,此数组是由要预加载的资源文件对象所组成。这可以用在以编程方式(programmatically)执行 HTTP/2 服务器推送(HTTP/2 server push)。

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

CSS 管理

vue-style-loader(vue-loader 内部使用的 loader),具备一些服务器端渲染的特殊功能:

  1. 客户端和服务器端的通用编程体验。
  2. 在使用 bundleRenderer 时,自动注入关键 CSS(critical CSS)。
  3. 通用 CSS 提取。

启用 CSS 提取

// webpack.config.js
const ExtractTextPlugin = require('extract-text-webpack-plugin')
// CSS 提取应该只用于生产环境
// 这样我们在开发过程中仍然可以热重载
const isProduction = process.env.NODE_ENV === 'production'
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          // enable CSS extraction
          extractCSS: isProduction
        }
      },
      // ...
    ]
  },
  plugins: isProduction
    // make sure to add the plugin!
    ? [new ExtractTextPlugin({ filename: 'common.[chunkhash].css' })]
    : []
}

如果想从 JavaScript 中导入 CSS,例如,import 'foo.css',你需要配置合适的 loader:

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        // 重要:使用 vue-style-loader 替代 style-loader
        use: isProduction
          ? ExtractTextPlugin.extract({
              use: 'css-loader',
              fallback: 'vue-style-loader'
            })
          : ['vue-style-loader', 'css-loader']
      }
    ]
  },
  // ...
}

从依赖模块导入样式

在服务器端构建过程中,不应该外置化提取??。

如果使用 CSS 提取 + 使用 CommonsChunkPlugin 插件提取 vendor,在 extract-text-webpack-plugin 提取 CSS 到 vendor chunk 时将遇到问题??。为了应对这个问题,请避免在 vendor chunk 中包含 CSS 文件。

客户端 webpack 配置示例如下:

  module.exports = {
    // ...
    plugins: [
      // 将依赖模块提取到 vendor chunk 以获得更好的缓存,是很常见的做法。
      new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',
        minChunks: function (module) {
          // 一个模块被提取到 vendor chunk 时……
          return (
            // 如果它在 node_modules 中
            /node_modules/.test(module.context) &&
            // 如果 request 是一个 CSS 文件,则无需外置化提取
            !/\.css$/.test(module.request)
          )
        }
      }),
      // 提取 webpack 运行时和 manifest
      new webpack.optimize.CommonsChunkPlugin({
        name: 'manifest'
      }),
      // ...
    ]
  }

Head 管理

类似于资源注入,Head 管理遵循相同的理念:
可以在组件的生命周期中,将数据动态地追加到渲染上下文(render context),然后在模板中的占位符替换为这些数据。

可以通过 this.$ssrContext 来直接访问组件中的服务器端渲染上下文(SSR context)。

一个简单的 标题管理mixin:

// title-mixin.js
function getTitle (vm) {
  // 组件可以提供一个 `title` 选项
  // 此选项可以是一个字符串或函数
  const { title } = vm.$options
  if (title) {
    return typeof title === 'function'
      ? title.call(vm)
      : title
  }
}
const serverTitleMixin = {
  created () {
    const title = getTitle(this)
    if (title) {
      this.$ssrContext.title = title
    }
  }
}
const clientTitleMixin = {
  mounted () {
    const title = getTitle(this)
    if (title) {
      document.title = title
    }
  }
}
// 可以通过 `webpack.DefinePlugin` 注入 `VUE_ENV`
export default process.env.VUE_ENV === 'server'
  ? serverTitleMixin
  : clientTitleMixin

在路由组件可以利用以上 mixin,来控制文档标题(document title):

// Item.vue
export default {
  mixins: [titleMixin],
  title () {
    return this.item.title
  }
  asyncData ({ store, route }) {
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    item () {
      return this.$store.state.items[this.$route.params.id]
    }
  }
}

缓存

虽然 Vue 的服务器端渲染(SSR)相当快速,但是由于创建组件实例和虚拟 DOM 节点的开销,无法与纯基于字符串拼接(pure string-based)的模板的性能相当。在 SSR 性能至关重要的情况下,明智地利用缓存策略,可以极大改善响应时间并减少服务器负载。

页面级别缓存(Page-level Caching)

在大多数情况下,服务器渲染的应用程序依赖于外部数据,因此本质上页面内容是动态的,不能持续长时间缓存。然而,如果内容不是用户特定(user-specific)(即对于相同的 URL,总是为所有用户渲染相同的内容),我们可以利用名为 micro-caching 的缓存策略,来大幅度提高应用程序处理高流量的能力。

这通常在 Nginx 层完成,但是也可以在 Node.js 中实现它:

const microCache = LRU({
  max: 100,
  maxAge: 1000 // 重要提示:条目在 1 秒后过期。
})
const isCacheable = req => {
  // 实现逻辑为,检查请求是否是用户特定(user-specific)。
  // 只有非用户特定(non-user-specific)页面才会缓存
}
server.get('*', (req, res) => {
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
  }
  renderer.renderToString((err, html) => {
    res.end(html)
    if (cacheable) {
      microCache.set(req.url, html)
    }
  })
})

由于内容缓存只有一秒钟,用户将无法查看过期的内容。然而,这意味着,对于每个要缓存的页面,服务器最多只能每秒执行一次完整渲染。??

组件级别缓存(Component-level Caching)

node-lru-cache
vue-server-renderer 内置支持组件级别缓存(component-level caching)。要启用组件级别缓存,需要在创建 renderer 时提供具体缓存实现方式(cache implementation)。典型做法是传入 lru-cache:

const LRU = require('lru-cache')
const renderer = createRenderer({
  cache: LRU({
    max: 10000,
    maxAge: ...
  })
})

然后,可以通过实现 serverCacheKey 函数来缓存组件。

export default {
  name: 'item', // 必填选项
  props: ['item'],
  serverCacheKey: props => props.item.id,
  render (h) {
    return h('div', this.item.id)
  }
}

可缓存组件还必须定义一个唯一的 name 选项。
通过使用唯一的名称,每个缓存键(cache key)对应一个组件:无需担心两个组件返回同一个 key。

serverCacheKey 返回的 key 应该包含足够的信息,来表示渲染结果的具体情况。如果渲染结果仅由 props.item.id 决定,则上述是一个很好的实现。但是,如果具有相同 id 的 item 可能会随时间而变化,或者如果渲染结果依赖于其他 prop,则需要修改 getCacheKey 的实现,以考虑其他变量。

返回常量将导致组件始终被缓存,这对纯静态组件是有好处的。

何时使用组件缓存

如果 renderer 在组件渲染过程中进行缓存命中,那么它将直接重新使用整个子树的缓存结果。

这意味着在以下情况,不应该缓存组件:

  • 它具有可能依赖于全局状态的子组件。
  • 它具有对渲染上下文产生副作用(side effect)的子组件。

因此,应该小心使用组件缓存来解决性能瓶颈。在大多数情况下,不应该也不需要缓存单一实例组件。适用于缓存的最常见类型的组件,是在大的 v-for 列表中重复出现的组件。由于这些组件通常由数据库集合(database collection)中的对象驱动,它们可以使用简单的缓存策略:使用其唯一 id,再加上最后更新的时间戳,来生成其缓存键(cache key):

serverCacheKey: props => props.item.id + '::' + props.item.last_updated

Streaming(流式渲染)

对于 vue-server-renderer 的基本 renderer 和 bundle renderer 都提供开箱即用的流式渲染功能。所有你需要做的就是,用 renderToStream 替代 renderToString:

const stream = renderer.renderToStream(context)

返回的值是 Node.js stream:

let html = ''
stream.on('data', data => {
  html += data.toString()
})
stream.on('end', () => {
  console.log(html) // 渲染完成
})
stream.on('error', err => {
  // handle error...
})

流式传输说明(Streaming Caveats)

在流式渲染模式下,当 renderer 遍历虚拟 DOM 树(virtual DOM tree)时,会尽快发送数据。这意味着可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。

然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文(render context),当流(stream)启动时,这些数据将不可用。这是因为,大量上下文信息(context information)(如头信息(head information)或内联关键 CSS(inline critical CSS))需要在应用程序标记(markup)之前出现,基本上必须等待流(stream)完成后,才能开始使用这些上下文数据。

因此,如果依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。(大概意思是纯dom的用这种方式更快)

SSR的实现原理

客户端请求服务器,服务器根据请求地址获得匹配的组件,在调用匹配到的组件返回 Promise (官方是preFetch方法)来将需要的数据拿到。最后再通过
<script>window.__initial_state=data</script>
将其写入网页,最后将服务端渲染好的网页返回回去。

接下来客户端会将vuex将写入的 __initial_state__ 替换为当前的全局状态树,再用这个状态树去检查服务端渲染好的数据有没有问题。遇到没被服务端渲染的组件,再去发异步请求拿数据。说白了就是一个类似React的 shouldComponentUpdate 的Diff操作。

Vue2使用的是单向数据流,用了它,就可以通过 SSR 返回唯一一个全局状态,并确认某个组件是否已经SSR过了。

react的同构与vue的同构

都有一个服务端渲染的方法
vue是vue-server-renderer提供的,
react是react-dom/server提供的。

vue需要客户端和服务端各打一次包,react只需要一次。
vue还需要在webpack里加入’vue-server-renderer/client-plugin’和’vue-server-renderer/server-plugin’,而react只需要’webpack-manifest-plugin’.
这里很大可能是vue的该插件做了其他整合和优化。

react对redux的处理比较复杂,特别是对懒加载和ssr,而vue也应该是整合过了比较简单。

整体而言两者各有异同,可能vue本身并没有比react更适合ssr,但是奈何有个尤雨溪做掉了很多脏活。。。。

其他

启动的server会主动去找static下的index.html,而且优先级很高

Uncaught SyntaxError: Unexpected token <很可能是html中的某个文件没正确加载.

增加重复的plugin会导致打包有问题却不会报错.

webpack-dev-middle 1.x和webpack 3.x适配,但是现在默认装都是webpack-dev-middle 3.x的版本,是和webpack 4.x适配的.非常坑.

ssr的热重载方案没有文档,基本只能直接用vue-hackernews-2.0里的setup-dev-server.js.

node-fetch/isomorphic-fetch 不知道为何一直报错改用axios.而axios的拦截器据说在node端有内存泄漏的问题.

store.registerModule本身规则比较奇怪,也可能是用法不对.
store.registerModule只能用在服务器或者客户端渲染里,不能和客户端共享,暂不知晓原因.

引用的vue组件库里如果有内置的style样式,
需要target: 'node', 不然会出现document is not defined的报错.
设置了target: 'node'后可能会出现this._ssrAttr的报错.
需要设置vue-loader的options:

  loader: 'vue-loader',
  options: {
    optimizeSSR: false
  }

optimizessr
这种设置可能会出现css丢失样式的问题.
所以最终考虑就是需要将vue的样式萃取出来,也不需要额外设置target: 'node'.

ssr里配置vue-router的scrollBehavior容易出问题.

posted @ 2018-04-04 23:13  晴明桑  阅读(1357)  评论(0编辑  收藏  举报