跳转到内容

服务器端渲染(SSR)

概述

什么是SSR?

Vue.js是一个用于构建客户端应用程序的框架。默认情况下,Vue组件在浏览器中以输出形式产生和操作DOM。然而,也可以将相同的组件渲染成HTML字符串在服务器上,直接发送到浏览器,最后将静态标记在客户端“水合”成一个完全交互的应用程序。

服务器渲染的Vue.js应用程序也可以被认为是“同构”或“通用”的,因为您的应用程序的大部分代码既在服务器上运行,也在客户端上运行。

为什么使用SSR?

与客户端单页应用程序(SPA)相比,SSR的主要优势在于

  • 更快的内容加载时间:这在互联网速度慢或设备速度慢的情况下更为明显。服务器渲染的标记不需要等待所有JavaScript下载和执行后才能显示,因此您的用户将更快地看到完全渲染的页面。此外,在首次访问时,数据获取是在服务器端进行的,这可能比客户端到数据库的连接速度更快。这通常会导致核心Web Vitals指标有所改善,用户体验更好,对于内容加载时间与转化率直接相关的应用程序来说可能至关重要。

  • 统一的心理模型:您可以使用相同的语言和相同的声明性、组件导向的心理模型来开发整个应用程序,而不是在后端模板系统和前端框架之间来回跳跃。

  • 更好的SEO:搜索引擎爬虫将直接看到完全渲染的页面。

    提示

    目前,谷歌和必应可以很好地索引同步JavaScript应用程序。关键词是同步。如果您的应用程序以加载旋转器开始,然后通过Ajax获取内容,爬虫将不会等待您完成。这意味着如果您的页面上有SEO重要内容是异步获取的,那么SSR可能是必要的。

在使用SSR时,还有一些权衡需要考虑

  • 开发限制。特定于浏览器的代码只能在某些生命周期钩子内使用;一些外部库可能需要特殊处理才能在服务器渲染的应用程序中运行。

  • 更复杂的构建设置和部署需求。与可以部署在任何静态文件服务器上的完全静态SPA不同,服务器渲染的应用程序需要一个可以运行Node.js服务器的环境。

  • 更多的服务器端负载。在Node.js中渲染整个应用程序比仅提供静态文件更耗费CPU资源,因此如果预期流量很大,请准备相应的服务器负载,并明智地采用缓存策略。

在为您的应用使用SSR之前,您首先应该问的问题是,您是否真的需要它。这主要取决于时间到内容对于您的应用有多重要。例如,如果您正在构建一个内部仪表板,初始加载时多几百毫秒并不会太重要,那么SSR将是一种过度设计。然而,在时间到内容绝对关键的情况下,SSR可以帮助您实现最佳可能的初始加载性能。

SSR 与 SSG

静态站生成(SSG),也称为预渲染,是构建快速网站的另一项流行技术。如果用于服务器端渲染页面的数据对每个用户都是相同的,那么我们可以在请求每次到来时渲染页面,而是在构建过程中提前渲染一次。预渲染的页面将生成并作为静态HTML文件提供服务。

SSG 保留了 SSR 应用的相同性能特征:它提供了极佳的时间到内容性能。同时,与 SSR 应用相比,它更便宜且更容易部署,因为输出是静态HTML和资产。这里的重点是 静态:SSG 只能应用于提供静态数据(即在构建时已知且在请求之间不会改变的数据)的页面。每次数据更改,都需要新的部署。

如果您只是调查 SSR 以改善少数营销页面(例如 //about/contact 等)的 SEO,那么您可能需要 SSG 而不是 SSR。SSG 也非常适合文档网站或博客等基于内容的网站。实际上,您现在正在阅读的网站就是使用 VitePress 静态站生成器构建的,它是一个基于 Vue 的静态站生成器。

基本教程

渲染应用

让我们看看 Vue SSR 的最基本示例。

  1. 创建一个新的目录并 cd 进入它
  2. 运行 npm init -y
  3. package.json 中添加 "type": "module",以便 Node.js 以 ES 模块模式 运行。
  4. 运行 npm install vue
  5. 创建一个 example.js 文件
js
// this runs in Node.js on the server.
import { createSSRApp } from 'vue'
// Vue's server-rendering API is exposed under `vue/server-renderer`.
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})

