Skip to content

VitePress 跨框架渲染策略

概述

VitePress + React

vitepress-rendering-strategies 库为 vitepress 静态站点生成器提供跨框架组件渲染能力,突破了 vitepress 原生仅支持 vue 组件的限制。

现阶段仅扩展支持 react 组件渲染,未来将支持其他主流 UI 框架(例如 solidsveltepreactangular 等)渲染支持。

技术架构说明

本库受 astro孤岛架构(Islands Architecture) 设计启发,在 vitepressSSG(静态站点生成)基础上实现跨框架组件集成。

架构核心特点:

  • 静态优先:基于 vitepressSSG 架构,构建时完成组件预渲染。
  • 选择性 hydration:仅对需要交互的组件进行客户端激活。
  • 框架隔离:各框架组件独立运行,每个组件容器独立完成 hydration,避免全局状态冲突。
  • 渐进增强:优先考虑静态内容,通过不同渲染策略逐步增强为交互式应用。

功能特性

  • 跨框架支持: 目前在 vitepress 中原生支持 react 组件渲染,未来扩展至其他主流框架。
  • 多样化渲染策略: 参照 astro模板指令。客户端指令目前支持了 client:onlyclient:loadclient:visible 三种渲染模式,默认情况下采用 ssr:only 渲染模式。
  • SPA 路由优化: spa:sync-render(简写 spa:sr) 指令优化 SPA 路由切换性能。
  • 单向数据传递: 支持在组件首次渲染时,由 vuereact 子组件传递 props,用于初始化 React 组件。此为一次性传递,非响应式绑定。
  • 开发体验: 完整的 HMR 支持,提供流畅的开发体验。
  • 环境一致性: 开发与生产环境保持一致的渲染策略,避免开发与生产环境不一致导致的渲染问题。
  • 支持 MPA 模式: 完全兼容 vitepressMPA 模式。即使在 MPA 模式下,react 组件的渲染和 hydration 也能正常工作。

spa:sync-render 指令的设计初衷

vitepress 是一个 SSG 应用,在构建阶段完成页面的预渲染工作,同时客户端路由是受控的。首次页面渲染会完成客户端注水(过滤静态节点)工作,当路由发生变更,vitepress 会加载目标路由页面所依赖的客户端脚本,完成局部客户端渲染工作,这是典型的 SSG 应用的架构。

默认情况下,vitepress-rendering-strategies 会将目标页面中所有需预渲染组件(非 vue 组件)均集成到单独的脚本中,路由切换时会预加载该脚本,等待 vue 渲染完成后再将预渲染产物注入到根容器节点下,若组件需要注水,则继续加载客户端脚本完成客户端注水工作。这样确保了路由切换时组件渲染行为的连贯性,但存在很典型的一个问题,切换路由场景下无法发挥预渲染的性能优势和存在组件渲染闪烁问题

下述演示环境在 CPU: 20x slowdown0.75 倍速播放:

spa:sync-render:disable

vitepressSPA 路由切换中,vue 的内容更新是同步的,但加载和渲染非 vue 组件(如 react)的预渲染 HTML 是异步的。这个时间差导致了视觉上的闪烁,并使得预渲染的性能优势在切换时丢失。

vitepress 采用的 SSG 架构方案是合理的,我们并不打算对整体架构进行调整。那么目标就是在现有架构的基础上尽可能增强预渲染的性能优势,我们为此提供 spa:sync-render(简写 spa:sr) 指令,该指令会集成目标页面中所有使用该指令的组件所预渲染的产物到 vue 的客户端渲染脚本中,跟随着 vue 客户端渲染工作并同步完成预渲染产物的渲染工作,用户就不会看到特殊场景下某一时刻组件的闪烁问题。

spa:sync-render

文档导向型项目本身并不建议大量集成高负载、强交互的渲染组件,文档导向型项目更关心主体内容交付给用户的时间,我们 假设 这类组件是 非关键渲染组件。我们并不推荐为这类组件启用 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) 指令。

使用方式

md
<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 组件提供了四种核心渲染模式,每种模式都针对特定的应用场景进行了优化。

