Skip to content

SSR注意事项

注意事项#

服务端的响应性

Vue 的响应式系统主要用于追踪数据的变化,并在数据变化时自动更新 DOM。在客户端应用中,用户的交互、数据更新都会触发这种响应式机制,从而使页面保持最新状态。但在服务器端渲染(SSR)的过程中,应用仅仅是一次性生成 HTML 字符串,随后发送给浏览器;这期间根本没有用户交互,也没有实际的 DOM 更新操作。

如果在服务器端继续启用 Vue 的响应式系统,会产生一些不必要的性能开销。

为了提高 SSR 的性能和减少不必要的资源消耗,Vue 默认在服务端渲染期间禁用了响应式转换。这样,在服务端渲染时,数据仅作为静态的状态来使用,不会再被包裹为响应式对象,而是直接用于生成最终的 HTML 字符串。

例如:

const app = Vue.createSSRApp({
data() {
return { count: 1 }
},
template: `<div>{{ count }}</div>`,
})

最终生成的结构:

<div>1</div>

组件生命周期钩子

由于服务端渲染只发生一次,且渲染完后不会有数据更新,也没有真实的 DOM,因此大部分与“动态交互”或“DOM 操作”相关的生命周期钩子(onMounted 或 onUpdated)不会在 SSR 期间被调用,而只会在客户端运行。

例如:

<template>
<div>App页面</div>
</template>
<script setup>
console.log('created')
onMounted(() => {
console.log('onMounted')
})
</script>

这就带来一个问题:如果你在组件的根作用域(比如在 setup() 或 <script setup> 中直接写一些副作用代码)执行了像 setInterval 这样的定时器逻辑,那么在 SSR 期间,这段代码也会执行,并且因为 SSR 渲染结束后不会调用任何“卸载”钩子(比如 onBeforeUnmount 或 onUnmounted),导致定时器无法被清理,从而可能引起内存泄漏。

<template>
<div>{{ count }}</div>
</template>
<script setup>
const count = ref(0)
// 该定时器代码由于实在根作用域下,因此在服务器端也会执行
// 但是清除计时器的操作实在钩子方法里面,而钩子方法仅在客户端才会执行
const timer = setInterval(() => {
count.value++
console.log('计时器运行中', count.value)
}, 1000)
// 客户端卸载时清除定时器
onUnmounted(() => {
clearInterval(timer)
})
</script>

最佳做法是将需要产生副作用的代码放到 onMounted 钩子中。因为 onMounted 只会在浏览器中挂载真实 DOM 后被调用,而在 SSR 中根本不会执行。

<template>
<div>{{ count }}</div>
</template>
<script setup>
const count = ref(0)
let timer = null
// 现在启动计时器和清除计时器都只在客户端才会执行
onMounted(() => {
timer = setInterval(() => {
count.value++
console.log('计时器运行中', count.value)
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>

访问平台特有API

通用代码不能访问平台特有的 API,如果你的代码直接使用了浏览器特有的全局变量,比如 windowdocument,他们会在 Node.js 运行时报错,反过来也一样。例如:

// 错误示例:直接在通用代码中访问浏览器特有的 API
if (window.innerWidth > 800) {
// 执行某些操作
}

更好的方式是将对平台特有 API 的访问封装在特定的生命周期钩子中,确保这些代码仅在客户端执行。例如,可以将对 window 的访问放在 onMounted 钩子中:

import { onMounted } from 'vue'
onMounted(() => {
if (window.innerWidth > 800) {
// 执行某些操作
}
})

这样 window 的访问仅在客户端挂载后执行,避免了在服务器端渲染期间的错误。

对于在服务器和客户端之间共享,但使用了不同的平台 API 的任务,建议将平台特定的实现封装在一个通用的 API 中,或者使用能够在不同平台上运行的库。

举个例子,现在需要发送请求,平时都是使用 fetch,但是 fetch 是属于浏览器环境特有的 API,SSR 的时候会报错。更好的解决方案是使用 node-fetch(第三方库,封装了通用代码,浏览器环境和Node环境都可以使用)

跨请求状态污染

在 SSR 环境下,应用模块通常只在服务器启动时初始化一次,同一个应用模块会在多个服务器请求之间被复用,从而导致这个状态可能会意外地泄露给另一个用户的请求。这种情况称为跨请求状态污染

解决方案是在每个请求中为整个应用创建一个全新的实例,包括 router 和全局 store。然后,我们使用 provide 方法来提供共享状态,并将其注入到需要它的组件中,而不是直接在组件中将其导入:

// app.js (在服务端和客户端间共享)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'
// 每次请求时调用
export function createApp() {
const app = createSSRApp(/* ... */)
// 对每个请求都创建新的 store 实例
const store = createStore(/* ... */)
// 提供应用级别的 store
app.provide('store', store)
// 也为激活过程暴露出 store
return { app, store }
}

像 Pinia 这样的状态管理库在设计时就考虑到了这一点。

export function createRouter() {
// 该方法就是返回一个新的router实例
return _createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
})
}
// createApp方法,每一次请求过来,会创建一系列新的实例
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
const pinia = createPinia()
// ...
return { app, router, pinia }
}

水合不匹配

Hydration completed but contains mismatches

简单来讲就是服务器端渲染出来的 html 结构和客户端激活时得到的 html 结构不一致导致的。

最常见的原因有以下几种:

  1. 结构不符合规范

    <p><div>123</div></p>

    服务器端遇到上面的结构,会直接渲染成对应的字符串。但是在浏览器环境存在纠错机制

    <p></p>
    <div>123</div>
    <p></p>
  2. 数据包含随机值

    • 如果是在 Nuxt 中可以使用 ClientOnly 组件来解决
    • 如果是原生 SSR 环境,那么可以将产生随机数的逻辑放到 mounted 钩子方法里面
    • 也可以使用一些第三方库,例如 seedrandom
  3. 服务端和客户端的时区不一致

    • 解决方案就是将和时区相关的处理放到客户端去操作

-EOF-