Vue 服务端渲染:从 HTML 到激活,再到请求隔离

原文:Vue.js 官方中文文档《服务端渲染 (SSR)》。作者归属:Vue.js 文档团队及中文翻译贡献者。本文在官方中文稿基础上重新编排、核对并补充编辑安全说明,按 CC BY 4.0 保留署名;原文依许可证按现状提供,不构成保证。

版本核对:2026-10-05 读取 Vue 3 官方中文页面;文中 data-allow-mismatch 要求 Vue 3.5+。这是机制教程,示例未经本次运行,不能当作生产框架或性能测试结果。

原文版权声明:Copyright (c) 2019-present, Yuxi (Evan) You and Vue documentation contributors。官方中文仓库正文按 CC BY 4.0 授权;仓库图片另遵各自权利人条款。本文配图为编辑原创技术示意图。

服务端针对请求A和请求B分别创建应用与store,输出HTML;浏览器用相同初始状态创建SSR应用并激活,两个请求不共享用户状态。
编辑原创技术示意图,依据本文所列官方源文绘制;非截图。绘制:未完纪编辑整理(Codex 辅助绘制)。

服务端先画页面,浏览器再接管交互

通常,Vue 组件在浏览器里创建并更新 DOM。SSR 把同一套组件先放到服务器上执行,输出 HTML 字符串,随 HTTP 响应发给浏览器。用户先看到已经排好的页面;随后浏览器加载客户端代码,创建与服务端相对应的应用,把组件与现有 DOM 对齐,再添加事件监听器。这个接管过程叫激活(hydration)。

一套应用的大部分代码可以同时运行在两端,因此这类应用也叫“同构”或“通用”应用。服务端返回了按钮,并不意味着按钮已经能响应点击:HTML 与交互接管是两个不同阶段。

考虑因素 SSR 带来的变化
首屏体验 无需等整个客户端 JavaScript 下载、执行后才显示主体内容;服务器也可能更靠近数据源。收益仍取决于网络、缓存、服务器与页面实现。
开发方式 两端共享声明式组件与语言,不必为主体内容维护另一套后端模板。
搜索可见性 爬虫可直接得到主要 HTML 内容,减少对客户端执行及异步请求的依赖。
运行代价 服务器要承担渲染 CPU 与数据获取开销,需要缓存和容量规划。
工程约束 浏览器 API、生命周期、构建和部署需要区分两端;基本 Node 示例不能直接等同生产系统。

原页以 Google、Bing 的 JavaScript 索引行为解释 SEO 动机,但爬虫执行与等待策略会随产品变化。这里保留可验证的工程结论:如果核心内容只在浏览器异步获取,搜索引擎能否及时得到内容具有额外不确定性;SSR 可以把主要内容直接放进响应。本文不承诺索引或排名结果。内部管理界面等场景若首屏与公开搜索并不关键,也未必需要承担 SSR 的复杂度。

数据在构建时已知时,考虑 SSG

静态站点生成(SSG,也叫预渲染)提前在构建阶段渲染页面,把结果保存为静态 HTML 与资源文件。若一页内容对所有用户一致,并且构建时已经知道,就没必要在每次请求时重新渲染。SSG 同样能较快显示主体内容,部署与服务成本通常更简单。限制在于内容更新后需要重新构建、部署,或者另行设计客户端数据更新。营销页、博客、文档站常适合这条路线;Vue 文档自身由 VitePress 生成。

第一步:把 Vue 应用渲染成字符串

在单独的演示目录初始化项目,安装 Vue,并在 package.json 中设置 ES module 模式。以下命令只是供读者在自己的隔离环境参考,本次没有执行。依赖版本应由项目锁文件固定。

npm init -y
npm install vue

在 package.json 中加入 "type": "module",再创建 example.js:

import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

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

const html = await renderToString(app)
console.log(html)

运行 node example.js 时,原文展示的预期输出为 <button>1</button>。这是原文示例预期,不是本次实测。renderToString() 接收应用实例并返回 Promise。需要流式响应时,还有 Node.js Stream 与 Web Streams 形式的渲染 API,具体签名见 SSR API 参考。

