从 React 巨石到 Astro 孤岛:PPT 幻灯片系统的组件化重构与解耦实践


在之前的探索中,我们曾通过 @revealjs/react + echarts-for-react 快速搭建了一套基于 React 的数据幻灯片播放页。然而随着项目的深入,原有的架构逐渐暴露出痛点:整个 PPT 树被 React 全量接管、静态文本页承受额外的 JS 运行时开销、以及新建不同类型的 PPT 时需反复修改多处代码。

为了实现类似 Astro 博客(在 src/content/blog/ 下新增一个 Markdown 文件即可全自动处理路由、导航与渲染)的极简体验,我们对 PPT 模块进行了一次彻底的架构重构。

本文将复盘本次重构的设计思路、技术权衡、分层实现与关键踩坑经验。


一、 重构背景与核心痛点

1. 旧架构存在的问题

  • 伪“孤岛”,真“巨石”:之前的方案在页面顶层使用了 client:only="react",将整个 Reveal.js 演示文稿(从外层容器到每一页静态封面、文本说明)都包裹在 React 树中,违背了 Astro 的**零 JS 默认静态输出(Islands Architecture)**理念。
  • 第三方 React 封装层受限:@revealjs/react 是一个轻量的 React 包装层,无法直接使用原生 Reveal.js 强大的插件生态(如 Highlight、Markdown、Notes、Math 等)与完整的生命周期事件。
  • 扩展成本高,违反开放封闭原则(OCP):若想新增一套不同板式或不同主题的 PPT,往往需要深入修改动态路由页面 [id].astro 中的分发逻辑,导致页面逻辑越变越复杂。

2. 我们的重构目标

  1. 纯正的 Astro 孤岛架构:Reveal.js 的容器骨架与静态幻灯片全部由 Astro 静态渲染;仅在包含 ECharts 图表的局部区域引入 React。
  2. 极简的开发者体验(DX):新增一套 PPT 只需在 src/ppts/ 下新建一个 .astro 文件,路由、导航、配置均自动生效,改动处降为 1 处。
  3. 高内聚低耦合:将 PPT 组件拆解为乐高积木(Cover、EchartContainer、Table 等),每套 PPT 可自主控制自身的主题、尺寸与动画配置。

二、 架构演进与技术权衡

在重构过程中,我们探讨了两种实现路径:

方案 组织方式 优点 缺点 最终结论
路径 1:纯 JSON 数据驱动 在数据集合中定义 JSON,包含元数据与幻灯片数据数组 结构统一,只写 JSON 数据 表达力受限,无法写 JSX/条件渲染,无法自由混用复杂自定义排版 ❌ 过于死板
路径 2:独立 .astro 组装 + 动态路由外壳 每套 PPT 是一个独立的 .astro 组件,[id].astro 作为纯净外壳 极度灵活,支持数据驱动与声明式混合;[id].astro 彻底解耦 需约定 meta 导出规范 ✅ 采用(最佳实践)

三、 分层架构与具体实现

重构后的系统结构清晰,各层职责单一明确:

src/
├── components/             # 🧱 乐高式幻灯片组件库
│   ├── Chart.jsx           # [React] 专职负责 ECharts 图表渲染引擎(React 孤岛)
│   ├── Cover.astro         # [Astro] 封面/文本幻灯片组件(输出 <section>,纯静态零 JS)
│   └── EchartContainer.astro # [Astro] 图表幻灯片组件(输出 <section>,内嵌 React Chart)
│
├── ppts/                   # 📄 PPT 组装与声明目录(唯一操作入口)
│   └── test.astro          # 独立 PPT 文件:自主定义 Reveal 配置与幻灯片排版
│
└── pages/ppt/              # ⚙️ 核心基础设施(完全自动化)
    ├── [id].astro          # 通用动态路由与 HTML Shell
    └── index.astro         # PPT 目录列表页(自动提取各 PPT 的 meta)

1. 图表 React 孤岛 (src/components/Chart.jsx)

仅封装 ECharts 核心逻辑,不引入任何 Reveal.js 依赖:

import React from 'react';
import ReactEChartsRaw from 'echarts-for-react';

const ReactECharts = ReactEChartsRaw?.default || ReactEChartsRaw;

export default function Chart({ title, source, option, height = '750px' }) {
  const chartOption = option || buildChartOption(title, source || []);

  return (
    <div style={{ width: '100%', height }}>
      <ReactECharts
        option={chartOption}
        opts={{ renderer: 'canvas' }}
        style={{ width: '100%', height: '100%' }}
        notMerge={true}
        lazyUpdate={true}
      />
    </div>
  );
}

2. 幻灯片积木组件 (Cover.astro & EchartContainer.astro)

单张幻灯片被拆分为不同类型的独立 Astro 组件,负责输出 <section> 标签:

  • Cover.astro:输出纯静态 HTML/CSS,零 JavaScript 注入。
  • EchartContainer.astro:输出带有背景的 <section>,并通过 client:only="react" 挂载 <Chart /> 孤岛。
---
// src/components/EchartContainer.astro
import Chart from './Chart.jsx';

const { title, source, background = '#09090b', transition } = Astro.props;
---

