Hydration utilities

把 Rho build-time 输出的"带 marker class 的静态 HTML"在浏览器里激活成可交互组件。


什么需要 hydration

DSL 需要 hydration
interact(slider / input / select / toggle / button / template / computed / timer / vega / svg) ✅
tabs(点击切换) ✅
stepper(Prev/Next) ✅
modal(点击展开) ✅
───────────────────────────────
Callout ❌ 静态 HTML
Layout grid + cards ❌ CSS Grid
Annotate ❌ CSS hover
Timeline ❌ CSS layout
STL syntax highlight ❌ highlight.js 自动

hydrateAll(container) —— 一键激活

最常用 API。扫 container 内所有 Rho marker class,依次接管。

import { hydrateAll } from '@rho/md';

const container = document.getElementById('root');
container.innerHTML = await renderRho(markdown);
hydrateAll(container);

幂等——同一 container 多次调用安全。每个 hydrated 块会标 data-rho-hydrated="true",后续调用跳过。

等价于:

renderInteractBlocks(container);
renderTabsBlocks(container);
renderStepperBlocks(container);
renderModalBlocks(container);

单独 hydrate

需要精细控制时(lazy load / 性能调优 / 部分接管):

import {
  renderInteractBlocks,
  renderTabsBlocks,
  renderStepperBlocks,
  renderModalBlocks,
} from '@rho/md';

// 只 hydrate tabs(structural),暂缓 interact(重型)
renderTabsBlocks(container);

// 后续在 tab 切换时再 hydrate active panel 内的 interact
container.querySelector('.rho-tab-btn').addEventListener('click', () => {
  const activePanel = container.querySelector('.rho-tab-panel:not([hidden])');
  renderInteractBlocks(activePanel);
});

Hydration 时机

Pattern 1:渲染立即 hydrate(默认)

container.innerHTML = html;
hydrateAll(container);

适合:内容不大,所有 interact 都立刻可见。

Pattern 2:滚动到视口才 hydrate(lazy)

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      hydrateAll(entry.target);
      observer.unobserve(entry.target);
    }
  });
});

container.querySelectorAll('.rho-interact, .rho-tabs, .rho-stepper').forEach((el) => {
  observer.observe(el);
});

适合:长文档含很多 chart——避免首屏卡顿。

Pattern 3:用户主动触发才 hydrate

container.innerHTML = html;
const button = document.getElementById('activate-demos');
button.addEventListener('click', () => {
  hydrateAll(container);
  button.remove();
});

适合:极端性能优化场景,明确告诉用户"点击启用 demo"。


Hydration options

部分 hydrate 函数支持 options(注意:option 类型可能因版本演化而扩展):

import { hydrateAll } from '@rho/md';
import type { HydrationOptions } from '@rho/md';

const options: HydrationOptions = {
  // hydrate 完成 callback(每块)
  onBlockHydrated: (blockEl: HTMLElement, type: string) => {
    console.log(`Hydrated ${type}`);
  },

  // hydrate 失败时的 fallback UI
  errorBoundary: (blockEl: HTMLElement, error: Error) => {
    blockEl.innerHTML = `<p style="color: red">Failed to hydrate: ${error.message}</p>`;
  },

  // 跳过特定 marker class(默认全 hydrate)
  skip: ['rho-modal'],
};

hydrateAll(container, options);

重新 hydrate(内容变了)

如果 markdown 源改变 → 重新 process → 重新塞进 container:

async function update(newMarkdown: string) {
  container.innerHTML = await renderRho(newMarkdown);
  hydrateAll(container);
}

为什么 hydrateAll 还是安全:旧 hydrated DOM 已被 innerHTML = ... 替换;新 DOM 没有 data-rho-hydrated,会被正常 hydrate。

⚠️ 替换 DOM 时之前的 interact state(slider 拖到的位置 / namespace 内的 computed 当前值)会丢失——hydrate 是 "static HTML → live",不是 "live → live"。需要保留 state 用 React/Vue 的 component 化思路(在框架里管理 state)。


嵌套块的 hydrate 顺序

hydrateAll 默认外层先 hydrate。例:

<div class="rho-tabs">                ← 1. 先 hydrate tabs
  <div class="rho-tab-panel">
    <div class="rho-interact">         ← 2. 再 hydrate interact
      <div class="rho-modal-trigger">  ← 3. 最后 hydrate modal
    </div>
  </div>
</div>

通常你不需要关心——hydrate 是声明式,结果一致。但调试 hydrate 顺序问题时知道这点有用。


Server-side rendering (SSR) + hydration

// === SSR (Node.js) ===
const html = String(await processor.process(markdown));
const fullPage = `
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/@rho/md/css/stl-md.css">
</head>
<body>
  <div id="root" class="markdown-body">${html}</div>
  <script type="module" src="/client.js"></script>
</body>
</html>
`;
return fullPage;
// === client.js (browser) ===
import { hydrateAll } from '@rho/md';

hydrateAll(document.getElementById('root')!);

关键:SSR 只输出 HTML(含 marker class);hydrate 必须在浏览器跑。


React / Vue / Svelte 集成

React (useEffect)

import { useEffect, useRef } from 'react';
import { hydrateAll } from '@rho/md';

function RhoMarkdown({ source }: { source: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    (async () => {
      ref.current!.innerHTML = await renderRho(source);
      hydrateAll(ref.current!);
    })();
  }, [source]);

  return <div ref={ref} className="markdown-body" />;
}

Vue 3

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { hydrateAll } from '@rho/md';

const props = defineProps<{ source: string }>();
const root = ref<HTMLElement>();

const update = async () => {
  if (!root.value) return;
  root.value.innerHTML = await renderRho(props.source);
  hydrateAll(root.value);
};

onMounted(update);
watch(() => props.source, update);
</script>

<template>
  <div ref="root" class="markdown-body" />
</template>

Svelte

<script lang="ts">
  import { onMount } from 'svelte';
  import { hydrateAll } from '@rho/md';

  export let source: string;
  let root: HTMLDivElement;

  onMount(async () => {
    root.innerHTML = await renderRho(source);
    hydrateAll(root);
  });
</script>

<div bind:this={root} class="markdown-body" />

性能 tips

  • 单页 hydrate 20+ interact 块 → 考虑 lazy hydrate
  • 首屏只 hydrate 视口内可见的 → 滚动时按需 hydrate 后续
  • 大型 chart(vega-lite)首屏卡 → 让用户点击 trigger 才 hydrate
  • 重复同 namespace 的 interact 块共享 state → reactive 更新只算一次

See also