第二步:共享应用工厂,在浏览器激活

服务端与客户端应共用应用创建逻辑,但每次调用要得到一个新实例。下面是依据原文整理的完整最小结构。编辑改动包括:只提供 public 目录、补上错误响应、默认只监听本机,以及从已安装的 Vue 包复制浏览器构建,避免把整个项目和 node_modules 都公开出去。

project/
  package.json
  server.js
  public/
    app.js
    client.js
    vendor/
      vue.esm-browser.js

安装 Express:npm install express。将当前锁定版本的 node_modules/vue/dist/vue.esm-browser.js 复制到 public/vendor/vue.esm-browser.js。这份开发构建便于演示错误信息;正式构建应采用项目的生产构建流程,保持服务端与浏览器 Vue 版本一致。

// public/app.js:两端共享
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: '<button @click="count++">{{ count }}</button>'
  })
}
// public/client.js:仅浏览器执行
import { createApp } from './app.js'

createApp().mount('#app')
// server.js:仅服务器执行
import express from 'express'
import { fileURLToPath } from 'node:url'
import { renderToString } from 'vue/server-renderer'
import { createApp } from './public/app.js'

const server = express()
const publicDir = fileURLToPath(new URL('./public/', import.meta.url))
server.use(express.static(publicDir))

server.get('/', async (req, res) => {
  try {
    const app = createApp()
    const html = await renderToString(app)
    res.type('html').send(
      '<!doctype html><html lang="zh-CN"><head>' +
      '<meta charset="utf-8"><title>Vue SSR 示例</title>' +
      '<script type="importmap">{"imports":{"vue":"/vendor/vue.esm-browser.js"}}</script>' +
      '</head><body><div id="app">' + html + '</div>' +
      '<script type="module" src="/client.js"></script></body></html>'
    )
  } catch (error) {
    console.error('SSR render failed', error)
    res.status(500).type('text').send('页面暂时无法加载')
  }
})

server.listen(3000, '127.0.0.1')

在隔离演示环境启动 node server.js 后,可访问 http://127.0.0.1:3000。浏览器 import map 把 vue 映射到本地浏览器构建;Node.js 则从安装包解析同一个模块名。客户端同样使用 createSSRApp(),表示挂载时已有预渲染 DOM,应执行激活;若没有加载 client.js,只会得到静态按钮。

安全边界:示例中的 HTML 拼接只插入 Vue 渲染结果和固定外壳,不能改成直接拼接不可信请求文本或让用户提交 Vue 模板。应用一旦使用 v-html、原始 HTML 或内联序列化状态,就必须单独处理对应上下文的转义与可信边界。不能把用户状态未经安全序列化塞入脚本标签。错误日志在真实服务中也要控制敏感数据与访问权限。原文的 express.static('.') 仅适合理解原理,本文已改为专用静态目录。

从示例到完整框架还缺什么

真实应用通常使用单文件组件,需要分别生成客户端和服务端构建。SSR 模板会编译为更适合服务端的字符串拼接,而不是普通客户端 render 函数。服务器还要把正确的客户端资源 URL、preload/prefetch 提示写进 HTML,并协调路由、数据请求和状态存储。有些应用还需要同站混合 SSR 与 SSG。

这些工作受构建工具与部署目标影响很大。官方建议需要 SSR 时优先评估具有 SSR 支持的上层 Vue 框架,而不是把本页 Express 教学代码直接扩成生产基础设施。相关方案见 Vue 工具链中的上层框架。

生命周期与平台 API:区分两端执行时机

SSR 的一次请求只需要把当前状态渲染出来,没有持续用户交互与 DOM 更新。响应性在 SSR 期间默认禁用,以减少不必要的开销。选项式 API 中,beforeCreate 与 created 会在服务端执行,mounted、updated 和卸载钩子不会;组合式 API 的 setup() 与 <script setup> 顶层代码也会参与服务端创建过程,但 onMounted 等挂载后钩子仅在客户端执行。