<section data-background-color={background} data-transition={transition}>
  <div class="slide-container chart-slide">
    {source?.length > 0 ? (
      <Chart title={title} source={source} client:only="react" />
    ) : (
      <p>无图表数据</p>
    )}
  </div>
</section>

3. 单套 PPT 组装文件 (src/ppts/test.astro)

每套 PPT 导出自身的 meta,并全权掌控自己的 Reveal.js 配置与页面排版:

---
// src/ppts/test.astro
import 'reveal.js/reset.css';
import 'reveal.js/reveal.css';
import 'reveal.js/theme/black.css';
import Cover from '../components/Cover.astro';
import EchartContainer from '../components/EchartContainer.astro';
import data from '../data/ppts/test/data.json';

export const meta = {
  title: 'TOP1 历史数据展示',
  description: '基于 Astro、Reveal.js 与 ECharts 的组件化幻灯片演示',
  note: '共收录 90 项核心历史数据,使用键盘方向键浏览',
};
---

<div class="reveal">
  <div class="slides">
    <!-- 封面页 -->
    <Cover title={meta.title} subtitle={meta.description} note={meta.note} />
    
    <!-- 数据驱动批量渲染 90 张图表 -->
    {data.map((item) => (
      <EchartContainer title={item.title} source={item.source} />
    ))}
  </div>
</div>

<script>
  import Reveal from 'reveal.js';
  import RevealHighlight from 'reveal.js/plugin/highlight';

  const deck = new Reveal(document.querySelector('.reveal'), {
    hash: true,
    slideNumber: true,
    transition: 'slide',
    width: 1600,
    height: 900,
    margin: 0.04,
    plugins: [RevealHighlight],
  });

  deck.initialize().then(() => {
    setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
  });
  deck.on('slidechanged', () => window.dispatchEvent(new Event('resize')));
  deck.on('resize', () => window.dispatchEvent(new Event('resize')));
</script>

4. 通用动态路由页 (src/pages/ppt/[id].astro)

动态路由不再做任何类型判断,只需动态导入目标 PPT 组件并渲染:

---
// src/pages/ppt/[id].astro
export function getStaticPaths() {
	const pptModules = import.meta.glob('../../ppts/*.astro');
	return Object.entries(pptModules).map(([path, loader]) => ({
		params: { id: path.replace(/^.*\/([^/]+)\.astro$/, '$1') },
		props: { loader },
	}));
}

const { loader } = Astro.props;
const module = await (loader as () => Promise<any>)();
const DeckContent = module.default;
const meta = module.meta ?? {};
---

<!doctype html>
<html lang="zh-CN">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<title>{meta.title ?? 'PPT'}</title>
	</head>
	<body>
		<DeckContent />
	</body>
</html>

四、 关键技术点与踩坑经验

1. 为什么使用 client:only="react" 而不是 client:visible?

  • 背景:ECharts 在初始化时需要依赖真实的浏览器 Canvas / DOM API。
  • 踩坑:如果使用 client:visible 或 client:load,Astro 会在构建时执行服务端渲染(SSR)。在 SSR 阶段,echarts-for-react 的 CommonJS 导出格式可能导致 Element type is invalid: expected a string or class/function but got: object 报错。
  • 解决:在图表孤岛中使用 client:only="react",明确指示 Astro 该组件无需进行服务端预渲染,不仅彻底避免了 SSR 环境下的 DOM 报错,还大幅加速了多达 90 张图表的静态构建时间。

2. ECharts 在 Reveal.js 缩放与切换时的尺寸自适应

  • 背景:Reveal.js 会通过 CSS transform: scale() 对幻灯片进行动态等比缩放,且在翻页前隐藏非当前页。
  • 解决:在 Reveal.js 初始化后及触发 slidechanged、resize 事件时,主动向窗口广播 window.dispatchEvent(new Event('resize')),触发 echarts-for-react 内部的重绘监听,确保每一张图表在切入视野时都能精确充满容器。

3. CJS 与 ESM 的 Interop 处理

  • 在 Vite / Astro 环境下导入 echarts-for-react 时,部分模块打包可能存在 default.default 的嵌套结构。
  • 通过 const ReactECharts = ReactEChartsRaw?.default || ReactEChartsRaw; 做一层防御性解构,确保开发与生产打包的一致性。

五、 重构收益与总结

维度 旧架构 (React 全量接管) 新架构 (Astro 孤岛 + 独立 Deck)
新建一套 PPT 需配置路由、注册表、在 JSX 中写 Switch 映射(改动 3~4 处) 只需在 src/ppts/ 新建 1 个 .astro 文件
客户端 JS 体积 包含整套 React 运行时 + 幻灯片全量 DOM 静态文本 0 JS,仅图表部分按需引入 React
Reveal.js 灵活性 受限于第三方 React 封装层 直接使用原生 Reveal.js,插件、主题随心定制
组件复用度 逻辑与样式混杂在单一组件中 乐高积木式拆分(Cover, EchartContainer 等)

通过本次重构,我们不仅彻底解锁了 Astro 在复杂交互式演示场景下的潜力,更建立了一套优雅、可扩展且符合开发直觉的幻灯片基础设施。未来即使接入表格、图片全屏背景、视频幻灯片等新型排版,也只需新增对应的 Astro 组件即可!