Vue3之服务端渲染

124 阅读6分钟

前言

Vue的代码不仅可以在浏览器中运行并输出DOM,同时还可以在Node.js环境运行,将组件渲染为字符串并发送个浏览器。这两种渲染方式即客户端渲染和服务端渲染。

同构渲染

在传统的SSR中,所有的HTML都在服务端生成(比如PHP、JSP等),页面跳转就是全页面刷新,虽然有着较好的SEO和首屏体验,但用户交互差,前后端耦合,不适合大型项目使用。 后来随着AJAX的出现,页面骨架由服务端渲染,内容通过AJAX动态加载,实现局部更新;此时已有动态交互,但核心页面仍然依赖SSR,也开始出现SEO问题。 再后来SPA(单应用页面)成为主流架构,前端完全接管UI渲染,提供了极佳的用户体验,前后端解耦,前端工程化逐渐成熟。但SEO不友好,首屏白屏时间长等问题难以解决。 由于众多网站有SEO的需求和前端框架能力的增加,同构渲染这一技术出现了,即首屏由服务端渲染保证速度和SEO,激活后转为SPA提供交互体验。这样既能有较好的SEO和用户体验。

Vue3的 SSR基本原理

  • 构建两个Bundle
    • 服务端Bundle:用于在 Node.js 环境中将Vue应用渲染为HTML字符串
    • 客户端Bundle:用于在浏览器激活(hydrate)一渲染HTML,并接管后续交互

渲染大致流程

  1. 用户请求页面
  2. 服务端调用Vue3的createSSRApp创建应用实例
  3. 调用renderToString(app)将应用渲染为HTML字符串
  4. 服务端将包含预渲染HTML的完整页面(通常嵌入客户端JS脚本)返回给浏览器
  5. 浏览器加载HTML并显示内容
  6. 客服端Vue应用启动,执行hydration,将静态HTML激活为可交互的Vue应用

