zh CN React 渲染 - chiba233/yumeDSL GitHub Wiki

React 渲染

Vue 3 渲染 | 导出一览

详细示例和教程请访问 wiki 首页

用于渲染 TextToken[] 树的即插即用递归 React 组件。


1. 配置解析器 (dsl.ts)

解析器配置与 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,
    },
});

2. RichTextRenderer.tsx

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

工作原理

  1. tagRenderers 是一个普通对象,把 token.type 映射到渲染函数。每个函数接收完整的 token(可以读取 hreftitlelang 等额外字段)和一个预渲染好的 children ReactNode。

  2. RichTextRenderer 遍历 token 数组。对于每个 token:

    • 如果是文本叶节点 → <span>{value}</span>
    • 如果 token.valueTextToken[] → 递归调用 <RichTextRenderer> 产出 children
    • 查找 tagRenderers[token.type] → 调用 (token, children)
    • 没有匹配 → 在普通 <span> 中渲染 children(优雅降级,与解析器行为一致)
  3. 没有 dangerouslySetInnerHTML — 一切都渲染为 React 元素。解析器输出的是数据结构,不是 HTML。


3. 使用

// 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。


4. 集成组件库

Material UI

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>
    ),
};

Ant Design

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>
    ),
};

5. 与 Vue 方案的对比

方面 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 是共享的。只有渲染层不同。

⚠️ **GitHub.com Fallback** ⚠️