注意事项

  1. 组件标签命名

    • 必须以大写字母开头(react 风格):例如 MyComp
    • 标签名必须与同一 .md 文件中 <script lang="react"> 块内本地导入的名称 完全匹配。如果你导入了 import { Landing as HomeLanding } from '...';,那么标签必须是 <HomeLanding ... />
    • 任何不匹配的情况都将在编译时被跳过,并显示一条警告。
  2. 仅支持自闭合标签

    • markdown 中的 react 组件必须写成自闭合形式:<Comp ... />
    • 非自闭合形式,如 <Comp>...</Comp>,将被跳过并显示一条警告。
  3. 位置与导入

    • 组件必须在 同一个 markdown 页面内的 <script lang="react"> 块中导入,未导入的组件将被忽略。
    • 组件可以在 vue 的插槽/模板中使用(例如,在 <template #default>...</template> 内部),它们仍然会被正确发现和转换。
  4. Props 传递(一次性)

    • 标签上的所有非策略性属性都会作为 props(字符串)传递给 react 组件。像 :page-title="page.title" 这样的 vue 绑定会首先由 vue 进行求值并写入 DOM 属性,然后在 react 渲染/水合(hydration)期间作为 props 转发。这是一个 一次性 的数据传递,不是响应式的。
    • 不要通过属性传递函数或事件处理程序(例如 onClick),不支持跨框架桥接可调用的 props/事件。

Client:Only

特征分析:

  1. 适用于强依赖宿主环境的客户端组件,如依赖浏览器宿主环境的 windowdocument 对象或宿主环境的 API 的组件。

  2. 该模式通常用于非关键组件或轻量组件的渲染,利好 TTFB 指标(减少服务器负载),但不利好 FCPLCP 指标(首屏无内容)、TTI 指标(需等待 JS 加载)以及 SEO(内容不在初始 HTML 中)。对 INP 指标影响取决于组件复杂度。

  3. 该模式对于服务器负载较低(或几乎没有),整个渲染开销完全由用户宿主环境承担,提供商通常可以将脚本托管到 CDN 上或用作服务器高负载时的回退方案。

  4. 该模式对于开发者来说心智负担较低,开发环境若无需集成复杂的渲染逻辑以及生产环境中局部更新组件时常见使用到的渲染模式,是现阶段最常见的渲染模式。

md
<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" />
tsx
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>
  );
}
css
.rc1-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: #ff6d0563;
  color: #0f3923;
  border: none;
}

预处理为:

html
<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

特征分析

  1. 适用于纯静态内容组件,如数据展示、SEO 关键内容等不需要客户端交互的组件。服务器渲染优先策略是文档内容导向(SSG)最常用的渲染策略,这是 vitepress-rendering-strategies 默认的渲染策略。astro 也采取该策略作为 默认渲染策略

    Astro leverages server rendering over client-side rendering in the browser as much as possible.

  2. 该模式与 SSG 模式结合,预渲染开销仅在项目构建时产生,构建完成后生成的静态 HTML 可托管到 CDN 上,不会影响生产服务器的负载。若需满足特定的实时渲染支持,可结合 ISR 来实现。该模式也可作为服务器高负载时的回退方案。

  3. 该模式除了不利好实时性渲染和交互性需求外,对其他各指标(FCPLCPSEO 等)来说是利好的,同时避免了客户端 javascript 包体积的增加。

这是 vitepress-rendering-strategies 的默认渲染策略,符合文档导向型项目的核心需求。

md
<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" />
tsx
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>
  );
}
css
.rc2-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: #9ceaca63;
  color: #1dd270;
  border: none;
}

预处理为:

html
<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>

渲染结果如下:


2: 渲染策略: ssr:only
  1. 组件名称: ReactComp2
  2. 页面标题: 渲染策略
  3. 仅预渲染模式, React 实例数量: 0

Client:Load