Hydration 这是SSR的关键步骤:客户端Vue会接管服务端生成的DOM,为其添加事件监听器、响应式绑定等,使其变成一个完整的SPA。Vue3对hydration做了大量优化,支持渐进式hyration和流体SSR(通过renderToStream

Vue3 SSR的关键注意事项

  • 仅在服务端运行的代码:避免在组件中直接使用windowdocument等浏览器API,需用if (import.meta.env.SSR)或者typeof window !== 'undefined'等条件判断环境
  • 生命周期注意:有些钩子是在服务端执行的,比如beforeCreate和created生命周期在服务端渲染时执行,应该避免在这两个生命周期里产生全局副作用的代码,例如使用setInterval设置定时器,在SSR期间创建了但不会销毁,造成服务器内存溢出。
  • 数据预取:在服务端渲染前,必须获取组件所需数据(如API请求),否则HTML中会是空状态,通常通过onServerPrefetch()生命周期钩子或自定义逻辑实现
  • 状态同步问题:服务端获取的数据需注入到客服端,避免客户端重复请求或状态不一致,常见做法,将状态序列化为window.__INITIAL_STATE__,客户端启动时读取
  • 状态管理:使用vue-router 和 pinia/vuex 时,需确保在服务端和客户端创建新的实例(避免状态污染),每个请求必须创建新的 store 实例,否则用户 A 的数据可能泄露给用户 B
  • 路由守卫:beforeEach等路由守卫在服务端也会执行,如果守卫中包含浏览器逻辑,需要判断环境
  • 第三方库的兼容性:需要确保适用于服务端还是客户端
代码位置 / API服务端执行?说明
<script setup> 整体✅ 是包括所有变量、函数、响应式声明
refreactivecomputed✅ 是用于生成初始 HTML
watchwatchEffect✅ 是但监听无意义(无交互),可能造成副作用
onServerPrefetch✅ 是专为 SSR 数据预取设计
onBeforeCreateonCreated✅ 是组件创建阶段
onBeforeMountonMounted❌ 否涉及 DOM,仅客户端
onBeforeUpdateonUpdated❌ 否更新阶段,仅客户端
onBeforeUnmountonUnmounted❌ 否仅客户端

同构渲染中,服务端渲染(SSR)只发生在「用户首次访问页面」时(即输入 URL 或刷新页面);后续的路由跳转默认是客户端渲染(CSR),除非你显式配置为每次跳转都走 SSR。

用户输入 URL → [服务端] 渲染 HTML → 浏览器显示 → Vue 激活(hydration)
       ↓
点击链接跳转 → [客户端] Vue Router 切换 → AJAX 请求数据 → 更新 DOM
       ↓
再次点击 → 同上(始终在客户端)
       ↓
用户刷新页面 → 回到第一步(再次触发 SSR)

如何实现同构渲染?

手动实现

项目结构

my-vue3-ssr/
├── src/
│   ├── app.js                 # 创建应用实例(同构)
│   ├── entry-client.js        # 客户端入口
│   ├── entry-server.js        # 服务端入口
│   ├── router.js              # 路由配置
│   ├── store.js               # Pinia store
│   ├── App.vue                # 根组件
│   └── pages/
│       ├── Home.vue
│       └── Article.vue
├── server.js                  # Node.js 服务
├── index.html                 # 客户端模板
├── vite.config.js             # Vite 配置
└── package.json

核心代码实现

src/app.js

// src/app.js
import { createSSRApp, h } from 'vue'
import { createRouter } from './router'
import { createPinia } from 'pinia'
import App from './App.vue'

export function createApp(ssrContext = {}) {
  const pinia = createPinia()
  const router = createRouter()
  const app = createSSRApp(App)

  app.use(pinia)
  app.use(router)

  return { app, router, pinia }
}

src/router.js —— 路由(支持 SSR)

// src/router.js
import { createRouter as _createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import Home from './pages/Home.vue'
import Article from './pages/Article.vue'

export function createRouter() {
 // 服务端用 memory history,客户端用 web history
 const history = import.meta.env.SSR
   ? createMemoryHistory()
   : createWebHistory()

 const routes = [
   { path: '/', component: Home, name: 'Home' },
   { path: '/article/:id', component: Article, name: 'Article' }
 ]

 return _createRouter({ history, routes })
}

 src/store.js —— Pinia Store(示例)  ```js // src/stores/article.js import { defineStore } from 'pinia'

export const useArticleStore = defineStore('article', { state: () => ({ title: '', content: '' }), actions: { async fetchArticle(id) { // 模拟 API 调用 const res = await fetch(https://jsonplaceholder.typicode.com/posts/${id}) const data = await res.json() this.title = data.title this.content = data.body } } })

注意:实际项目中,**API 调用应封装为可同构的函数**


**`src/pages/Article.vue` —— 支持 SSR 数据预取**
```js
<!-- src/pages/Article.vue -->
<script setup>
import { useArticleStore } from '@/stores/article'
import { useRoute } from 'vue-router'
import { onServerPrefetch, onMounted } from 'vue'
import { useHead } from '@vueuse/head' // 可选:SEO 标签

const route = useRoute()
const store = useArticleStore()

// 服务端预取 关键:`onServerPrefetch` 只在服务端执行,确保数据在渲染前加载。
onServerPrefetch(async () => {
  await store.fetchArticle(route.params.id)
})

// 客户端 fallback(如直接访问)
onMounted(async () => {
  if (!store.title) {
    await store.fetchArticle(route.params.id)
  }
})

// SEO 优化(需额外安装 @vueuse/head)
useHead({
  title: store.title,
  meta: [{ name: 'description', content: store.content.substring(0, 100) }]
})
</script>

<template>
  <div>
    <h1>{{ store.title }}</h1>
    <p>{{ store.content }}</p>
  </div>
</template>

src/entry-server.js —— 服务端入口

// src/entry-server.js
import { renderToString } from 'vue/server-renderer'
import { createApp } from './app'

export async function render(url, manifest) {
  const { app, router, pinia } = createApp()

  // 设置路由
  await router.push(url)
  await router.isReady()

  // 渲染 HTML
  const appHtml = await renderToString(app)

  // 序列化 Pinia 状态
  const state = JSON.stringify(pinia.state.value).replace(/</g, '\\u003c')

  // 读取客户端入口文件名(用于注入 script)
  const entryFile = manifest['src/entry-client.js']

  return {
    appHtml,
    state,
    entryFile
  }
}

src/entry-client.js —— 客户端入口

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

const { app, router, pinia } = createApp()

// 恢复服务端注入的状态
if (window.__INITIAL_STATE__) {
  pinia.state.value = window.__INITIAL_STATE__
}

// 激活应用(hydration)
router.isReady().then(() => {
  app.mount('#app', true) // true 表示是 hydration
})

server.js —— Node.js 服务

// server.js
import fs from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import express from 'express'
import { render } from './dist/server/entry-server.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const manifest = JSON.parse(
  fs.readFileSync(join(__dirname, 'dist/client/ssr-manifest.json'), 'utf-8')
)

const template = fs.readFileSync(
  join(__dirname, 'dist/client/index.html'),
  'utf-8'
)

const app = express()

// 静态资源
app.use('/assets', express.static(join(__dirname, 'dist/client/assets')))

// SSR 路由
app.get('*', async (req, res) => {
  try {
    const { appHtml, state, entryFile } = await render(req.url, manifest)
    
    const html = template
      .replace('<!--app-html-->', appHtml)
      .replace('<!--state-->', `<script>window.__INITIAL_STATE__ = ${state}</script>`)
      .replace('<!--entry-->', `<script type="module" src="/${entryFile}"></script>`)

    res.setHeader('Content-Type', 'text/html')
    res.send(html)
  } catch (e) {
    console.error(e)
    res.status(500).send('Internal Server Error')
  }
})

app.listen(3000, () => {
  console.log('SSR server running on http://localhost:3000')
})

index.html —— 客户端模板

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My SSR App</title>
</head>
<body>
  <div id="app"><!--app-html--></div>
  <!--state-->
  <!--entry-->
</body>
</html>

vite.config.js —— 构建配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  build: {
    ssr: true,
    rollupOptions: {
      input: {
        server: resolve(__dirname, 'src/entry-server.js'),
        client: resolve(__dirname, 'src/entry-client.js')
      },
      output: {
        entryFileNames: '[name].js'
      }
    }
  },
  ssr: {
    noExternal: ['vue', 'vue-router', 'pinia'] // 确保这些包被编译
  }
})