然后运行

sh
> node example.js

它应该在命令行中打印以下内容

<button>1</button>

renderToString() 接受一个 Vue 应用实例,并返回一个解析为应用渲染的 HTML 的 Promise。也可以使用 Node.js 流 APIWeb 流 API 进行流式渲染。请参阅 SSR API 参考 了解详细信息。

然后我们可以将 Vue SSR 代码移动到服务器请求处理程序中,该处理程序将应用标记包裹在完整的页面 HTML 中。我们将使用 express 进行下一步操作

  • 运行 npm install express
  • 创建以下 server.js 文件
js
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const server = express()

server.get('/', (req, res) => {
  const app = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('ready')
})

最后,运行 node server.js 并访问 https://127.0.0.1:3000。您应该看到带有按钮的页面正在工作。

在 StackBlitz 上尝试它

客户端激活

如果您点击按钮,您会注意到数字没有改变。由于我们没有在浏览器中加载Vue,因此客户端的HTML是完全静态的。

为了使客户端应用程序交互式,Vue需要执行解耦步骤。在解耦过程中,它创建与服务器上运行相同的Vue应用程序,将每个组件与其应控制的DOM节点匹配,并附加DOM事件监听器。

要在解耦模式下安装应用程序,我们需要使用createSSRApp()而不是createApp()

js
// this runs in the browser.
import { createSSRApp } from 'vue'

const app = createSSRApp({
  // ...same app as on server
})

// mounting an SSR app on the client assumes
// the HTML was pre-rendered and will perform
// hydration instead of mounting new DOM nodes.
app.mount('#app')

代码结构

注意,我们需要与服务器上相同的同一应用程序实现。这就是我们需要开始考虑SSR应用程序中的代码结构的地方 - 我们如何在服务器和客户端之间共享相同的应用程序代码?

在这里,我们将演示最基础的设置。首先,让我们将应用程序创建逻辑拆分到一个专门的文件app.js

js
// app.js (shared between server and client)
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })
}

该文件及其依赖项在服务器和客户端之间共享 - 我们称它们为通用代码。在编写通用代码时,您需要注意一些事情,我们将在下面讨论。

客户端入口导入通用代码,创建应用程序并执行挂载

js
// client.js
import { createApp } from './app.js'

createApp().mount('#app')

服务器在请求处理程序中使用相同的创建应用程序逻辑

js
// server.js (irrelevant code omitted)
import { createApp } from './app.js'

server.get('/', (req, res) => {
  const app = createApp()
  renderToString(app).then(html => {
    // ...
  })
})

此外,为了在浏览器中加载客户端文件,我们还需要

  1. 通过在server.js中添加server.use(express.static('.'))来提供客户端文件。
  2. 通过在HTML外壳中添加<script type="module" src="/client.js"></script>来加载客户端入口
  3. 通过在HTML外壳中添加一个导入映射来支持在浏览器中使用import * from 'vue'

在StackBlitz上尝试完成的示例。按钮现在交互式了!

高级解决方案

从示例到生产就绪的SSR应用程序需要更多的工作。我们需要

  • 支持Vue SFC和其他构建步骤要求。实际上,我们需要为同一应用程序协调两个构建:一个用于客户端,一个用于服务器。

    提示

    Vue组件在用于SSR时编译方式不同 - 模板被编译成字符串连接,而不是虚拟DOM渲染函数,以提高渲染性能。

  • 在服务器请求处理程序中,使用正确的客户端资产链接和最佳资源提示渲染HTML。我们可能还需要在SSR和SSG模式之间切换,甚至在同一应用程序中混合两者。

  • 以通用方式管理路由、数据获取和状态管理存储。

完整的实现相当复杂,并且取决于您选择的构建工具链。因此,我们强烈建议选择一个更高级、有见地的解决方案,该解决方案可以为您抽象复杂性。以下我们将介绍Vue生态系统中一些推荐的SSR解决方案。

Nuxt

