Files
BoosAPI/desktop/libs/markstream-vue/docs/zh/guide/react-markdown-migration-cookbook.md
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

7.7 KiB

react-markdown 迁移 Cookbook

这一页是对主迁移指南 从 react-markdown 迁移 的补充,专门放常见场景的 before / after 示例。

::: tip 想看可运行的例子? 可以直接打开线上 React migration demo,在 React playground 里查看同一套 before / after 和审计输出。 :::

当你已经确定要迁移,但卡在这些具体问题时,就看这一页:

  • components.code 怎么替换?
  • remark-gfm 怎么办?
  • rehype-raw 怎么迁?
  • urlTransform 怎么替?
  • allowedElements 怎么处理?

1. 纯渲染器替换

// before
import Markdown from 'react-markdown'

export function Article({ markdown }: { markdown: string }) {
  return <Markdown>{markdown}</Markdown>
}
// after
import MarkdownRender from 'markstream-react'
import 'markstream-react/index.css'

export function Article({ markdown }: { markdown: string }) {
  return <MarkdownRender content={markdown} />
}

2. components.h1 和 components.a

react-markdown 的自定义通常是围绕 HTML tag:

import Markdown from 'react-markdown'

<Markdown
  components={{
    h1({ children }) {
      return <h1 className="docs-title">{children}</h1>
    },
    a({ href, children }) {
      return <a href={href} target="_blank" rel="noreferrer">{children}</a>
    },
  }}
>
  {markdown}
</Markdown>

markstream-react 则是围绕节点类型:

import type { NodeComponentProps } from 'markstream-react'
import MarkdownRender, { setCustomComponents } from 'markstream-react'

function CustomHeading({ node, ctx, renderNode, indexKey }: NodeComponentProps<any>) {
  const Tag = `h${node.level || 1}` as keyof JSX.IntrinsicElements

  return (
    <Tag className="docs-title">
      {node.children?.map((child: any, i: number) =>
        renderNode && ctx
          ? renderNode(child, `${String(indexKey)}-heading-${i}`, ctx)
          : null,
      )}
    </Tag>
  )
}

function CustomLink({ node, ctx, renderNode, indexKey }: NodeComponentProps<any>) {
  return (
    <a href={node.href} target="_blank" rel="noreferrer">
      {node.children?.map((child: any, i: number) =>
        renderNode && ctx
          ? renderNode(child, `${String(indexKey)}-link-${i}`, ctx)
          : null,
      )}
    </a>
  )
}

setCustomComponents('docs', {
  heading: CustomHeading,
  link: CustomLink,
})

export function Article({ markdown }: { markdown: string }) {
  return <MarkdownRender customId="docs" content={markdown} />
}

3. components.code

很多 react-markdown 项目会覆盖 code 来接代码高亮:

<Markdown
  components={{
    code({ className, children, ...rest }) {
      const isBlock = /language-/.test(className || '')

      if (isBlock) {
        return (
          <pre className="docs-code">
            <code className={className} {...rest}>
              {children}
            </code>
          </pre>
        )
      }

      return <code {...rest}>{children}</code>
    },
  }}
>
  {markdown}
</Markdown>

在 markstream-react 里,一般选这三条之一:

  • 继续用默认 CodeBlockNode
  • 把 code_block 切到 MarkdownCodeBlockNode
  • 设置 renderCodeBlocksAsPre

下面是 MarkdownCodeBlockNode 的例子:

import MarkdownRender, { MarkdownCodeBlockNode, setCustomComponents } from 'markstream-react'

setCustomComponents('docs', {
  code_block: ({ node, isDark, ctx }: any) => (
    <MarkdownCodeBlockNode
      node={node}
      isDark={isDark}
      stream={ctx?.codeBlockStream}
      {...(ctx?.codeBlockProps || {})}
    />
  ),
})

export function Article({ markdown }: { markdown: string }) {
  return <MarkdownRender customId="docs" content={markdown} />
}

4. remark-gfm

很多 react-markdown 示例会这样写:

import remarkGfm from 'remark-gfm'

<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>

在 markstream-react 里,不要默认把它重新接回来。

先删掉,再观察你的真实内容是否仍然正常。表格、任务列表、删除线、代码围栏这些本来就是解析器常见覆盖场景;如果你依赖某些非常具体的插件行为,再做额外回归确认。

5. rehype-raw

典型 react-markdown 写法:

import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'

<Markdown rehypePlugins={[rehypeRaw]}>{markdown}</Markdown>

到了 markstream-react,已经没有公开的 rehype 阶段。

推荐迁移规则:

  • 如果你只是要标准 HTML 渲染,请直接验证内置 HTML 行为。
  • 如果你接它是为了支持 <thinking> 之类的可信自定义标签,请改用 customHtmlTags。
import type { NodeComponentProps } from 'markstream-react'
import MarkdownRender, { setCustomComponents } from 'markstream-react'

function ThinkingNode({ node }: NodeComponentProps<any>) {
  return <aside className="thinking-box">{node.content}</aside>
}

setCustomComponents('chat', { thinking: ThinkingNode })

export function Message({ markdown }: { markdown: string }) {
  return (
    <MarkdownRender
      customId="chat"
      content={markdown}
      customHtmlTags={['thinking']}
    />
  )
}

6. urlTransform

典型 react-markdown URL 改写:

function rewriteDocsUrl(url: string) {
  if (url.startsWith('/docs/'))
    return `https://example.com${url}`
  return url
}

<Markdown urlTransform={rewriteDocsUrl}>{markdown}</Markdown>

在 markstream-react 中,建议拆成两层:

  • parseOptions.validateLink 负责放行/拦截
  • 自定义 link / image 渲染器负责改写 URL
import type { NodeComponentProps } from 'markstream-react'
import MarkdownRender, { setCustomComponents } from 'markstream-react'

function rewriteDocsUrl(url: string) {
  if (url.startsWith('/docs/'))
    return `https://example.com${url}`
  return url
}

function CustomLink({ node, ctx, renderNode, indexKey }: NodeComponentProps<any>) {
  const href = rewriteDocsUrl(node.href)

  return (
    <a href={href}>
      {node.children?.map((child: any, i: number) =>
        renderNode && ctx
          ? renderNode(child, `${String(indexKey)}-link-${i}`, ctx)
          : null,
      )}
    </a>
  )
}

setCustomComponents('docs', { link: CustomLink })

export function Article({ markdown }: { markdown: string }) {
  return <MarkdownRender customId="docs" content={markdown} />
}

7. allowedElements

典型 react-markdown 过滤:

<Markdown allowedElements={['h1', 'h2', 'p', 'a', 'code', 'pre']}>
  {markdown}
</Markdown>

markstream-react 没有直接对等的 prop。

推荐规则:

  • 如果这个 allowlist 很轻、不是关键安全边界,可以先完成渲染器迁移,再留 TODO。
  • 如果这是安全或核心展示要求的一部分,就在渲染前自己过滤解析后的节点树。

也就是说,这一项通常属于“手工迁移项”,而不是自动 1:1 替换项。

8. 把流式能力当作第二阶段

等静态迁移稳定后,如果你有聊天 / SSE / AI 输出,再考虑升到 nodes 模式:

import MarkdownRender from 'markstream-react'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'

const md = getMarkdown('chat')
const nodes = parseMarkdownToStructure(buffer, md, { final: done })

export function Message() {
  return <MarkdownRender nodes={nodes} viewportPriority deferNodesUntilVisible />
}

这一步是可选项。不要为了“看起来更高级”而强行把纯静态场景也改成流式。

相关文档