Background#

In a recent AI project I took over, I needed to implement a ChatGPT-like conversational interface. The core workflow is: the backend continuously pushes AI-generated Markdown text fragments to the frontend via SSE (Server-Sent Events) protocol. The frontend is responsible for dynamically receiving and concatenating these Markdown fragments, ultimately rendering and displaying the complete Markdown text in real-time on the user interface.

Markdown rendering isn't an uncommon requirement, especially in today's world flooded with LLM-based products. Unlike the React ecosystem, which has a popular third-party library with 14k+ stars—react-markdown—Vue doesn't seem to have an actively maintained Markdown rendering library with significant popularity (at least 2k+ stars). cloudacy/vue-markdown-render last released a year ago but has only 103 stars as of this writing; miaolz123/vue-markdown has 2k stars but its last commit was 7 years ago; zhaoxuhui1122/vue-markdown is even archived.

First Version: Simple and Brute Force v-html#

After a quick survey, I confirmed that the Vue ecosystem indeed lacks a robust Markdown rendering library. Since there's no ready-made solution, let's build our own!

Based on most articles and LLM recommendations, we first adopted the markdown-it third-party library to convert Markdown to HTML strings, then passed it through v-html.

PS: We assume here that the Markdown content is trusted (e.g., generated by our own AI). If the content comes from user input, you must use libraries like DOMPurify to prevent XSS attacks and avoid "opening a window" in your website!

Example code:

<template>
  <div v-html="renderedHtml"></div>
</template>

<script setup>
import { computed, onMounted, ref } from 'vue';
import MarkdownIt from 'markdown-it';

const markdownContent = ref('');
const md = new MarkdownIt();

const renderedHtml = computed(() => md.render(markdownContent.value))

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
</script>

Evolution: Chunked Updates for Markdown#

While the above approach achieves basic rendering, it has obvious flaws in real-time update scenarios: every time a new Markdown fragment is received, the entire document triggers a full re-render. Even if only the last line is new content, the entire document's DOM gets completely replaced. This leads to two core problems:

  1. Performance bottleneck: As Markdown content grows, the overhead of markdown-it parsing and DOM reconstruction increases linearly.
  2. Lost interaction state: Full refresh wipes out the user's current operation state. Most notably, if you've selected some text, one refresh and the selection is gone!

To solve these two problems, we found a chunked rendering solution online—splitting Markdown by two consecutive newlines (\n\n) into chunks. This way, each update only re-renders the last new chunk, while previous chunks reuse the cache. The benefits are obvious:

  • If the user has selected text in a previous chunk, the selection state won't be lost on the next update (because that chunk hasn't changed).
  • Fewer DOM nodes need re-rendering, naturally improving performance.

The adjusted code looks like this:

<template>
  <div>
    <div
      v-for="(block, idx) in renderedBlocks"
      :key="idx"
      v-html="block"
      class="markdown-block"
    ></div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import MarkdownIt from 'markdown-it'

const markdownContent = ref('')
const md = new MarkdownIt()

const renderedBlocks = ref([])
const blockCache = ref([])

watch(
  markdownContent,
  (newContent, oldContent) => {
    const blocks = newContent.split(/\n{2,}/)
    // Only re-render the last block, reuse cache for others
    // Handle cases where blocks decrease or increase
    blockCache.value.length = blocks.length
    for (let i = 0; i < blocks.length; i++) {
      // Only render the last one, or new blocks
      if (i === blocks.length - 1 || !blockCache.value[i]) {
        blockCache.value[i] = md.render(blocks[i] || '')
      }
      // Reuse other blocks directly
    }
    renderedBlocks.value = blockCache.value.slice()
  },
  { immediate: true }
)

onMounted(() => {
  // markdownContent.value = await fetch() ...
})
</script>

Ultimate Weapon: Precise Updates with morphdom#

While chunked rendering solves most problems, it struggles with Markdown lists. Because in Markdown syntax, list items are usually separated by only one newline, so the entire list is treated as one large chunk. Imagine a list with hundreds of items—even if only the last item is updated, the entire list chunk must be completely re-rendered, and we're back to square one.

What is morphdom?#

morphdom is a JavaScript library of only 5KB (gzipped), with the core functionality of: receiving two DOM nodes (or HTML strings), calculating the minimal DOM operations needed, and "morphing" the first node into the second, rather than directly replacing it.

Its working principle is similar to Virtual DOM's Diff algorithm, but operates directly on the real DOM:

  1. Compare tag names, attributes, text content, etc., between old and new DOM;
  2. Execute only add/delete/modify operations on the differences (like modifying text, updating attributes, moving node positions);
  3. Unchanged DOM nodes are completely preserved, including their event listeners, scroll positions, selection states, etc.

Markdown treats lists as a whole, but in the generated HTML, each list item (<li>) is independent! When morphdom updates later list items, it ensures earlier list items remain untouched, naturally preserving their state.

Isn't this exactly what we've been dreaming of? Real-time Markdown updates while maximally preserving user operation state, and saving a bunch of unnecessary DOM operations!

Example Code#

<template>
  <div ref="markdownContainer" class="markdown-container">
    <div id="md-root"></div>
  </div>
</template>

<script setup>
import { nextTick, ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
import morphdom from 'morphdom';

const markdownContent = ref('');
const markdownContainer = ref(null);
const md = new MarkdownIt();

const render = () => {
  if (!markdownContainer.value.querySelector('#md-root')) return;

  const newHtml = `<div id="md-root">` + md.render(markdownContent.value) + `</div>`

  morphdom(markdownContainer.value, newHtml, {
    childrenOnly: true
  });
}

watch(markdownContent, () => {
    render()
});

onMounted(async () => {
  // Wait for DOM to be mounted
  await nextTick()
  render()
})
</script>

Seeing is Believing: Demo Comparison#

The iframe below contains a comparison demo showing the performance differences between different approaches.

Tip: If you're using Chromium-based browsers like Chrome or Edge, open Developer Tools (DevTools), find the "Rendering" tab, and check "Paint flashing." This way you can visually see which parts get repainted with each update—fewer repainted areas mean better performance!

Progress So Far#

From the initial "brute force full refresh," to the "smarter chunked updates," and now to the "surgical precision of morphdom updates," we've progressively eliminated unnecessary rendering overhead, ultimately creating a Markdown real-time rendering solution that's both fast and preserves user state.

However, using morphdom, a third-party library to directly manipulate DOM in Vue components, feels somewhat... not quite "Vue"? While it solves the core performance and state issues, playing this way in the Vue world feels a bit like taking the back door.

Next Episode Preview: In the next article, we'll discuss whether there's a more elegant, more "native" solution in the Vue world to achieve precise Markdown updates. Stay tuned!