VitePress SSR 兼容性实战指南让主题组件与自定义代码安全通过服务端渲染【免费下载链接】vitepressVite Vue powered static site generator.项目地址: https://gitcode.com/gh_mirrors/vi/vitepressVitePress 在生产构建时会使用 Vue 的 SSR服务端渲染能力在 Node.js 中预渲染整个站点因此主题组件与自定义代码中的浏览器 / DOM API 访问必须在构建阶段被正确处理。本文以 docs/ja/guide/ssr-compat.md与英文版 docs/en/guide/ssr-compat.md 内容一致为主线结合仓库源码深入讲解 SSR 兼容性的判断标准、ClientOnly组件的使用、导入时访问浏览器 API 的库的三种处理方案以及defineClientComponent的底层实现原理帮助你在开发 VitePress 主题时写出既能在构建期预渲染、又能在浏览器端正常交互的代码。VitePress 的 SSR / SSG 构建流程为什么自定义代码需要兼容 SSRVitePress 属于 SSG静态站点生成器范畴但它的静态化过程建立在 Vue SSR 之上生产构建时VitePress 会在 Node.js 环境中运行 Vue 的renderToString把每个 Markdown 页面渲染成字符串再写入独立的 HTML 文件。这意味着主题组件中的全部自定义代码都会在 Node.js 环境下执行一遍——包括script setup的顶层逻辑、组合式函数的调用、以及组件挂载前的所有生命周期钩子。这一点在仓库源码中有清晰的体现SSR 入口 src/client/app/ssr.ts 中render(path)通过createApp()创建应用、router.go(path)完成路由跳转后调用renderToString(app, ctx)把整棵组件树渲染为 HTML 字符串。构建阶段 src/node/build/build.ts 会通过nativeImport加载打包好的app.js对[404.md, ...siteConfig.pages]中的每个页面依次调用renderPage最终由 src/node/build/render.ts 生成完整 HTML。客户端构建与 SSR 构建使用不同的入口src/node/build/bundle.ts 中ssr ? ssr.js : index.jsSSR 构建还会将vue外部化处理见 src/node/alias.ts。因此可以得出一个关键结论凡是依赖window、document、navigator、localStorage等浏览器 / DOM API 的代码如果放在模块顶层或组件setup阶段直接执行就会在 Node.js 预渲染时抛出window is not defined之类的错误导致整个vitepress build失败。Vue 官方文档对 SSR 友好代码给出的一般性经验是只在 Vue 组件的beforeMount或mounted钩子中访问浏览器 / DOM API。原因很简单这两个钩子只在客户端执行服务端渲染阶段不会调用它们。下面结合 VitePress 提供的工具逐一说明具体做法。使用内置ClientOnly包装非 SSR 友好组件如果某个组件本身不适合 SSR例如包含自定义指令、依赖浏览器环境的第三方组件你并不需要重写它直接用 VitePress 内置的ClientOnly组件把它包起来即可ClientOnly NonSSRFriendlyComponent / /ClientOnly这段 Markdown 可以写在任意.md文件中VitePress 会把 Markdown 中的组件语法编译进页面组件。ClientOnly的含义是仅客户端渲染在 SSR 阶段它什么都不渲染只有浏览器端才会把内部内容挂载出来。其实现原理非常直白见 src/client/app/components/ClientOnly.tsimport { defineComponent, onMounted, ref } from vue export const ClientOnly defineComponent({ setup(_, { slots }) { const show ref(false) onMounted(() { show.value true }) return () (show.value slots.default ? slots.default() : null) } })关键点在于show初始为falseSSR 阶段渲染结果恒为空onMounted在浏览器端才把show置为true此时才渲染默认插槽。这带来的两个直接效果是构建期安全被包装的组件不会参与预渲染Node.js 中不会执行其内部代码。首屏代价该区域的 HTML 在 SSR 输出中为空内容完全依赖客户端挂载因此可能出现短暂的空白或布局抖动只应对确实无法 SSR的组件使用不要滥用。需要补充的一点源码旁证由于ClientOnly内部的内容不会出现在 SSR 输出的 HTML 中构建系统在收集 SSR 阶段渲染出的图标vpIcons时也看不到这部分内容。因此 src/node/build/build.ts 允许通过配置icons.include预置这些仅客户端渲染的图标避免图标样式丢失。处理导入时访问 Browser API的库有些组件或第三方库的行为更为隐蔽它们在模块被 import 的那一刻就去访问浏览器 API。例如某个图表库在包入口处直接读取window.innerWidth。这类代码即使放在组件内部只要import语句在模块顶层被静态执行SSR 阶段仍会崩溃。解决办法是让这些模块不被静态导入而是延迟到客户端运行时再动态加载。VitePress 官方文档给出了三种由浅入深的方案。方案一在 mounted 钩子中动态导入最直接的方式是把动态import()放进onMounted钩子因为该钩子只在客户端执行script setup import { onMounted } from vue onMounted(() { import(./lib-that-access-window-on-import).then((module) { // 这里才可以使用该库 }) }) /scriptimport()是动态导入Vite/Rolldown 会把它拆成独立的异步 chunk只有运行到该语句时才会真正加载并执行模块——因此模块顶层对window的访问被推迟到了浏览器环境。方案二使用import.meta.env.SSR条件导入import.meta.env.SSR是 Vite 提供的环境变量构建时会被静态替换SSR 构建中为true客户端构建中为false。利用它可以在源码中显式地区分运行环境if (!import.meta.env.SSR) { import(./lib-that-access-window-on-import).then((module) { // 这里才可以使用该库 }) }由于import.meta.env.SSR在构建期被替换为常量配合死代码消除dead code eliminationSSR 构建产物中甚至不会包含这段分支代码进一步保证了安全。方案三通过异步enhanceApp条件注册 Vue 插件如果你的需求是注册一个在导入时触碰浏览器 API 的 Vue 插件可以利用Theme.enhanceApp是异步函数这一特性。Theme接口定义见 src/client/app/theme.tsenhanceApp?: (ctx: EnhanceAppContext) Awaitablevoid其中Awaitable意味着它既可以返回void也可以返回Promise。在 src/client/app/index.ts 中应用创建流程会await Theme.enhanceApp(...)所以可以在函数体内放心地使用await import()/** 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这样该插件只会在客户端构建中被动态加载并注册SSR 阶段enhanceApp内不会执行导入预渲染可以顺利进行。进阶工具defineClientComponent及其实现原理前面三种方案都要求你自己管理何时加载、如何渲染的逻辑。如果目标只是懒加载一个导入时访问浏览器 API 的 Vue 组件VitePress 提供了专用辅助函数defineClientComponent它从vitepress包导出见 src/client/index.ts会在包装组件的mounted钩子中才首次导入目标组件。基础用法script setup import { defineClientComponent } from vitepress const ClientComp defineClientComponent(() { return import(component-that-access-window-on-import) }) /script template ClientComp / /templatedefineClientComponent接收一个异步加载器返回import()Promise并返回一个可用的 Vue 组件对象。在 SSR 阶段它渲染为空客户端挂载后才异步加载并渲染真实组件。传递 props / children / slotsdefineClientComponent的完整签名支持三个参数加载器、传给h()的参数数组含 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), // 该数组作为参数传给 h() 渲染函数 [ { ref: clientCompRef }, { default: () default slot, foo: () h(div, foo), bar: () [h(span, one), h(span, two)] } ], // 组件加载完成后的回调可以是异步函数 () { console.log(clientCompRef.value) } ) /script template ClientComp / /template其中第二个参数的结构对应 Vueh()函数的签名第一项是组件 props这里把ref传进去以便拿到组件实例第二项是子节点 / 插槽集合default、foo、bar分别对应命名插槽值可以是字符串、h()返回值或数组。源码级原理解读defineClientComponent的实现位于 src/client/app/utils.tsexport function defineClientComponent( loader: AsyncComponentLoader, args?: any[], cb?: () Awaitablevoid ) { return { setup() { const comp shallowRef() onMounted(async () { let res await loader() // interop module default if (res (res.__esModule || res[Symbol.toStringTag] Module)) { res res.default } comp.value res await cb?.() }) return () (comp.value ? h(comp.value, ...(args ?? [])) : null) } } }逐行拆解其设计要点返回的是普通对象组件而非defineComponent包裹的组件内部通过setup()暴露渲染逻辑这保证了它是一个可被模板直接使用的合法组件。comp使用shallowRef而非ref目标组件是组件对象而非响应式数据浅层引用即可避免不必要的深度响应式代理开销。加载时机被严格限定在onMounted内SSR 阶段mounted不会触发comp.value始终为空渲染函数返回null这正是目标组件只会在包装组件的 mounted 钩子中被首次导入这一文档结论的代码来源。ESM 互操作处理await loader()之后若模块是 ESM通过__esModule或Symbol.toStringTag Module判断则取.default作为组件兼容不同打包器产出的模块格式。加载完成后的回调cb支持异步且会在comp.value赋值之后执行因此回调里读取clientCompRef.value一定能拿到已挂载的实例。渲染使用h(comp.value, ...(args ?? []))args被展开为h()的参数与 Vue 官方h()文档中props children的参数约定完全一致。实践建议与注意事项结合以上内容给出几条在 VitePress 项目中保持 SSR 兼容性的实操准则先判断再动手自定义代码中只有真正涉及浏览器 / DOM API 的部分才需要上述处理纯计算、纯数据转换代码保持普通写法即可避免不必要的异步加载拖慢首屏。ClientOnly是最低成本兜底用于演示某个不兼容组件或临时引入不友好依赖的场景但要注意其内容不参与 SSRSEO 与首屏内容都会受影响长期方案仍是让组件本身兼容 SSR。import.meta.env.SSR是区分环境的标准手段它由 Vite 构建期静态替换天然适配 VitePress 的构建体系适合写条件分支配合动态import()可以同时满足SSR 安全与客户端按需加载。Vue 插件优先走异步enhanceApp把app.use(plugin)放进enhanceApp的if (!import.meta.env.SSR)分支既避免 SSR 崩溃也保持了插件注册的集中管理注意 src/client/app/index.ts 中base.enhanceApp与theme.enhanceApp是依次 await 的多主题继承时同样适用。defineClientComponent适合组件级懒加载需要传 props / slots / ref / 加载回调时它比手写onMounted dynamic import h()更简洁且经过了 ESM 互操作处理跨构建器行为更稳定。构建期尽早验证vitepress build会在 Node.js 中完整执行 SSR任何遗漏的浏览器 API 访问都会在构建时报错因此 CI 中应始终包含生产构建步骤把 SSR 兼容性问题拦截在发布之前。本文所述方案与源码均对应当前仓库的实现ClientOnly见 src/client/app/components/ClientOnly.tsdefineClientComponent见 src/client/app/utils.tsSSR 入口见 src/client/app/ssr.ts构建渲染管线见 src/node/build/build.ts 与 src/node/build/render.ts。如需深入了解自定义主题的接口定义如enhanceApp的完整上下文可继续阅读 docs/ja/guide/custom-theme.md。【免费下载链接】vitepressVite Vue powered static site generator.项目地址: https://gitcode.com/gh_mirrors/vi/vitepress创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