特征分析

  1. 这是典型的同构应用组件,需要服务端渲染以提升首屏性能,同时需要客户端交互功能,适用于关键组件的渲染。
  2. 采用类似传统 SSR 的架构模式,在构建时预渲染组件生成初始 HTML,客户端脚本加载完成后立即执行 hydration 工作接管组件交互。在传统 SSR 应用中可能会遇到性能瓶颈问题,包括高并发时服务器渲染性能问题和客户端 FIDINP 指标问题,给用户感觉是虚假站点而弱交互性体验。孤岛架构 简化了传统 SSR 架构的复杂度,各组件容器可独立完成渲染和 hydration 流程,无需等待所有组件渲染完成后进行根容器的一次性 hydration
  3. 需要注意的是,这与传统的 SSR 架构不同,这是在 SSG 架构基础上完成 hydration 工作。预渲染在构建时完成,生成静态 HTML,而非传统 SSR 的运行时渲染,构建完成后可托管到 CDN 上,不会影响生产服务器的负载。因此使用该模式相对于 ssr:only 模式,增加的是客户端 hydration 流程,这部分开销由 CDN 和用户宿主环境承担。
  4. 该模式通常情况下利好 FCPLCP 指标(快速显示内容),但不利好 TTI 指标(需要 hydration 时间)。对 FIDINP 指标的影响取决于 hydration 期间的主线程阻塞程度和组件复杂度。
md
<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" />
tsx
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>
  );
}
css
.rc3-button {
  padding: 5px;
  border-radius: 8px;
  font-size: 14px;
  margin-right: 8px;
  background-color: #56a8ab;
  color: #9ee2d3;
  border: none;
}

预处理为:

md
<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>

渲染结果如下:


3: 渲染策略: client:load
  1. 组件名称: ReactComp3
  2. 页面标题: 渲染策略
  3. 预渲染客户端 hydration 模式, React 实例数量: 0

Client:Visible

特征分析

  1. 适用于非首屏关键内容的交互式组件,如页面底部的评论系统、图表组件等。但需要注意的是,组件脚本默认会采取预加载策略,并非存粹的懒加载。
  2. 特征可参考 client:load
md
<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" />
tsx
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>
  );
}

预处理为:

md
<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>

渲染结果如下:


4: 渲染策略: client:visible
  1. 组件名称: ReactComp4
  2. 页面标题: 渲染策略
  3. 预渲染客户端可见 hydration 模式, React 实例数量: 0

默认策略

默认渲染策略等价于 ssr:only 模式,详情可见 ssr:only

md
<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" />
tsx
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>
  );
}

预处理为:

md
<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>

渲染结果如下:


5: 渲染策略: default
  1. 组件名称: ReactComp5
  2. 页面标题: 渲染策略
  3. 默认渲染模式(仅预渲染模式), React 实例数量: 0

渲染策略组合

本库支持 vue 组件与 react 组件的嵌套使用。在组件 首次渲染 时,vue 父组件可以通过 slot 将数据作为 props 一次性 传递给 react 子组件,用于初始化 react 组件的状态。

渲染根容器首次快照会先经 vue 渲染引擎处理,再完成对应 UI 框架的渲染工作,因此渲染组件的 props 可访问到根容器快照的属性。

playground.md
vue
<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>
tsx
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>
  );
}
vue
<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>

渲染结果如下:


6: 渲染策略: client:only
  1. 组件名称: VueComp1
  2. 页面标题: 渲染策略
  3. 子组件渲染:

集成方式

要在 vitepress 项目中启用跨框架渲染策略,需要在构建配置中引入相应的插件:

ts
import { defineConfig } from 'vitepress';
import vitepressReactRenderingStrategies from 'vitepress-rendering-strategies/react';

const vitePressConfig = defineConfig({
  // ...
});

vitepressReactRenderingStrategies(vitePressConfig);

export default vitePressConfig;
ts
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;

注意事项

  1. 项目结构一致性:项目结构需要与路由配置保持一致,否则可能会导致渲染策略失效。

  2. 错误处理机制:当组件 hydration 失败时,系统会自动降级到客户端渲染模式,确保用户体验不受影响。

  3. 性能最佳实践

    • 优先使用 ssr:only 模式处理静态内容。
    • 仅对需要交互的关键组件使用 client:load
    • 非首屏组件建议使用 client:visible 延迟加载。
    • 避免在单个页面中大量使用 spa:sr 指令,以免影响主体内容加载。

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (2bfade1)