Nuxt 是建立在 Vue 生态系统之上的一个高级框架,它为编写通用 Vue 应用程序提供了流畅的开发体验。更好的是,您还可以将其用作静态网站生成器!我们强烈推荐您尝试一下。

Quasar

Quasar 是一个完整的基于 Vue 的解决方案,允许您使用一个代码库针对 SPA、SSR、PWA、移动应用、桌面应用和浏览器扩展。它不仅处理构建设置,还提供了一套符合 Material Design 标准的 UI 组件。

Vite SSR

Vite 提供了对 Vue 服务器端渲染的内建 支持,但它是有意为之的低级。如果您希望直接使用 Vite,请查看社区插件 vite-plugin-ssr,它为您抽象了很多具有挑战性的细节。

您还可以在这里找到使用手动设置的 Vue + Vite SSR 项目的示例 (链接),这可以作为构建的基础。请注意,这仅建议您对 SSR / 构建工具有经验,并且真的想对高级架构拥有完全的控制权。

编写 SSR 代码

无论您的构建设置或高级框架选择如何,都有些原则适用于所有 Vue SSR 应用程序。

服务器上的响应性

在 SSR 过程中,每个请求 URL 都映射到我们应用程序的期望状态。没有用户交互和 DOM 更新,因此在服务器上不需要响应性。默认情况下,为了更好的性能,响应性在 SSR 期间是禁用的。

组件生命周期钩子

由于没有动态更新,因此在 SSR 期间将不会调用生命周期钩子,如 mountedonMountedupdatedonUpdated,它们只会在客户端执行。在 SSR 期间被调用的唯一钩子是 beforeCreatecreated

您应该避免在 beforeCreatesetup()<script setup> 的根作用域中编写产生需要清理的副作用代码。这种副作用的一个例子是使用 setInterval 设置定时器。在客户端仅代码中,我们可以在 beforeUnmountonBeforeUnmountunmountedonUnmounted 中设置并销毁定时器。然而,由于在 SSR 期间不会调用卸载钩子,定时器将永远存在。为了避免这种情况,请将副作用代码移到 mountedonMounted 中。

访问特定平台 API

通用代码不能假设访问特定平台的 API,因此如果您的代码直接使用仅浏览器全局变量如 windowdocument,它们将在 Node.js 中执行时抛出错误,反之亦然。

对于在服务器和客户端之间共享但具有不同平台 API 的任务,建议在通用 API 中封装特定平台的实现,或者使用为您执行此操作的库。例如,您可以使用 node-fetch 在服务器和客户端上使用相同的 fetch API。

对于仅浏览器API,常见的做法是在客户端生命周期钩子中延迟访问它们,例如 mountedonMounted

注意,如果第三方库没有考虑到通用使用,那么将其集成到服务器端渲染应用程序中可能会很棘手。你可能可以通过模拟一些全局变量来让它工作,但这将是 hacky 的,可能会干扰其他库的环境检测代码。

跨请求状态污染

在状态管理章节中,我们介绍了一种使用 Reactivity API 的简单状态管理模式 简单状态管理模式。在 SSR 上下文中,这种模式需要一些额外的调整。

该模式在 JavaScript 模块的根作用域中声明共享状态。这使得它们成为 单例 - 即在整个应用程序的生命周期中只有一个响应式对象的实例。在纯客户端 Vue 应用程序中,这按预期工作,因为我们的应用程序中的模块在每次浏览器页面访问时都会全新初始化。

然而,在 SSR 上下文中,应用程序模块通常只在服务器启动时初始化一次。相同的模块实例将在多个服务器请求之间重用,我们的单例状态对象也是如此。如果我们用针对特定用户的数据修改共享单例状态,它可能会意外地泄露到来自另一个用户的请求。我们称这种情况为 跨请求状态污染

技术上,我们可以在每个请求上重新初始化所有 JavaScript 模块,就像我们在浏览器中做的那样。然而,初始化 JavaScript 模块可能会很昂贵,这将显著影响服务器性能。

建议的解决方案是在每个请求上创建整个应用程序的新实例,包括路由和全局存储。然后,而不是直接在我们的组件中导入它,我们通过 应用级提供 提供共享状态,并在需要它的组件中注入。

