VitePress 跨框架渲染策略
概述
vitepress-rendering-strategies
库为 vitepress
静态站点生成器提供跨框架组件渲染能力,突破了 vitepress
原生仅支持 vue
组件的限制。
现阶段仅扩展支持
react
组件渲染,未来将支持其他主流UI
框架(例如solid
、svelte
、preact
、angular
等)渲染支持。
技术架构说明
本库受 astro
的 孤岛架构(Islands Architecture) 设计启发,在 vitepress
的 SSG
(静态站点生成)基础上实现跨框架组件集成。
架构核心特点:
- 静态优先:基于
vitepress
的SSG
架构,构建时完成组件预渲染。 - 选择性
hydration
:仅对需要交互的组件进行客户端激活。 - 框架隔离:各框架组件独立运行,每个组件容器独立完成
hydration
,避免全局状态冲突。 - 渐进增强:优先考虑静态内容,通过不同渲染策略逐步增强为交互式应用。
功能特性
- 跨框架支持: 目前在
vitepress
中原生支持react
组件渲染,未来扩展至其他主流框架。 - 多样化渲染策略: 参照
astro
的 模板指令。客户端指令目前支持了client:only
、client:load
、client:visible
三种渲染模式,默认情况下采用ssr:only
渲染模式。 - SPA 路由优化:
spa:sync-render
(简写spa:sr
) 指令优化SPA
路由切换性能。 - 单向数据传递: 支持在组件首次渲染时,由
vue
向react
子组件传递props
,用于初始化React
组件。此为一次性传递,非响应式绑定。 - 开发体验: 完整的
HMR
支持,提供流畅的开发体验。 - 环境一致性: 开发与生产环境保持一致的渲染策略,避免开发与生产环境不一致导致的渲染问题。
- 支持
MPA
模式: 完全兼容vitepress
的MPA
模式。即使在MPA
模式下,react
组件的渲染和hydration
也能正常工作。
spa:sync-render 指令的设计初衷
vitepress
是一个 SSG
应用,在构建阶段完成页面的预渲染工作,同时客户端路由是受控的。首次页面渲染会完成客户端注水(过滤静态节点)工作,当路由发生变更,vitepress
会加载目标路由页面所依赖的客户端脚本,完成局部客户端渲染工作,这是典型的 SSG
应用的架构。
默认情况下,vitepress-rendering-strategies
会将目标页面中所有需预渲染组件(非 vue
组件)均集成到单独的脚本中,路由切换时会预加载该脚本,等待 vue
渲染完成后再将预渲染产物注入到根容器节点下,若组件需要注水,则继续加载客户端脚本完成客户端注水工作。这样确保了路由切换时组件渲染行为的连贯性,但存在很典型的一个问题,切换路由场景下无法发挥预渲染的性能优势和存在组件渲染闪烁问题。
下述演示环境在 CPU: 20x slowdown
、0.75
倍速播放:
在
vitepress
的SPA
路由切换中,vue
的内容更新是同步的,但加载和渲染非vue
组件(如react
)的预渲染HTML
是异步的。这个时间差导致了视觉上的闪烁,并使得预渲染的性能优势在切换时丢失。
vitepress
采用的 SSG
架构方案是合理的,我们并不打算对整体架构进行调整。那么目标就是在现有架构的基础上尽可能增强预渲染的性能优势,我们为此提供 spa:sync-render
(简写 spa:sr
) 指令,该指令会集成目标页面中所有使用该指令的组件所预渲染的产物到 vue
的客户端渲染脚本中,跟随着 vue
客户端渲染工作并同步完成预渲染产物的渲染工作,用户就不会看到特殊场景下某一时刻组件的闪烁问题。
文档导向型项目本身并不建议大量集成高负载、强交互的渲染组件,文档导向型项目更关心主体内容交付给用户的时间,我们 假设 这类组件是 非关键渲染组件。我们并不推荐为这类组件启用 spa:sync-render
指令,因为这会增加 vue
客户端渲染脚本的体积的同时还需额外加载脚本完成组件的渲染工作,这可能会延迟主体内容的交付。
客户端包体积增加说明
vitepress
首次页面渲染(非路由切换)时,会通过简化的 vue
客户端脚本(.lean.js
)完成应用的 hydration
工作,简化意味着 vitepress
在编译阶段会过滤掉所有静态节点来减少首次 hydration
的脚本体积。
当路由切换时,vitepress
会加载目标路由页面所依赖的客户端脚本,完成局部客户端渲染工作,这是一个完整的客户端渲染,客户端脚本必须包含渲染组件的所有信息。
上述提到的体积增加 仅针对 路由切换时加载的客户端脚本,并 不会影响 首次页面渲染(非路由切换)时 vue
客户端脚本(.lean.js
)的体积。
我们提供此指令是为了满足 关键渲染组件 的同步渲染需求,但开发者需要警惕其对客户端包体积的影响。
ssr:only
指令意味着组件是纯静态的。我们 假设 这类组件是 关键渲染组件,因此默认将其渲染优先级与 vue
组件对齐,以消除路由切换时的视觉不一致。
spa:sr
的核心是在 更平滑的路由切换体验 与 更小的客户端包体积 之间做权衡。请仔细评估你的组件是否为必须同步渲染的 关键组件。
基于上述考量,我们约定了以下默认规则:
- 所有使用
client:*
指令的组件默认不启用spa:sync-render
指令,除非显式启用spa:sync-render
(或spa:sr
) 指令。 - 所有使用
ssr:only
指令的组件(包括不带任何指令的组件)默认启用spa:sync-render
(或spa:sr
) 指令,除非显式启用spa:sync-render:disable
(或spa:sr:disable
) 指令。
使用方式
<script setup>
import VueComp1 from './rendering-strategy-comps/vue/VueComp1.vue';
const page = {
title: '渲染策略',
};
const vueUniqueId = 'vue-unique-id';
</script>
<script lang="react">
import ReactComp1 from './rendering-strategy-comps/react/ReactComp1';
import { ReactComp2 } from './rendering-strategy-comps/react/ReactComp2';
import ReactComp3 from './rendering-strategy-comps/react/ReactComp3';
import { ReactComp4 } from './rendering-strategy-comps/react/ReactComp4';
import { ReactComp5 } from './rendering-strategy-comps/react/ReactComp5';
import ReactVueSharedComp from './rendering-strategy-comps/react/ReactVueSharedComp';
</script>
策略设计
vitepress-rendering-strategies
跨框架渲染策略目前为 react
组件提供了四种核心渲染模式,每种模式都针对特定的应用场景进行了优化。
注意事项
组件标签命名
- 必须以大写字母开头(
react
风格):例如MyComp
。 - 标签名必须与同一
.md
文件中<script lang="react">
块内本地导入的名称 完全匹配。如果你导入了import { Landing as HomeLanding } from '...';
,那么标签必须是<HomeLanding ... />
。 - 任何不匹配的情况都将在编译时被跳过,并显示一条警告。
- 必须以大写字母开头(
仅支持自闭合标签
markdown
中的react
组件必须写成自闭合形式:<Comp ... />
。- 非自闭合形式,如
<Comp>...</Comp>
,将被跳过并显示一条警告。
位置与导入
- 组件必须在 同一个
markdown
页面内的<script lang="react">
块中导入,未导入的组件将被忽略。 - 组件可以在
vue
的插槽/模板中使用(例如,在<template #default>...</template>
内部),它们仍然会被正确发现和转换。
- 组件必须在 同一个
Props
传递(一次性)- 标签上的所有非策略性属性都会作为
props
(字符串)传递给react
组件。像:page-title="page.title"
这样的vue
绑定会首先由vue
进行求值并写入DOM
属性,然后在react
渲染/水合(hydration
)期间作为props
转发。这是一个 一次性 的数据传递,不是响应式的。 - 不要通过属性传递函数或事件处理程序(例如
onClick
),不支持跨框架桥接可调用的props
/事件。
- 标签上的所有非策略性属性都会作为
Client:Only
特征分析:
适用于强依赖宿主环境的客户端组件,如依赖浏览器宿主环境的
window
、document
对象或宿主环境的API
的组件。该模式通常用于非关键组件或轻量组件的渲染,利好
TTFB
指标(减少服务器负载),但不利好FCP
、LCP
指标(首屏无内容)、TTI
指标(需等待JS
加载)以及SEO
(内容不在初始HTML
中)。对INP
指标影响取决于组件复杂度。该模式对于服务器负载较低(或几乎没有),整个渲染开销完全由用户宿主环境承担,提供商通常可以将脚本托管到
CDN
上或用作服务器高负载时的回退方案。该模式对于开发者来说心智负担较低,开发环境若无需集成复杂的渲染逻辑以及生产环境中局部更新组件时常见使用到的渲染模式,是现阶段最常见的渲染模式。
<script lang="react">
import ReactComp1 from './rendering-strategy-comps/react/ReactComp1';
</script>
<ReactComp1 client:only render-strategy="client:only" component-name="ReactComp1" :page-title="page.title" :render-count="1" />
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc1.css';
export default function ReactComp1(props: CompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-comp1-demo">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<button
className="rc1-button"
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>仅客户端渲染模式, React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
.rc1-button {
padding: 5px;
border-radius: 8px;
font-size: 14px;
margin-right: 8px;
background-color: #ff6d0563;
color: #0f3923;
border: none;
}
预处理为:
<div
__vrite__react_render_id__="dd3e6d7f"
__vrite__react_render_directive__="client:only"
__vrite__react_render_component__="ReactComp1"
__vrite__react_spa_sync_render__="false"
render-strategy="client:only"
component-name="ReactComp1"
page-title="渲染策略"
render-count="1"
></div>
渲染结果如下:
SSR:Only
特征分析:
适用于纯静态内容组件,如数据展示、
SEO
关键内容等不需要客户端交互的组件。服务器渲染优先策略是文档内容导向(SSG
)最常用的渲染策略,这是vitepress-rendering-strategies
默认的渲染策略。astro
也采取该策略作为 默认渲染策略:Astro leverages server rendering over client-side rendering in the browser as much as possible.
该模式与
SSG
模式结合,预渲染开销仅在项目构建时产生,构建完成后生成的静态HTML
可托管到CDN
上,不会影响生产服务器的负载。若需满足特定的实时渲染支持,可结合ISR
来实现。该模式也可作为服务器高负载时的回退方案。该模式除了不利好实时性渲染和交互性需求外,对其他各指标(
FCP
、LCP
、SEO
等)来说是利好的,同时避免了客户端javascript
包体积的增加。
这是
vitepress-rendering-strategies
的默认渲染策略,符合文档导向型项目的核心需求。
<script lang="react">
import { ReactComp2 } from './rendering-strategy-comps/react/ReactComp2';
</script>
<ReactComp2 ssr:only render-strategy="ssr:only" component-name="ReactComp2" :page-title="page.title" :render-count="2" />
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc2.css';
export function ReactComp2(props: CompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-comp2-demo">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<button
className="rc2-button"
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>仅预渲染模式, React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
.rc2-button {
padding: 5px;
border-radius: 8px;
font-size: 14px;
margin-right: 8px;
background-color: #9ceaca63;
color: #1dd270;
border: none;
}
预处理为:
<div
__vrite__react_render_id__="bd41e4f2"
__vrite__react_render_directive__="ssr:only"
__vrite__react_render_component__="ReactComp2"
__vrite__react_spa_sync_render__="true"
render-strategy="ssr:only"
component-name="ReactComp2"
page-title="渲染策略"
render-count="2"
></div>
渲染结果如下:
- 组件名称: ReactComp2
- 页面标题: 渲染策略
- 仅预渲染模式, React 实例数量: 0
Client:Load
特征分析:
- 这是典型的同构应用组件,需要服务端渲染以提升首屏性能,同时需要客户端交互功能,适用于关键组件的渲染。
- 采用类似传统
SSR
的架构模式,在构建时预渲染组件生成初始HTML
,客户端脚本加载完成后立即执行hydration
工作接管组件交互。在传统SSR
应用中可能会遇到性能瓶颈问题,包括高并发时服务器渲染性能问题和客户端FID
、INP
指标问题,给用户感觉是虚假站点而弱交互性体验。孤岛架构 简化了传统SSR
架构的复杂度,各组件容器可独立完成渲染和hydration
流程,无需等待所有组件渲染完成后进行根容器的一次性hydration
。 - 需要注意的是,这与传统的
SSR
架构不同,这是在SSG
架构基础上完成hydration
工作。预渲染在构建时完成,生成静态HTML
,而非传统SSR
的运行时渲染,构建完成后可托管到CDN
上,不会影响生产服务器的负载。因此使用该模式相对于ssr:only
模式,增加的是客户端hydration
流程,这部分开销由CDN
和用户宿主环境承担。 - 该模式通常情况下利好
FCP
、LCP
指标(快速显示内容),但不利好TTI
指标(需要hydration
时间)。对FID
、INP
指标的影响取决于hydration
期间的主线程阻塞程度和组件复杂度。
<script lang="react">
import ReactComp3 from './rendering-strategy-comps/react/ReactComp3';
</script>
<ReactComp3 client:load spa:sync-render render-strategy="client:load" component-name="ReactComp3" :page-title="page.title" :render-count="3" />
import { useState } from 'react';
import type { CompProps } from '../type';
import './css/rc3.css';
export default function ReactComp3(props: CompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-comp3-demo">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<button
className="rc3-button"
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>预渲染客户端 hydration 模式, React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
.rc3-button {
padding: 5px;
border-radius: 8px;
font-size: 14px;
margin-right: 8px;
background-color: #56a8ab;
color: #9ee2d3;
border: none;
}
预处理为:
<div
__vrite__react_render_id__="9cf6644e"
__vrite__react_render_directive__="client:load"
__vrite__react_render_component__="ReactComp3"
__vrite__react_spa_sync_render__="true"
render-strategy="client:load"
component-name="ReactComp3"
page-title="渲染策略"
render-count="3"
></div>
渲染结果如下:
- 组件名称: ReactComp3
- 页面标题: 渲染策略
- 预渲染客户端 hydration 模式, React 实例数量: 0
Client:Visible
特征分析:
- 适用于非首屏关键内容的交互式组件,如页面底部的评论系统、图表组件等。但需要注意的是,组件脚本默认会采取预加载策略,并非存粹的懒加载。
- 特征可参考
client:load
。
<script lang="react">
import { ReactComp4 } from './rendering-strategy-comps/react/ReactComp4';
</script>
<ReactComp4 client:visible render-strategy="client:visible" component-name="ReactComp4" :page-title="page.title" :render-count="4" />
import { useState } from 'react';
import type { CompProps } from '../type';
export function ReactComp4(props: CompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-comp4-demo">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<button
style={{
padding: '5px',
borderRadius: '8px',
fontSize: '14px',
marginRight: '8px',
backgroundColor: '#56a8ab',
color: '#9ee2d3',
border: 'none'
}}
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>预渲染客户端可见 hydration 模式, React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
预处理为:
<div
__vrite__react_render_id__="81ca6107"
__vrite__react_render_directive__="client:visible"
__vrite__react_render_component__="ReactComp4"
__vrite__react_spa_sync_render__="false"
render-strategy="client:visible"
component-name="ReactComp4"
page-title="渲染策略"
render-count="4"
></div>
渲染结果如下:
- 组件名称: ReactComp4
- 页面标题: 渲染策略
- 预渲染客户端可见 hydration 模式, React 实例数量: 0
默认策略
默认渲染策略等价于 ssr:only
模式,详情可见 ssr:only
。
<script lang="react">
import { ReactComp5 } from './rendering-strategy-comps/react/ReactComp5';
</script>
<ReactComp5 render-strategy="default" component-name="ReactComp5" :page-title="page.title" :render-count="5" />
import { useState } from 'react';
import type { CompProps } from '../type';
export function ReactComp5(props: CompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-comp5-demo">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<button
style={{
padding: '5px',
borderRadius: '8px',
fontSize: '14px',
marginRight: '8px',
backgroundColor: '#56a8ab',
color: '#9ee2d3',
border: 'none'
}}
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>默认渲染模式(仅预渲染模式), React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
预处理为:
<div
__vrite__react_render_id__="f4dd2447"
__vrite__react_render_directive__="ssr:only"
__vrite__react_render_component__="ReactComp5"
__vrite__react_spa_sync_render__="false"
render-strategy="default"
component-name="ReactComp5"
page-title="渲染策略"
render-count="5"
></div>
渲染结果如下:
- 组件名称: ReactComp5
- 页面标题: 渲染策略
- 默认渲染模式(仅预渲染模式), React 实例数量: 0
渲染策略组合
本库支持 vue
组件与 react
组件的嵌套使用。在组件 首次渲染 时,vue
父组件可以通过 slot
将数据作为 props
一次性 传递给 react
子组件,用于初始化 react
组件的状态。
渲染根容器首次快照会先经
vue
渲染引擎处理,再完成对应UI
框架的渲染工作,因此渲染组件的props
可访问到根容器快照的属性。
<script setup>
import VueComp1 from './rendering-strategy-comps/vue/VueComp1.vue';
const page = {
title: '渲染策略'
};
const vueUniqueId = 'vue-unique-id';
</script>
<script lang="react">
import ReactVueSharedComp from './rendering-strategy-comps/react/ReactVueSharedComp';
</script>
<VueComp1
:unique-id="vueUniqueId"
render-strategy="client:only"
component-name="VueComp1"
:page-title="page.title"
:render-count="6"
>
<template #default="{ vueInfo }">
<ReactVueSharedComp client:only render-strategy="client:only" component-name="ReactVueSharedComp" :page-title="page.title" render-count="3-7" :vue-info="vueInfo" />
</template>
</VueComp1>
import { useState } from 'react';
import type { CompProps } from '../type';
interface ReactVueSharedCompProps extends CompProps {
'vue-info': string;
}
export default function ReactVueSharedComp(props: ReactVueSharedCompProps) {
const [count, setCount] = useState(0);
return (
<div className="react-vue-shared-comp">
<strong>
{props['render-count']}: 渲染策略: {props['render-strategy']}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{props['component-name']}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{props['page-title']}</span>
</li>
<li>
<strong>Vue 组件信息:</strong> <span>{props['vue-info']}</span>
</li>
<li>
<button
style={{
padding: '5px',
borderRadius: '8px',
fontSize: '14px',
marginRight: '8px',
backgroundColor: '#56a8ab',
color: '#9ee2d3',
border: 'none'
}}
onClick={() => setCount(count + 1)}
type="button"
>
点击我!
</button>
<strong>仅客户端渲染模式, React 实例数量:</strong>{' '}
<span>{count}</span>
</li>
</ol>
</div>
);
}
<script setup lang="ts">
export interface CompProps {
componentName: string;
renderStrategy: string;
pageTitle: string;
renderCount: number;
}
const props = defineProps<CompProps>();
const vueInfo = 'VueComp1';
</script>
<template>
<div class="vue-comp1-demo">
<strong>
{{ props.renderCount }}: 渲染策略: {{ props.renderStrategy }}
</strong>
<ol>
<li>
<strong>组件名称:</strong> <span>{{ props.componentName }}</span>
</li>
<li>
<strong>页面标题:</strong> <span>{{ props.pageTitle }}</span>
</li>
<li>
<strong>子组件渲染:</strong>
<slot :vue-info="vueInfo"></slot>
</li>
</ol>
</div>
</template>
渲染结果如下:
- 组件名称: VueComp1
- 页面标题: 渲染策略
- 子组件渲染:
集成方式
要在 vitepress
项目中启用跨框架渲染策略,需要在构建配置中引入相应的插件:
import { defineConfig } from 'vitepress';
import vitepressReactRenderingStrategies from 'vitepress-rendering-strategies/react';
const vitePressConfig = defineConfig({
// ...
});
vitepressReactRenderingStrategies(vitePressConfig);
export default vitePressConfig;
import DefaultTheme from 'vitepress/theme';
import reactClientIntegration from 'vitepress-rendering-strategies/react/client';
import type { Theme } from 'vitepress';
const theme: Theme = {
extends: DefaultTheme,
async enhanceApp(context) {
await reactClientIntegration();
}
};
export default theme;
注意事项
项目结构一致性:项目结构需要与路由配置保持一致,否则可能会导致渲染策略失效。
错误处理机制:当组件
hydration
失败时,系统会自动降级到客户端渲染模式,确保用户体验不受影响。性能最佳实践:
- 优先使用
ssr:only
模式处理静态内容。 - 仅对需要交互的关键组件使用
client:load
。 - 非首屏组件建议使用
client:visible
延迟加载。 - 避免在单个页面中大量使用
spa:sr
指令,以免影响主体内容加载。
- 优先使用