zh CN React 渲染 - chiba233/yumeDSL GitHub Wiki
详细示例和教程请访问 wiki 首页。
用于渲染 TextToken[] 树的即插即用递归 React 组件。
解析器配置与 Vue 示例完全一致——解析器本身与框架无关。
// src/dsl.ts
import {
createParser,
createSimpleInlineHandlers,
parsePipeArgs,
parsePipeTextArgs,
createToken,
type TagHandler,
type TokenDraft,
} from "yume-dsl-rich-text";
const titledHandler = (type: string, defaultTitle: string): TagHandler => ({
inline: (tokens, ctx): TokenDraft => {
const args = parsePipeArgs(tokens, ctx);
if (args.parts.length <= 1) return {type, title: defaultTitle, value: args.materializedTokens(0)};
return {type, title: args.text(0), value: args.materializedTailTokens(1)};
},
block: (arg, tokens, ctx): TokenDraft => ({type, title: arg || defaultTitle, value: tokens}),
raw: (arg, content, ctx): TokenDraft => ({
type, title: arg || defaultTitle,
value: [createToken({type: "text", value: content}, undefined, ctx)],
}),
});
const collapseHandler: TagHandler = {
block: (arg, tokens, ctx): TokenDraft => ({type: "collapse", title: arg || "详情", value: tokens}),
raw: (arg, content, ctx): TokenDraft => ({
type: "collapse", title: arg || "详情",
value: [createToken({type: "text", value: content}, undefined, ctx)],
}),
};
const rawCodeHandler: TagHandler = {
raw: (arg, content, ctx): TokenDraft => {
const args = parsePipeTextArgs(arg || "", ctx);
return {type: "raw-code", lang: args.text(0) || "text", value: content};
},
};
export const dsl = createParser({
handlers: {
...createSimpleInlineHandlers(["bold", "thin", "underline", "strike", "code", "center"]),
link: {
inline: (tokens, ctx): TokenDraft => {
const args = parsePipeArgs(tokens, ctx);
return {
type: "link",
url: args.text(0),
value: args.materializedTailTokens(1),
};
},
},
info: titledHandler("info", "提示"),
warning: titledHandler("warning", "警告"),
collapse: collapseHandler,
"raw-code": rawCodeHandler,
},
});// src/components/RichTextRenderer.tsx
import type { TextToken } from "yume-dsl-rich-text";
import type { ReactNode, FC } from "react";
/* ── URL 安全校验 ── */
function normalizeUrl(raw: string): string {
try {
const url = new URL(raw, "https://example.com");
if (url.protocol === "http:" || url.protocol === "https:") return url.href;
} catch {
// 无效 URL
}
return "#";
}
/* ── 每个标签的渲染器 ── */
/**
* 将每种标签类型映射到一个渲染函数。
* 新增标签时扩展这个对象即可。
*
* 每个渲染器接收 token 本身和一个 children ReactNode
* (由递归渲染 token.value 产出,前提是 value 是数组)。
*/
const tagRenderers: Record<string, (token: TextToken, children: ReactNode) => ReactNode> = {
bold: (_t, children) => <strong>{children}</strong>,
thin: (_t, children) => <span style={{fontWeight: 300}}>{children}</span>,
underline: (_t, children) => <u>{children}</u>,
strike: (_t, children) => <s>{children}</s>,
code: (_t, children) => <code>{children}</code>,
center: (_t, children) => <div style={{textAlign: "center"}}>{children}</div>,
link: (token, children) => (
<a
href={normalizeUrl(String(token.url ?? "#"))}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
info: (token, children) => (
<div className="alert alert-info" role="alert">
{token.title && <strong>{String(token.title)}:</strong>}
{children}
</div>
),
warning: (token, children) => (
<div className="alert alert-warning" role="alert">
{token.title && <strong>{String(token.title)}:</strong>}
{children}
</div>
),
collapse: (token, children) => (
<details>
<summary>{String(token.title ?? "详情")}</summary>
{children}
</details>
),
"raw-code": (token) => (
<pre className="code-block" data-lang={String(token.lang ?? "text")}>
<code>{typeof token.value === "string" ? token.value : ""}</code>
</pre>
),
};
/* ── 递归渲染器 ── */
interface RichTextRendererProps {
tokens: TextToken[];
}
/**
* 递归地将 TextToken[] 树渲染为 React 元素。
*
* - 纯文本 token → <span>
* - 已知标签 → 从 tagRenderers 查找
* - 未知标签 → <span> 兜底(优雅降级)
* - 原始字符串值(如 raw-code)→ 不递归,直接渲染
*/
export const RichTextRenderer: FC<RichTextRendererProps> = ({tokens}) => (
<>
{tokens.map((token, idx) => {
const key = token.id ?? idx;
// 纯文本
if (token.type === "text" && typeof token.value === "string") {
return <span key={key}>{token.value}</span>;
}
// 递归渲染子节点(当 value 是 token 数组时)
const children: ReactNode =
Array.isArray(token.value) && token.value.length > 0
? <RichTextRenderer tokens={token.value} />
: typeof token.value === "string"
? token.value
: null;
// 查找标签对应的渲染器
const renderer = tagRenderers[token.type];
if (renderer) {
return <span key={key}>{renderer(token, children)}</span>;
}
// 未知标签——优雅降级
return <span key={key}>{children}</span>;
})}
</>
);-
tagRenderers是一个普通对象,把token.type映射到渲染函数。每个函数接收完整的 token(可以读取href、title、lang等额外字段)和一个预渲染好的childrenReactNode。 -
RichTextRenderer遍历 token 数组。对于每个 token:- 如果是文本叶节点 →
<span>{value}</span> - 如果
token.value是TextToken[]→ 递归调用<RichTextRenderer>产出children - 查找
tagRenderers[token.type]→ 调用(token, children) - 没有匹配 → 在普通
<span>中渲染children(优雅降级,与解析器行为一致)
- 如果是文本叶节点 →
-
没有
dangerouslySetInnerHTML— 一切都渲染为 React 元素。解析器输出的是数据结构,不是 HTML。
// src/App.tsx
import { dsl } from "./dsl";
import { RichTextRenderer } from "./components/RichTextRenderer";
function App() {
const source = "你好 $$bold(世界)$$!访问 $$link(https://example.com | 这里)$$。";
const tokens = dsl.parse(source);
return <RichTextRenderer tokens={tokens} />;
}
export default App;import { useState, useMemo } from "react";
import { dsl } from "./dsl";
import { RichTextRenderer } from "./components/RichTextRenderer";
function LivePreview() {
const [source, setSource] = useState("$$bold(你好)$$ $$italic(世界)$$!");
// 仅在 source 变化时重新解析
const tokens = useMemo(() => dsl.parse(source), [source]);
return (
<div>
<textarea
value={source}
onChange={(e) => setSource(e.target.value)}
rows={5}
cols={60}
/>
<div className="preview">
<RichTextRenderer tokens={tokens} />
</div>
</div>
);
}性能提示:
dsl.parse()是同步调用且速度很快。对于典型的聊天消息/游戏对话,在useMemo中每次按键都重新解析完全没问题。对于超大文档(10KB+),可以考虑 debounce。
import { Alert, AlertTitle } from "@mui/material";
const tagRenderers = {
// ... 基础渲染器 ...
info: (token: TextToken, children: ReactNode) => (
<Alert severity="info">
<AlertTitle>{String(token.title ?? "提示")}</AlertTitle>
{children}
</Alert>
),
warning: (token: TextToken, children: ReactNode) => (
<Alert severity="warning">
<AlertTitle>{String(token.title ?? "警告")}</AlertTitle>
{children}
</Alert>
),
};import { Alert, Typography } from "antd";
const tagRenderers = {
// ... 基础渲染器 ...
info: (token: TextToken, children: ReactNode) => (
<Alert type="info" message={String(token.title ?? "提示")} description={children} />
),
"raw-code": (token: TextToken) => (
<Typography.Text code>
{typeof token.value === "string" ? token.value : ""}
</Typography.Text>
),
};import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
const tagRenderers = {
// ... 基础渲染器 ...
"raw-code": (token: TextToken) => (
<SyntaxHighlighter
language={String(token.lang ?? "text")}
style={oneDark}
>
{typeof token.value === "string" ? token.value : ""}
</SyntaxHighlighter>
),
};| 方面 | Vue | React |
|---|---|---|
| 标签映射 |
tagMap 对象(字符串或 Component)+ getComponentProps
|
tagRenderers 对象(渲染函数) |
| 递归 | 模板中 <RichTextRenderer :tokens="token.value" />
|
JSX 中 <RichTextRenderer tokens={token.value} />
|
| 每个标签的 props |
getComponentProps(token) switch |
内联在每个渲染函数中 |
| 响应式 | Computed / ref | useMemo |
| URL 校验 | 同一个 normalizeUrl 函数 |
同一个 normalizeUrl 函数 |
| 解析器配置 | 完全相同的 dsl.ts
|
完全相同的 dsl.ts
|
解析器与框架无关——dsl.ts 是共享的。只有渲染层不同。