构建Bundle

# 构建客户端
vite build --outDir dist/client

# 构建服务端(需指定 ssr)
vite build --ssr src/entry-server.js --outDir dist/server

启动服务

node server.js

基于Nuxt 实现

Nuxt 是一个基于 Vue.js 的开源“元框架”(Meta Framework),用于构建服务端渲染(SSR)静态站点生成(SSG)单页应用(SPA) 的现代化 Web 应用。 它不是替代 Vue,而是 让 Vue 更强大、更开箱即用,尤其在 SEO、性能和开发体验方面。

  • 基于文件的路由: 根据您的 app/pages/ 目录的结构定义路由。这可以使组织您的应用程序更容易,并避免手动路由配置的需要。
  • 代码分割: Nuxt 自动将您的代码分割成更小的块,这有助于减少应用程序的初始加载时间。
  • 开箱即用的服务器端渲染: Nuxt 附带内置的 SSR 功能,因此您无需自己设置单独的服务器。
  • 自动导入: 在各自的目录中编写 Vue 可组合函数和组件,无需导入即可使用,并受益于摇树优化和优化的 JS 包。
  • 数据获取实用程序: Nuxt 提供可组合函数来处理兼容 SSR 的数据获取以及不同的策略。
  • 零配置 TypeScript 支持: 通过我们自动生成的类型和 tsconfig.json,无需学习 TypeScript 即可编写类型安全的代码。
  • 配置的构建工具: 我们默认使用Vite来支持开发中的热模块替换(HMR)以及将您的代码打包用于生产,并内置最佳实践。

结语

今天的 SSR,是由前端工程师主导、基于现代框架、兼顾性能与体验的“智能渲染策略”,而非回到 PHP 时代。历史不是简单的重复,而是更高层次的综合