js
// app.js (shared between server and client)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'

// called on each request
export function createApp() {
  const app = createSSRApp(/* ... */)
  // create new instance of store per request
  const store = createStore(/* ... */)
  // provide store at the app level
  app.provide('store', store)
  // also expose store for hydration purposes
  return { app, store }
}

如 Pinia 这样的状态管理库正是为此而设计的。有关更多详细信息,请参阅 Pinia 的 SSR 指南

渲染不一致

如果预渲染的 HTML 的 DOM 结构与客户端应用程序的预期输出不匹配,将出现渲染不一致错误。渲染不一致通常由以下原因引起

  1. 模板包含无效的 HTML 嵌套结构,浏览器本地的 HTML 解析行为将其“纠正”。例如,一个常见的陷阱是 <div> 不能放在 <p>

    html
    <p><div>hi</div></p>

    如果我们在我们服务器端渲染的 HTML 中产生这种情况,浏览器将在遇到 <div> 时终止第一个 <p>,并将其解析成以下 DOM 结构

    html
    <p></p>
    <div>hi</div>
    <p></p>
  2. 渲染过程中使用的数据包含随机生成的值。由于同一应用程序将运行两次 - 一次在服务器上,一次在客户端 - 随机值在这两次运行之间不一定相同。有两种方法可以避免随机值引起的差异

    1. 使用 v-if + onMounted 仅在客户端渲染依赖于随机值的部分。您的框架也可能具有内置功能使这更容易,例如 VitePress 中的 <ClientOnly> 组件。

    2. 使用支持使用种子生成随机数的随机数生成库,并保证服务器运行和客户端运行使用相同的种子(例如,通过在序列化状态中包含种子并在客户端检索它)。

  3. 服务器和客户端位于不同的时区。有时,我们可能需要将时间戳转换为用户的本地时间。然而,服务器运行时的时区和客户端运行时的时区并不总是相同的,我们在服务器运行时可能无法可靠地知道用户的时区。在这种情况下,本地时间转换也应作为仅客户端的操作来执行。

当Vue遇到数据恢复不匹配时,它会尝试自动恢复并调整预渲染的DOM以匹配客户端状态。这会导致一些渲染性能损失,因为错误的节点被丢弃,新节点被挂载,但在大多数情况下,应用应该能够按预期继续工作。尽管如此,最好在开发过程中消除数据恢复不匹配。

抑制数据恢复不匹配

在Vue 3.5+中,可以使用data-allow-mismatch属性有选择地抑制不可避免的数据恢复不匹配。

自定义指令

由于大多数自定义指令涉及直接DOM操作,它们在服务器端渲染期间被忽略。但是,如果您想指定自定义指令应该如何渲染(即它应该添加哪些属性到渲染的元素),您可以使用getSSRProps指令钩子。

js
const myDirective = {
  mounted(el, binding) {
    // client-side implementation:
    // directly update the DOM
    el.id = binding.value
  },
  getSSRProps(binding) {
    // server-side implementation:
    // return the props to be rendered.
    // getSSRProps only receives the directive binding.
    return {
      id: binding.value
    }
  }
}

Teleports

Teleports在服务器端渲染期间需要特殊处理。如果渲染的应用包含Teleports,则Teleports的内容将不会是渲染字符串的一部分。一个更简单的解决方案是在挂载时条件性地渲染Teleport。

如果您确实需要渲染Teleports的内容,它们在ssr上下文对象的teleports属性下暴露。

js
const ctx = {}
const html = await renderToString(app, ctx)

console.log(ctx.teleports) // { '#teleported': 'teleported content' }

您需要将Teleports的标记注入到最终页面HTML中的正确位置,就像您需要注入主应用标记一样。

提示

当使用Teleports和服务器端渲染一起时,避免针对body - 通常,<body>将包含其他服务器端渲染的内容,这使得Teleports无法确定水合的正确起始位置。

相反,优先使用专用容器,例如<div id="teleported"></div>,其中只包含Teleports的内容。

服务器端渲染(SSR)已加载