ニューラルネットワークからのストリーミングMarkdownをちらつきやフリーズなしでレンダリングする方法
言語モデルからのストリーミング出力をフロントエンドに接続した瞬間、一般的なMarkdownレンダラーはすぐにページを悪夢に変えてしまいます。markdown-itやmarkedのような標準ライブラリは、完成した静的ドキュメント向けに設計されています。SSEやWebSocket経由で生のトークンストリームをそれらに与えると、チャンクごとにテキスト全体を再解析し、DOMツリーを再描画して、スクロールを乱してしまいます。
この時点で、インターフェースは目に見えるちらつきを始めます。シンタックスハイライトが点滅し、未完成のコードブロックが下のマークアップを壊し、閉じられなかった数式が無限ローディング状態に陥ります。markstream-vueリポジトリは、このまさに頭を悩ませる問題を解決します。
ライブラリの内部構造
このプロジェクトはVue 3用の専用コンポーネントとして始まりましたが、時間の経過とともに著者はアーキテクチャをコアのstream-markdown-parserとさまざまなフレームワーク向けのアダプターに分割しました。現在、Vue 3、Nuxt、React、Next.js、Svelte 5、Angular、さらにはレガシーなVue 2用のパッケージが利用可能です。
レンダラーの主要なタスクは、テキストの頻繁なマイクロアップデート中にDOMを安定させることです。このライブラリはストリームをインクリメンタルに解析し、未閉じタグの中間状態を理解し、変更されたノードのみを更新して、ページの残りの部分には手を加えません。
動作モードと負荷管理
このライブラリには、modeプロパティで切り替え可能な2つの根本的に異なるレンダリングアプローチがあります。
mode="chat"モードはAIチャット向けに設計されています。このモードでは、レンダラーは受信したトークンを小さなバッチにグループ化し、滑らかなタイピング効果で出力します。同時に、不必要なopacityアニメーションが無効化されるため、新しい単語が入力されるたびにインターフェースが抖动することがありません。
生成された長い記事やドキュメントを表示する必要がある場合は、mode="docs"で仮想化を有効にするのが良いでしょう。レンダラーはアクティブDOMツリー内の固定サイズの要素ウィンドウを維持します(デフォルトでは約220ノード)。これによりブラウザのメモリ消費が安定し、長い会話をスクロールする際のフリーズを防ぎます。
<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'markstream-vue'
import 'markstream-vue/index.css'
const message = ref('')
const isDone = ref(false)
// Получаем чанки через EventSource или fetch
const eventSource = new EventSource('/api/chat')
eventSource.onmessage = (event) => {
message.value += event.data
}
eventSource.addEventListener('done', () => {
isDone.value = true
eventSource.close()
})
</script>
<template>
<MarkdownRender
mode="chat"
:content="message"
:final="isDone"
smooth-streaming="auto"
:fade="false"
/>
</template>
ストリームを扱う際にfinalプロパティが重要です。final="false"がtrueの間、パーサーは途中で途切れた構文要素を穏やかに許容します。完了信号が到着すると、レンダラーはストリーミングキャッシュをクリアし、マークアップを最終的な形に整形します。
複雑なブロックの処理
一般的なパーサーは、構文が完全に書き上がる前にMermaidダイアグラムやKaTeX数式碰到するとつまずいてしまいます。markstreamでは、この点が最も小さな细节まで考慮されています。
Mermaidダイアグラムと数式
mermaidやkatexのような重い依存関係はメインバンドルに含まれていません。これらはピア依存関係としてインストールし、関数を呼び出してアクティブ化します:
import { enableKatex, enableMermaid } from 'markstream-vue'
import 'katex/dist/katex.min.css'
enableMermaid()
enableKatex()
Mermaidダイアグラムは段階的に解析されます。グラフがまだモデルによって書かれている最中の場合、レンダラーはコンソールに構文エラーを表示する代わりに、整ったプレースホルダーを表示します。KaTeXの場合、数式の解析をCDN経由で別のWeb Workerにオフロードできるため、重い数式式がメインインターフェーススレッドをブロックすることがありません。
コードブロックと差分
バージョン2.0では、重いMonacoエディターを捨ててstream-diffsとの統合に移行しました。これで、ストリーム内で直接インタラクティブなファイル差分を表示でき、ライトテーマとダークテーマの切り替えやブロックの高さ設定も可能です。
<template>
<MarkdownRender
:content="content"
:is-dark="true"
:code-block-props="{
theme: { light: 'vitesse-light', dark: 'vitesse-dark' }
}"
/>
</template>
Markdown内のカスタムVueコンポーネント
モデルは<reasoning><thinking>のような推論チェーン用や、ボタンやウィジェットを呼び出すカスタムショートコードなど、標準外のタグを出力することがあります。これらを傍受して完全なVueコンポーネントに置き換えることができます:
import { setCustomComponents } from 'markstream-vue'
setCustomComponents('chat-scope', {
CALLOUT: () => import('./components/Callout.vue'),
THINKING: () => import('./components/ThinkingAccordion.vue'),
})
テンプレートでは、同じ識別子を指定するだけです:
<MarkdownRender
:content="message"
custom-id="chat-scope"
:custom-html-tags="['thinking']"
/>
サーバーサイドレンダリングと状態の引き渡し
NuxtやNext.jsでアプリを作成している場合、クライアント側でパーサーをゼロから実行する必要はありません。ドキュメントはサーバー側でtyped nodesの構造に解析できます:
import { getMarkdown, parseMarkdownToStructure } from 'markstream-vue'
const md = getMarkdown()
const nodes = parseMarkdownToStructure(rawMarkdown, md, { final: true })
クライアントコンポーネントは、レイアウトの不整合のない素早いハイドレーションを提供する:nodes="nodesFromServer"プロパティ経由で準備されたノードを受け入れます。初期ページ読み込み後にストリーミングを継続する必要がある場合、クライアントは単にバッファを引き継いで新しい部分をさらに解析します。
実践的なシナリオ
このライブラリは一度にいくつかの一般的なフロントエンド開発タスクをカバーします:
- 大規模言語モデルとのダイアログインターフェース。ちらつきや画面抖动の解消が重要。
- 生成中に差分表示を備えたコードレビューとパッチ生成システム。
- 動的なセクションローディングとインタラクティブコンポーネントを備えたナレッジベースと changelog パネル。
- 数式や複雑なダイアグラムを含む技術ドキュメントページ。
まとめ
プロジェクトでローカルフォルダからの静的Markdownファイルを表示するだけなら、実績のあるmarkdown-itが不必要な複雑さなく対応してくれます。しかし、LLMからのライブトークンストリームを扱っている場合、チャットインターフェースをWebに移行する場合、または長いレスポンスのレンダリング時にラグと戦うのに疲れている場合、このライブラリは確かに依存関係に加える価値があります。
dozensの非自明なストリーミング問題を解決し、パーサーの вокруг独自の回避策を記述にかかる大量の時間を節約します。クイックスタートには、公式オンラインプレイグラウンドを確認するか、StackBlitz経由でテスト環境をデプロイしてください。
関連プロジェクト