因此,不要在 created、setup() 顶层创建需要卸载钩子回收的计时器、订阅等副作用。例如 setInterval 若只在 onUnmounted 中清理,在 SSR 中不会走到清理钩子,可能长期占用进程。适合浏览器的副作用应移到 onMounted,且在客户端卸载时配对回收。

通用代码不能直接依赖 window、document,也不能让浏览器执行 Node 专有文件或进程 API。把差异封装在跨平台接口后面,或者延迟到客户端钩子访问。原文用 node-fetch 说明统一网络接口的思路;实际项目要依据所用 Node 版本及运行时决定是否需要额外实现。不能靠随意伪造 window 等全局变量来“骗过”第三方库,因为这可能改变其他库的环境判断。

每个请求都需要自己的状态

浏览器里的模块级 store 往往随着页面加载重新初始化;长时间运行的 Node 服务则通常只在启动时加载一次模块。若在模块顶层创建 store,再把用户 A 的资料写进去,请求 B 可能读到同一对象。这是实际的数据隔离风险,叫跨请求状态污染。

逐请求重载全部 JavaScript 模块成本很高。更合适的方式是用工厂为每次请求创建新的 app、router 和 store,并通过应用级 provide/inject 向组件提供状态,而不是让组件直接导入可变单例。下面是编辑补全的工厂形式,保留原文的设计:

// app.js:结构示意,Root 和 createStore 由项目提供
import { createSSRApp } from 'vue'
import Root from './Root.js'
import { createStore } from './store.js'

export function createApp() {
  const app = createSSRApp(Root)
  const store = createStore()
  app.provide('store', store)
  return { app, store }
}

服务端的请求处理器调用该工厂,并只把允许下发给当前用户的数据传给客户端;客户端用同样的初始状态激活。Pinia 已考虑 SSR 场景,但仍需遵循 Pinia SSR 指南 的实例隔离与安全序列化要求。

激活不匹配:查数据与 DOM,而不只是压掉警告

如果浏览器实际解析出的 DOM 和客户端期望结构不一致,就会发生激活不匹配。Vue 会尝试修复,可能丢弃节点并重新渲染,造成额外开销。常见原因有三类:

  • 不合法 HTML 被浏览器改写。例如 <p><div>hi</div></p> 可能解析成两个空 p 加中间的 div;服务端字符串看似对称,浏览器 DOM 却不是。
  • 渲染期间生成随机值,两端各运行一次,得到不同结果。可让相关内容通过 v-if 与 onMounted 仅在客户端显示,或使用可种子的随机生成器,并把同一 seed 作为初始状态传给客户端。
  • 服务器与用户时区不同,同一时间戳格式化为不同文本。若无法可靠获知用户时区,把本地时间转换放在客户端,或显式使用两端相同的时区和格式。

Vue 3.5+ 提供 data-allow-mismatch,可选择性抑制无法避免的不匹配警告。它不是状态串用、随机数据错误或不合法 HTML 的修复方案;优先保证两端输入一致。

自定义指令与 Teleport 的服务端输出

很多自定义指令直接操作 DOM,服务端默认忽略这类行为。若指令需要在服务端输出属性,可实现 getSSRProps;该钩子只接收 binding,返回要写到元素上的属性:

const myDirective = {
  mounted(el, binding) {
    el.id = binding.value
  },
  getSSRProps(binding) {
    return { id: binding.value }
  }
}

Teleport 的内容不在主应用渲染字符串中。最简单的处理是客户端挂载后再条件渲染。如果必须在服务端输出,则把渲染上下文传给 renderToString,并读取 ctx.teleports:

const ctx = {}
const html = await renderToString(app, ctx)
// 例如:{ '#teleported': 'teleported content' }
console.log(ctx.teleports)

服务器必须把每个目标对应的 HTML 插入正确位置。建议为目标准备独立容器,例如 <div id=”teleported”></div>。避免直接以 body 为目标,因为 body 往往还包含其他服务端内容,激活过程难以确定 Teleport 内容从哪里开始。

这条完整链路的核心是两端共享组件逻辑、每个请求独立持有状态、客户端使用确定的初始数据接管同一份 DOM。本文补充的静态审核只覆盖展示代码,不代表对完整应用、依赖或部署环境的安全审计。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容