跳到正文
DEV Community· fyz lemo·· 4 小時前AI 評分52

react-stream-markdown 如何透過凍結已完成的 Markdown 區塊減少串流渲染工作

Why freezing completed Markdown blocks reduces streaming rendering work

AI 導讀

作者以 react-stream-markdown 説明,凍結已完成的 Markdown 區塊可讓後續更新略過已定稿內容的解析與更新,但不會令整體更新成本固定。

正文

An AI response arrives as text deltas. A Markdown renderer normally receives a document. Connecting them with render(allTextSoFar) repeatedly asks the renderer to interpret text the reader has already seen.

I built react-stream-markdown to explore a different boundary: keep completed blocks on screen and re-parse the unfinished suffix. The repository is currently version 0.4.1 and MIT licensed. Here is how it works, where its cost still grows, and what the published benchmark actually measures.

Where repeated work comes from

Suppose a response contains N characters and arrives in chunks of c characters. A full-document parser sees c characters, then 2c, then 3c, and so on. With a linear parser, the sum of characters examined is approximately N²/(2c). This models repeated parsing work; it does not mean every part of browser rendering is quadratic.

React can reuse DOM elements after reconciliation. That does not automatically prevent an upstream Markdown parser from scanning the accumulated string. Parsing, building a tree, reconciling, layout, and painting have separate costs.

Markdown also has ambiguous intermediate states: bold delimiters and code fences may still be open. A streaming renderer needs an intermediate-display policy as well as a way to avoid revisiting stable content.

The useful boundary is a completed block

The renderer maintains a source offset, live DOM nodes, and the previous virtual representation of the live suffix. Each update parses state.text.slice(offset), converts its blocks to virtual nodes, and patches the live nodes.

After patching, it advances the offset past blocks it considers finished. The implementation freezes preceding blocks when a following block has a complete first line. Frozen DOM nodes remain in the container but leave the arrays used for subsequent patch operations.

A completed paragraph therefore stops participating in Markdown parsing and tree comparison when later paragraphs arrive. This is a finalization policy for the syntax this parser supports. It is not a universal rule that a blank line makes arbitrary Markdown immutable; document-wide dependencies would need additional invalidation rules.

Conceptually, the update is:

  1. Parse the accumulated text after the frozen offset.
  2. Compare the new suffix tree with its previous tree.
  3. Patch the live DOM nodes.
  4. Advance the offset past finalizable blocks and forget their live-state entries.

For block lengths B₁, B₂, …, repeated parsing work is closer to a sum of per-block work, roughly ΣBᵢ²/c, than repeatedly scanning the whole prefix. When blocks remain reasonably short, the parser's active working set stays small as the complete answer grows.

Flat cost has boundaries

A huge paragraph, list, or open code block can keep growing without becoming finalizable. Its active suffix still gets re-parsed. Freezing does not make that case constant-time.

The component also receives cumulative text snapshots, checks whether the new text starts with the previous text, and retains the accumulated response. String construction, prefix checks, allocation, garbage collection, and browser layout sit outside the simple per-block parser model. A larger DOM may still cost more to lay out.

The defensible claim is narrower: freezing removes repeated parser and patch work on finalized blocks. It does not prove O(1) total update cost, bounded memory, or constant latency for every document.

Patching and animation

The active suffix is compared with its previous virtual tree. DOM APIs create or change the nodes that need updating. Raw model HTML is treated as text rather than assigned through innerHTML. Links are limited to HTTP(S) and mailto, and images appear as links.

Unfinished inline syntax is rendered optimistically: a partial bold span can already appear bold. That is an intermediate-display policy, not a guarantee that malformed Markdown will match every other parser's final interpretation.

Optional fade-in applies temporary animation spans to newly arrived characters. When the animation completes, the spans collapse into text nodes. Previously visible text does not restart its animation. Creating and animating those spans still consumes CPU time.

What the benchmark measures

The published README results use production builds in headless Chromium 141, three runs, and a median. The synthetic document contains 10,201 characters with lists, code, tables, and quotes. It arrives in 24-character chunks, one chunk per animation frame: 426 updates, not a recorded provider token stream.

Each renderer uses a 720-pixel-wide host. The harness synchronously applies an update and reads offsetHeight, deliberately forcing layout into that update's timing. React-based adapters use flushSync; react-stream-markdown disables its own frame batching for this test. These choices make synchronous work measurable but do not reproduce every application's scheduling behavior.

Whole-reply work is measured using the difference in Chrome's TaskDuration counter. It is not elapsed generation time, network latency, or time to first readable text. Settling is detected when text length stays unchanged for 30 frames. This is a heuristic, not proof that every animation or formatting effect has finished. The script also subtracts a fixed 3 ms from the total; that adjustment should be disclosed when reproducing the numbers.

Renderer/configuration Published main-thread time Published slowest update
react-stream-markdown, fade off 1,100 ms 3.3 ms
react-stream-markdown, fade on 2,941 ms 7.3 ms
Vercel Streamdown 2.7.0 2,031 ms 15.3 ms
@lobehub/streamdown 1.4.0, realtime 2,609 ms 14.4 ms
react-markdown 10.1.0 + remark-gfm 4.0.1, full string each update 6,384 ms 38.7 ms

These are existing README results, not a fresh measurement of 0.4.1 on my current machine. The script takes medians of the metrics independently, so the displayed maximum update and total need not belong to the same run. Its slowest update is a synchronous measurement, not INP or a complete responsiveness score.

Within this workload, disabling fade-in reduces total main-thread work. Enabling it raises that total above both compared Streamdown configurations. A lower maximum synchronous update does not cancel a higher overall CPU cost.

Making the comparison more informative

The scripts are available in the repository. Reproduction should record the commit, lockfile, browser version, machine, viewport, and animation settings. Test ordinary short blocks and a deliberately long single block; vary chunk size and cadence too.

The harness runs renderers in a fixed order and does not explicitly warm them up. Randomized order, more repetitions, and result distributions would help distinguish repeatable differences from noise. Correctness needs separate checks: similar rendered text lengths do not establish equivalent formatting, links, tables, or accessibility.

Choosing a renderer

Vercel Streamdown offers a broader rendering ecosystem, including syntax highlighting and Mermaid integrations. react-stream-markdown takes a narrower approach with no direct runtime dependencies beyond React peer dependencies. Optional math needs a renderer such as KaTeX supplied by the application, which changes the application's bundle cost.

The repository currently reports 7.8 kB gzipped for its measured JavaScript entry, with stylesheet size listed separately. That is a bundle experiment with React externalized, not the npm tarball size. Imported plugins, styles, features, and lazy chunks matter as much as the headline number.

For a chat interface, I would choose based on required Markdown features, intermediate rendering behavior, correctness on actual responses, and profiling on target devices. This library explores how much repeated work can be removed by giving completed blocks an explicit lifetime. Its strongest result is that inspectable mechanism, rather than an unconditional claim to be the fastest renderer.

Source · Interactive demo · Benchmark scripts

來源:DEV Community · dev.to