- 前端
- 文档
【免费下载链接】vitepress
Vite & Vue powered static site generator.
本文基于 VitePress 仓库中的 docs/en/guide/ssr-compat.md 展开,结合 src/client/app 下的源码实现与测试用例,系统讲解 VitePress 生产构建中的 SSR 预渲染机制、
<ClientOnly>组件的使用、浏览器 API 的按需加载,以及defineClientComponent辅助函数的完整用法。读完本文,你将能排查并修复自定义主题和组件中所有常见的 SSR 不兼容问题,写出"既能在 Node.js 中预渲染、又能在浏览器中正常交互"的代码。
一、为什么 VitePress 主题代码必须考虑 SSR 兼容
VitePress 是一个基于 Vite 与 Vue 3 的静态站点生成器。与纯客户端渲染的 SPA 不同,VitePress 在生产构建阶段使用 Vue 的服务端渲染(SSR)能力,在 Node.js 环境中预先渲染整个应用,将页面输出为静态 HTML 文件。这意味着:
- 构建时,你的每一个页面都会在 Node.js 进程中被真实地渲染一遍;
- 主题组件中的所有自定义代码,都会经历一次"没有
window、document、localStorage等浏览器 API"的渲染环境; - 只有通过了这轮预渲染,站点才能生成可被搜索引擎直接抓取、首屏即可见的静态 HTML。
从源码结构看,这一流程的核心入口是 src/client/app/ssr.ts:SSR 入口调用renderToString(app, ctx)(来自vue/server-renderer)把整个应用渲染成 HTML 字符串,再交由构建管线写入静态文件。也就是说,"SSR 兼容"并不是一个可选项——只要你的主题有自定义组件或自定义代码,它们就默认处于 SSR 环境的约束之下。
关于 SSR 的定义、SSR 与 SSG(静态站点生成)的关系,以及编写 SSR 友好代码的通用注意事项,官方 Vue 文档的 SSR 章节提供了更系统的背景知识。这里给出最核心的一条经验法则:只允许在 Vue 组件的
beforeMount或mounted钩子中访问浏览器 / DOM API。因为挂载阶段只会在浏览器端发生,Node.js 预渲染时不会执行这些钩子。
二、内置<ClientOnly>组件:包住不安全的组件
如果你正在使用或演示一些不兼容 SSR 的组件(例如包含自定义指令、依赖 DOM 测量、读取window的组件),最省事的做法是用 VitePress 内置的<ClientOnly>组件把它们包起来:
<ClientOnly> <NonSSRFriendlyComponent /> </ClientOnly><ClientOnly>的实现非常轻量(见 src/client/app/components/ClientOnly.ts):它内部维护一个show的 ref,初始为false,只在onMounted之后才变为true并渲染默认插槽。因此:
- SSR 阶段:插槽内容不会被渲染,预渲染 HTML 中该区域为空,不会触发不安全的代码;
- 浏览器阶段:组件挂载完成后才渲染插槽内容,此时浏览器 API 可用。
<ClientOnly>是 VitePress 的全局组件,在 src/client/app/index.ts 中通过app.component('ClientOnly', ClientOnly)注册,你可以在任何 Markdown 或 Vue 文件中直接使用,无需手动导入。除 Markdown 内联使用外,它同样适用于在你的自定义布局组件中包裹任何客户端专用片段。
三、在导入阶段就访问浏览器 API 的库
<ClientOnly>能解决"渲染时"的问题,但还有一种更隐蔽的情况:某些组件或库在模块被 import 时(即模块顶层作用域)就会访问浏览器 API。这类代码即使从未在 SSR 中被渲染,只要静态 import 语句存在于模块图中,Node.js 预渲染时执行到该模块就会报错。
解决办法是把这类代码改为动态导入(dynamic import),让模块只在浏览器端、真正需要时才被加载。下面是三种官方推荐的落地方式。
3.1 在onMounted钩子中动态导入
最通用的做法:把import()放进组件的onMounted钩子中,确保模块只会在浏览器端加载:
<script setup> import { onMounted } from 'vue' onMounted(() => { import('./lib-that-access-window-on-import').then((module) => { // use code }) }) </script>这样做的原理很直接:onMounted不会在 Node.js 预渲染时执行,动态import()返回的 Promise 只会在浏览器中 resolve,因此依赖的浏览器全局对象在模块执行时必然存在。
3.2 使用import.meta.env.SSR条件导入
你还可以借助 Vite 注入的环境变量import.meta.env.SSR,在模块顶层显式判断当前运行环境。该变量在服务端(SSR/构建)为true,在客户端为false,据此决定是否执行导入:
if (!import.meta.env.SSR) { import('./lib-that-access-window-on-import').then((module) => { // use code }) }注意import.meta.env.SSR是编译期常量,生产构建时 Vite 会按运行环境做死代码消除(dead-code elimination),因此这样写不会有额外的运行时开销。
在enhanceApp中条件注册插件
由于Theme.enhanceApp本身是可以异步的(其类型签名enhanceApp?: (ctx: EnhanceAppContext) => Awaitable<void>,见 docs/en/guide/custom-theme.md),你可以利用它 +import.meta.env.SSR在客户端条件导入并注册那些"导入即访问浏览器 API"的 Vue 插件:
/** @type {import('vitepress').Theme} */ export default { // ... async enhanceApp({ app }) { if (!import.meta.env.SSR) { const plugin = await import('plugin-that-access-window-on-import') app.use(plugin.default) } } }如果使用 TypeScript,可以用satisfies Theme获得类型约束:
import type { Theme } from 'vitepress' export default { // ... async enhanceApp({ app }) { if (!import.meta.env.SSR) { const plugin = await import('plugin-that-access-window-on-import') app.use(plugin.default) } } } satisfies Theme这里有一个需要留意的细节:从源码看,src/client/app/index.ts 中enhanceApp是在createApp流程里被await的,且SSR 阶段也会执行(参见 src/client/app/ssr.ts 的渲染流程),所以"是否只在客户端执行"的判断必须由你在enhanceApp内部通过import.meta.env.SSR自行完成。
3.3defineClientComponent:导入即访问浏览器 API 的 Vue 组件
VitePress 针对"导入时访问浏览器 API 的 Vue 组件"提供了一个便捷辅助函数defineClientComponent。它返回一个包装组件,该包装组件的内部逻辑是:在onMounted中才真正加载目标组件(实现见 src/client/app/utils.ts)。这意味着目标组件的模块只有在浏览器端挂载时才会被 import,SSR 阶段完全不会触达。
最基本的用法:
<script setup> import { defineClientComponent } from 'vitepress' const ClientComp = defineClientComponent(() => { return import('component-that-access-window-on-import') }) </script> <template> <ClientComp /> </template>传递 props / 插槽 / 子节点
defineClientComponent的第二个参数是一组参数,它们会被原样透传给 Vue 的h()渲染函数(h()的签名细节见 Vue 官方渲染函数文档),从而实现向目标组件传递 props、ref 与具名插槽:
<script setup> import { ref } from 'vue' import { defineClientComponent } from 'vitepress' const clientCompRef = ref(null) const ClientComp = defineClientComponent( () => import('component-that-access-window-on-import'), // args are passed to h() - https://vuejs.org/api/render-function.html#h [ { ref: clientCompRef }, { default: () => 'default slot', foo: () => h('div', 'foo'), bar: () => [h('span', 'one'), h('span', 'two')] } ], // callback after the component is loaded, can be async () => { console.log(clientCompRef.value) } ) </script> <template> <ClientComp /> </template>第三个参数是组件加载完成后的回调,支持 async,此时你可以安全地访问通过ref拿到的组件实例(因为回调必然在浏览器端触发)。
底层实现要点
结合 src/client/app/utils.ts 的实现,可以提炼出defineClientComponent的几个关键行为:
- 内部使用
shallowRef持有加载结果,setup()中注册onMounted钩子,在钩子内执行你传入的 loader 动态导入; - 对动态导入结果做 ESM 互操作处理:当模块带有
__esModule标记或Symbol.toStringTag === 'Module'时,自动取.default作为组件; - 渲染函数只在
comp.value存在时才h(comp.value, ...args)渲染目标组件,否则返回null——因此在 SSR 阶段输出为空,不会触发任何客户端代码; - 目标组件只会在包装组件的 mounted 钩子中被 import,这正是"导入时访问浏览器 API"的组件能够安全运行的根本原因。
四、SSR 环境下的其他注意事项
除了上述三种"浏览器 API 访问"场景,SSR 兼容还涉及几个容易踩坑的细节,一并整理如下:
import.meta.env.SSR是 Vite 内置环境变量(属于 Vite 环境变量体系的一部分),不限于 VitePress 内部使用,任何在 VitePress 主题中编写的代码都可以引用它做环境分支。- 所有浏览器专属工作都应放在
onMounted内:包括事件监听、第三方 SDK 初始化、基于document的尺寸测量等。自定义主题的setup()钩子同样会在 SSR/SSG 渲染期间执行,因此其中的浏览器专属逻辑也必须收进onMounted(参见 docs/en/guide/custom-theme.md 的说明)。 - SSR 阶段保持确定性:Node.js 预渲染要求每次渲染输出一致,不要在渲染期依赖随机值、当前时间戳、
Math.random()等不确定性来源,否则会导致每次构建的 HTML 出现差异。 - 不要直接读写
window/document/localStorage(除非确认在客户端环境),可用import.meta.env.SSR或onMounted做保护;VitePress 内部的大量代码也正是这样做的,例如 src/client/app/index.ts 中仅在 SSR 阶段开启throwUnhandledErrorInProduction,以及 src/client/app/composables/icon.ts 中对 SSR 分支的处理。
五、快速自查清单
把上面的内容浓缩成一份排查清单,当你的 VitePress 站点在vitepress build阶段报出与window is not defined、document is not defined类似的错误时,按序检查:
- 是否在组件顶层(非钩子内)访问了浏览器 API?→ 把逻辑移入
beforeMount/onMounted。 - 是否有库在 import 时就访问浏览器 API?→ 改为
onMounted动态导入,或用import.meta.env.SSR条件导入。 - 是否是"导入即访问浏览器 API"的 Vue 组件?→ 用
defineClientComponent包装。 - 是否只是某个演示区域不兼容 SSR?→ 用
<ClientOnly>包住该区域。 - 自定义主题的
enhanceApp/setup中是否有浏览器专属逻辑?→ 用import.meta.env.SSR分支或在onMounted中执行。
六、进一步阅读
- 本主题的官方原始文档:docs/en/guide/ssr-compat.md
- 主题接口与
enhanceApp详解:docs/en/guide/custom-theme.md defineClientComponent与运行时工具的实现:src/client/app/utils.ts<ClientOnly>组件实现:src/client/app/components/ClientOnly.ts- SSR 渲染入口:src/client/app/ssr.ts
- 应用创建与全局组件注册、
enhanceApp调用链:src/client/app/index.ts - 在 Vue 组件中使用 VitePress 主题能力的指南:docs/en/guide/using-vue.md
- 前端
- 文档
【免费下载链接】vitepress
Vite & Vue powered static site generator.
相关推荐
VitePress SSR 兼容性实战:让主题组件与自定义代码安全通过服务端渲染
VitePress SSR 兼容性实战:让主题组件与自定义代码安全通过服务端渲染 VitePress 在生产构建阶段会在 Node.js 环境中利用 Vue 的
前端文档VitePress SSR 兼容性实战指南:让主题组件与自定义代码安全通过服务端渲染
VitePress SSR 兼容性实战指南:让主题组件与自定义代码安全通过服务端渲染 VitePress 在生产构建时会使用 Vue 的 SSR(服务端渲染)能
前端文档VitePress SSR 兼容性完全指南:主题组件与自定义代码的服务端渲染适配
VitePress SSR 兼容性完全指南:主题组件与自定义代码的服务端渲染适配 VitePress 在生产构建阶段会借助 Vue 的 Server Side
前端文档
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考