从零实现一个 Markdown 解析器:块级状态机、行内分隔符栈与增量解析

举报
Snowplow5180 发表于 2026/09/25 11:56:14 2026/09/25
【摘要】 在上一篇文章中,我们从零实现了一个栈式虚拟机,完成了从AST到字节码再到执行引擎的完整链路。那篇文章聚焦的是编译器的后端和运行时。这一次我们把视角转回编译器前端,但换一个更有工程复杂度的目标:Markdown解析器。Markdown看起来语法简单——加个#就是标题,加个**就是加粗。但CommonMark规范有652个示例,每个示例都在测试边缘情况。*什么时候是列表标记,什么时候是强调符号?...

在上一篇文章中,我们从零实现了一个栈式虚拟机,完成了从AST到字节码再到执行引擎的完整链路。那篇文章聚焦的是编译器的后端和运行时。这一次我们把视角转回编译器前端,但换一个更有工程复杂度的目标:Markdown解析器。

Markdown看起来语法简单——加个#就是标题,加个**就是加粗。但CommonMark规范有652个示例,每个示例都在测试边缘情况。*什么时候是列表标记,什么时候是强调符号?[是链接开始还是普通文本?嵌套列表的缩进怎么算?这些问题让Markdown解析器的实现远比表面看起来困难。

本文用纯前端JavaScript从零实现一个支持CommonMark核心子集的Markdown解析器,不依赖任何库。完整链路是:源文本 → 块级解析(行迭代+块容器状态机)→ 行内解析(分隔符栈+回溯匹配)→ HTML渲染。最后会讨论流式增量解析的实现思路——这是2026年AI对话场景对Markdown解析器提出的新要求。

一、CommonMark的两阶段解析策略
CommonMark规范在附录A中明确给出了参考实现的解析策略,分为两个阶段。

第一阶段是块级解析(block structure pass) 。输入按行消费,识别段落、引用块、列表、代码块、标题等块级结构,构建一棵块级节点树。这个阶段是行迭代的——每一行都可能开始一个新块、延续一个已打开的块,或者关闭一个未匹配的块。缩进敏感是这一阶段的核心特征:>、-、空格和制表符的组合决定了块的嵌套层次。

第二阶段是行内解析(inline pass) 。对块级解析产生的叶子节点(段落、标题等)中的文本内容,识别强调、加粗、链接、代码跨度、自动链接等行内元素。这一阶段使用分隔符栈来处理嵌套和优先级——**bold *italic* bold**中的分隔符需要按后进先出的顺序匹配。

markdown-it的架构进一步细化了这个模型,用三层嵌套的规则链组织解析流程:core链处理文档级的规范化,block链处理块级规则,inline链处理行内规则。每个链有独立的state对象,规则以函数形式注册在Ruler中,可以动态启用或禁用。这套设计让解析器具有良好的可扩展性——添加一个自定义语法只需要在对应的链中注册一条规则。

二、块级解析:行迭代与块容器状态机
块级解析的核心是一个逐行迭代的循环。每一行进入时,解析器需要判断:它是延续当前打开的块,还是开始一个新块,还是关闭所有未匹配的块后开始新块?

我们用一棵“块容器树”来跟踪状态。根节点是Document,每个块容器可以有子节点。当前打开的块构成一条从根到叶子的“右脊”(right spine)。处理新行时,从右脊的叶子开始向上回溯,找到能匹配当前行前缀的最深容器。

javascript
class BlockNode {
  constructor(type, startLine) {
    this.type = type;       // 'document', 'paragraph', 'heading', 'blockquote',
                            // 'list', 'listItem', 'codeBlock', 'fence', 'htmlBlock'
    this.children = [];
    this.startLine = startLine;
    this.endLine = null;
    this.open = true;
    // 类型特定字段
    this.level = 0;         // heading level
    this.listType = null;   // 'bullet' | 'ordered'
    this.listStart = 1;     // ordered list start number
    this.listTight = true;
    this.info = '';         // fence info string
    this.literal = '';      // code block content
    this.lines = [];        // raw lines for paragraphs
  }
}
右脊的维护是块级解析的关键。当新行匹配某个容器的延续条件时,该容器保持打开;不匹配时,从叶子到根依次关闭容器,直到找到一个可以延续的祖先,或者关闭到根后开始新块。

javascript
function parseBlocks(source) {
  const lines = source.split('\n');
  const doc = new BlockNode('document', 0);
  let spine = [doc];  // 右脊:从根到当前最深叶子

  let i = 0;
  while (i < lines.length) {
    const line = lines[i];

    // 1. 空行处理
    if (isBlank(line)) {
      closeUnmatchedBlocks(spine, i);
      // 空行可能影响列表的 tight/loose 判断
      markBlankLine(spine, i);
      i++;
      continue;
    }

    // 2. 从右脊叶子向上回溯,找到匹配的容器
    let matched = false;
    for (let depth = spine.length - 1; depth >= 0; depth--) {
      const container = spine[depth];
      const result = tryContinue(container, line);

      if (result.matched) {
        // 当前容器可以延续
        spine = spine.slice(0, depth + 1);
        matched = true;

        // 尝试在当前容器内开始新的子块
        const child = tryStartNewBlock(line, container, i);
        if (child) {
          // 关闭未匹配的旧子块
          container.children.push(child);
          if (child.type !== 'fence' && child.type !== 'htmlBlock') {
            spine.push(child);
          }
        }
        break;
      }
    }

    // 3. 如果没有任何容器匹配,关闭到根,作为新顶层块
    if (!matched) {
      closeUnmatchedBlocks(spine, i);
      spine = [doc];
      const child = tryStartNewBlock(line, doc, i);
      if (child) {
        doc.children.push(child);
        if (child.type !== 'fence' && child.type !== 'htmlBlock') {
          spine.push(child);
        }
      }
    }

    // 4. 将行内容追加到叶子容器
    appendLine(spine[spine.length - 1], line, i);
    i++;
  }

  closeUnmatchedBlocks(spine, lines.length);
  return doc;
}
这段代码的骨架决定了块级解析的行为。spine数组模拟了块容器树的右脊。当一行进入时,从最深的叶子向上查找能延续的容器。匹配成功后,尝试在当前容器内开始新子块(比如列表项中的段落、引用块中的标题)。如果没有找到匹配,说明当前行不属于任何已打开的块,关闭所有块并从根重新开始。

tryContinue和tryStartNewBlock是两个需要按容器类型分派的函数。以引用块和列表项为例:

javascript
function tryContinue(container, line) {
  switch (container.type) {
    case 'document':
      return { matched: true };

    case 'blockquote': {
      // 引用块延续条件:行以 '>' 开头(可带最多3个前导空格)
      const m = line.match(/^ {0,3}> ?(.*)$/);
      if (m) {
        return { matched: true, content: m[1] };
      }
      return { matched: false };
    }

    case 'listItem': {
      // 列表项延续条件:缩进达到列表项的基准缩进
      const indent = getIndent(line);
      if (indent >= container.listIndent) {
        return {
          matched: true,
          content: line.slice(container.listIndent)
        };
      }
      return { matched: false };
    }

    case 'paragraph':
      // 段落延续条件:非空行且不以其他块标记开头
      return { matched: true };

    case 'fence':
      // 围栏代码块延续条件:直到遇到闭合围栏
      return { matched: true };

    default:
      return { matched: true };
  }
}
tryStartNewBlock负责识别新块的开始标记。优先级顺序很重要——引用块和列表标记在段落之前检查,ATX标题在任何行都可以开始,但在一行已经属于某个容器时,新块必须是该容器的子节点。

javascript
function tryStartNewBlock(line, parent, lineNum) {
  // 引用块
  const bq = line.match(/^ {0,3}> ?(.*)$/);
  if (bq) {
    const node = new BlockNode('blockquote', lineNum);
    parent.children.push(node);
    return node;
  }

  // ATX 标题
  const atx = line.match(/^ {0,3}(#{1,6})(?:\s+|$)(.*)$/);
  if (atx) {
    const node = new BlockNode('heading', lineNum);
    node.level = atx[1].length;
    node.inlineText = atx[2].replace(/\s+#+\s*$/, ''); // 移除闭合 #
    node.open = false;
    parent.children.push(node);
    return node;
  }

  // 围栏代码块
  const fence = line.match(/^ {0,3}(`{3,}|~{3,})\s*(.*)$/);
  if (fence) {
    const node = new BlockNode('fence', lineNum);
    node.fenceChar = fence[1][0];
    node.fenceLength = fence[1].length;
    node.info = fence[2].trim();
    parent.children.push(node);
    return node;
  }

  // 无序列表
  const ul = line.match(/^ {0,3}([-+*])(?:\s+(.*))?$/);
  if (ul) {
    return startListItem(parent, lineNum, 'bullet', 0);
  }

  // 有序列表
  const ol = line.match(/^ {0,3}(\d{1,9})([.)])(?:\s+(.*))?$/);
  if (ol) {
    return startListItem(parent, lineNum, 'ordered', parseInt(ol[1]));
  }

  // 分隔线
  if (/^ {0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
    const node = new BlockNode('thematicBreak', lineNum);
    node.open = false;
    parent.children.push(node);
    return node;
  }

  // 段落(默认)
  const node = new BlockNode('paragraph', lineNum);
  node.lines = [];
  parent.children.push(node);
  return node;
}
列表的启动逻辑比表面复杂。一个列表标记- foo需要创建一个list容器和一个listItem容器,listItem的基准缩进取决于标记后面的空格宽度。CommonMark规范中,列表项的缩进规则是:如果标记后跟1到4个空格,缩进宽度为标记宽度加空格宽度;如果标记后跟空行或5个以上空格,缩进宽度为标记宽度加1。

javascript
function startListItem(parent, lineNum, listType, startNum) {
  // 找到或创建列表容器
  let list = parent.children[parent.children.length - 1];
  if (!list || list.type !== 'list' || list.listType !== listType) {
    list = new BlockNode('list', lineNum);
    list.listType = listType;
    list.listStart = startNum;
    parent.children.push(list);
  }

  const item = new BlockNode('listItem', lineNum);
  item.listIndent = 2; // 简化:基准缩进
  list.children.push(item);
  return item;
}
三、行内解析:分隔符栈与回溯匹配
块级解析完成后,叶子节点(段落、标题、列表项等)中的原始文本需要经过行内解析,识别强调、加粗、链接、代码跨度等元素。

行内解析的核心数据结构是分隔符栈。当解析器遇到一个可能开启或关闭强调的符号序列(*或_)时,它不立即创建强调节点,而是把分隔符作为“潜在分隔符”推入栈中,同时记录其位置、长度和前后文信息。当后续遇到匹配的闭合分隔符时,再从栈中弹出对应的开启分隔符,生成强调节点。

这个策略的关键在于左右侧翼判断。一个*序列是“左侧翼”如果它前面是空白或行首,后面是非空白;是“右侧翼”如果它前面是非空白,后面是空白或行尾。只有左侧翼分隔符可以作为开启,右侧翼分隔符可以作为关闭。

javascript
function parseInlines(text) {
  const nodes = [];       // 输出节点序列
  const delimiters = [];  // 分隔符栈
  let pos = 0;

  while (pos < text.length) {
    const ch = text[pos];

    // 代码跨度优先
    if (ch === '`') {
      const code = tryCodeSpan(text, pos);
      if (code) {
        flushText(nodes, text, pos, code.end);
        nodes.push({ type: 'code', literal: code.content });
        pos = code.end;
        continue;
      }
    }

    // 强调分隔符
    if (ch === '*' || ch === '_') {
      const run = countRun(text, pos, ch);
      const before = pos > 0 ? text[pos - 1] : '\n';
      const after = pos + run < text.length ? text[pos + run] : '\n';

      const leftFlanking = !isSpace(after) &&
        (isPunct(after) || isSpace(before) || isPunct(before) ? false : true);
      const rightFlanking = !isSpace(before) &&
        (isPunct(before) || isSpace(after) || isPunct(after) ? false : true);

      const canOpen = leftFlanking;
      const canClose = rightFlanking ||
        (ch === '_' && rightFlanking && !leftFlanking);

      delimiters.push({
        char: ch,
        length: run,
        pos: pos,
        canOpen,
        canClose,
        textIndex: nodes.length  // 在nodes中的位置
      });

      // 立即尝试匹配关闭
      if (canClose) {
        matchDelimiters(nodes, delimiters, text);
      }

      pos += run;
      continue;
    }

    // 链接 [text](url)
    if (ch === '[') {
      const link = tryLink(text, pos);
      if (link) {
        flushText(nodes, text, pos, link.end);
        nodes.push(link.node);
        pos = link.end;
        continue;
      }
    }

    pos++;
  }

  // 收尾:将所有剩余分隔符转为文本
  flushRemaining(nodes, text, delimiters);
  return nodes;
}
matchDelimiters是分隔符栈的核心逻辑。它从栈顶向下扫描,找到最近的一个canOpen的分隔符,其字符类型与当前关闭符匹配,且两者之间的文本没有跨越块级边界。

javascript
function matchDelimiters(nodes, delimiters, text) {
  const closer = delimiters[delimiters.length - 1];
  if (!closer.canClose) return;

  for (let i = delimiters.length - 2; i >= 0; i--) {
    const opener = delimiters[i];
    if (opener.char !== closer.char) continue;
    if (!opener.canOpen) continue;

    // 确定使用的分隔符数量
    const use = Math.min(opener.length, closer.length);

    // 确定强调类型
    // 如果 use >= 2 且 opener.length >= 2 且 closer.length >= 2,使用 strong
    // 否则使用 em
    let type;
    if (use >= 2 && opener.length >= 2 && closer.length >= 2) {
      type = 'strong';
    } else {
      type = 'em';
    }

    // 提取被包裹的文本
    const contentStart = opener.pos + opener.length;
    const contentEnd = closer.pos;
    const innerText = text.slice(contentStart, contentEnd);

    // 递归解析内部行内内容
    const innerNodes = parseInlines(innerText);

    // 替换 nodes 中 opener 和 closer 之间的内容
    const insertAt = opener.textIndex;
    nodes.splice(insertAt, closer.textIndex - insertAt, {
      type,
      children: innerNodes
    });

    // 更新分隔符长度
    opener.length -= use;
    closer.length -= use;

    // 清理已消耗的分隔符
    if (opener.length === 0) delimiters.splice(i, 1);
    if (closer.length === 0) delimiters.pop();

    return;
  }
}
这段代码需要处理一个微妙的问题:分隔符的部分消耗。***bold italic***中,三个*既开启了strong也开启了em。正确的处理方式是:先匹配strong(消耗两个*),剩余的一个*继续参与em的匹配。上面的实现通过opener.length -= use和closer.length -= use来处理部分消耗,然后在下次遇到关闭符时继续匹配。

四、链接、图片与自动链接
链接解析是行内解析中最复杂的部分,因为需要处理多种形式:行内链接[text](url "title")、引用链接[text][ref]、折叠引用[text][]、快捷引用[text],以及自动链接<https://example.com>。

javascript
function tryLink(text, start) {
  // 查找匹配的 ]
  let depth = 1;
  let i = start + 1;
  while (i < text.length && depth > 0) {
    if (text[i] === '\\') { i += 2; continue; }
    if (text[i] === '[') depth++;
    if (text[i] === ']') depth--;
    i++;
  }
  if (depth !== 0) return null;

  const textEnd = i - 1;  // ] 的位置
  const linkText = text.slice(start + 1, textEnd);

  // 检查后面是否紧跟 (
  if (text[i] === '(') {
    // 行内链接:解析 (url "title")
    let j = i + 1;
    let url = '';
    let title = '';

    // 跳过空白
    while (j < text.length && isSpace(text[j])) j++;

    // 读取 URL(可能被 <> 包裹)
    if (text[j] === '<') {
      j++;
      while (j < text.length && text[j] !== '>') url += text[j++];
      j++; // skip >
    } else {
      let parenDepth = 0;
      while (j < text.length) {
        const c = text[j];
        if (c === '\\') { url += text[j + 1]; j += 2; continue; }
        if (c === '(') parenDepth++;
        if (c === ')') { if (parenDepth === 0) break; parenDepth--; }
        if (isSpace(c) && parenDepth === 0) break;
        url += c;
        j++;
      }
    }

    // 跳过空白
    while (j < text.length && isSpace(text[j])) j++;

    // 读取 title(如果存在)
    const quote = text[j];
    if (quote === '"' || quote === "'" || quote === '(') {
      const closeQuote = quote === '(' ? ')' : quote;
      j++;
      while (j < text.length && text[j] !== closeQuote) {
        title += text[j++];
      }
      j++; // skip close quote
      while (j < text.length && isSpace(text[j])) j++;
    }

    // 期望 )
    if (text[j] !== ')') return null;
    j++;

    return {
      end: j,
      node: {
        type: 'link',
        url: url.trim(),
        title: title.trim(),
        children: parseInlines(linkText)
      }
    };
  }

  // 引用链接:检查 [ref] 或 []
  if (text[i] === '[') {
    const refEnd = text.indexOf(']', i + 1);
    if (refEnd === -1) return null;
    const ref = text.slice(i + 1, refEnd);
    // 折叠引用:[] 使用 linkText 作为引用
    const actualRef = ref === '' ? linkText : ref;
    // 这里需要访问引用定义表,简化处理返回 null
    return null;
  }

  return null;
}
自动链接的处理相对简单,用正则匹配尖括号内的URL或邮箱即可:

javascript
function tryAutolink(text, start) {
  const m = text.slice(start).match(/^<([^>]+)>/);
  if (!m) return null;

  const content = m[1];
  // URL 自动链接
  if (/^[a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*$/.test(content)) {
    return {
      end: start + m[0].length,
      node: { type: 'link', url: content, title: '', children: [{ type: 'text', literal: content }] }
    };
  }
  // 邮箱自动链接
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(content)) {
    return {
      end: start + m[0].length,
      node: { type: 'link', url: 'mailto:' + content, title: '', children: [{ type: 'text', literal: content }] }
    };
  }
  return null;
}
五、HTML渲染与转义
AST构建完成后,渲染阶段将节点树转换为HTML字符串。需要特别注意的是HTML转义——段落和标题中的<、>、&必须被转义,否则会破坏输出结构。

javascript
function renderHTML(node) {
  switch (node.type) {
    case 'document':
      return node.children.map(renderHTML).join('\n');

    case 'paragraph':
      return '<p>' + renderInlines(node.inlineNodes) + '</p>';

    case 'heading':
      return `<h${node.level}>` + renderInlines(node.inlineNodes) + `</h${node.level}>`;

    case 'blockquote':
      return '<blockquote>\n' + node.children.map(renderHTML).join('\n') + '\n</blockquote>';

    case 'list': {
      const tag = node.listType === 'ordered' ? 'ol' : 'ul';
      const startAttr = node.listType === 'ordered' && node.listStart !== 1
        ? ` start="${node.listStart}"` : '';
      return `<${tag}${startAttr}>\n` +
        node.children.map(renderHTML).join('\n') + `\n</${tag}>`;
    }

    case 'listItem':
      return '<li>' + node.children.map(renderHTML).join('\n') + '</li>';

    case 'fence': {
      const langAttr = node.info ? ` class="language-${escapeAttr(node.info.split(/\s+/)[0])}"` : '';
      return `<pre><code${langAttr}>` + escapeHtml(node.literal) + '</code></pre>';
    }

    case 'thematicBreak':
      return '<hr />';

    default:
      return '';
  }
}

function renderInlines(nodes) {
  if (!nodes) return '';
  return nodes.map(n => {
    switch (n.type) {
      case 'text': return escapeHtml(n.literal);
      case 'code': return '<code>' + escapeHtml(n.literal) + '</code>';
      case 'em': return '<em>' + renderInlines(n.children) + '</em>';
      case 'strong': return '<strong>' + renderInlines(n.children) + '</strong>';
      case 'link':
        const titleAttr = n.title ? ` title="${escapeAttr(n.title)}"` : '';
        return `<a href="${escapeAttr(n.url)}"${titleAttr}>` + renderInlines(n.children) + '</a>';
      case 'softbreak': return '\n';
      case 'hardbreak': return '<br />\n';
      default: return '';
    }
  }).join('');
}

function escapeHtml(s) {
  return s.replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;');
}

function escapeAttr(s) {
  return escapeHtml(s).replace(/'/g, '&#39;');
}
HTML块的渲染需要原样输出,不能转义。在块级解析中,HTML块作为叶子节点,其literal字段保存原始内容,渲染时直接拼接。

六、流式增量解析:2026年的新需求
2026年,Markdown解析器面临一个传统实现没有处理过的场景:流式渲染。在AI对话产品中,LLM以20到50 tokens/秒的速度输出Markdown内容,传统解析器的做法是每次新token到达就重新解析整个文档,导致闪烁重绘、内存暴涨和代码块卡顿。

流式增量解析的核心思路是只重新解析变化的部分。当新内容追加到文档末尾时,大多数已解析的块级节点是不变的——已经闭合的段落、已经结束的代码块、已经完成的列表。只有最后一个未闭合的块可能需要重新解析。

实现增量解析的关键是维护一个块级缓存,记录每个块的起始行、结束行和节点引用。当新行到达时,从缓存中查找最后一个可能受影响的块,只重新解析从该块开始的部分。

javascript
class IncrementalParser {
  constructor() {
    this.doc = new BlockNode('document', 0);
    this.blockCache = [];   // [{ startLine, endLine, node }]
    this.spine = [this.doc];
    this.lineCount = 0;
    this.sourceLines = [];
  }

  append(newText) {
    const newLines = newText.split('\n');
    // 如果新文本不以换行结尾,最后一行是延续
    const continued = !newText.endsWith('\n');

    // 找到最后一个未闭合块的起始位置
    let reparseFrom = this.lineCount;
    if (continued && this.spine.length > 0) {
      const lastOpen = this.spine[this.spine.length - 1];
      reparseFrom = lastOpen.startLine;
    }

    // 合并源行
    this.sourceLines = this.sourceLines.slice(0, reparseFrom)
      .concat(this.sourceLines.slice(reparseFrom, this.lineCount))
      .concat(newLines);

    // 只重新解析从 reparseFrom 开始的部分
    const partialLines = this.sourceLines.slice(reparseFrom);
    this.doc = this.reparseFrom(reparseFrom, partialLines);
    this.lineCount = this.sourceLines.length;

    return this.doc;
  }

  reparseFrom(startLine, lines) {
    // 截断块缓存
    this.blockCache = this.blockCache.filter(b => b.endLine !== null && b.endLine < startLine);

    // 从缓存中重建右脊
    this.spine = [this.doc];
    for (const entry of this.blockCache) {
      if (entry.node.open) {
        this.spine.push(entry.node);
      }
    }

    // 继续块级解析循环
    // ... 复用 parseBlocks 的剩余逻辑
    return this.doc;
  }
}
增量解析的性能收益是显著的。根据@mizchi/markdown的实现数据,增量解析只重新解析变化的块,速度提升可达42倍。markstream-vue的流式解析器维护了source→tokens→AST的三级缓存,进一步降低了重复计算的开销。

流式场景还有一个特殊挑战:半成品块的处理。当LLM正在输出一个代码块时,围栏可能还没有闭合;正在输出一个链接时,]可能还没出现。解析器需要能够处理“语法未完成”的状态——代码块的围栏未闭合时,内容照常渲染,但在AST中标记为“未闭合”,直到闭合围栏出现或流结束。

七、完整流程与测试
把块级解析和行内解析串联起来:

javascript
function parseMarkdown(source) {
  const doc = parseBlocks(source);
  // 后处理:对每个叶子块的行内内容执行行内解析
  walkBlockTree(doc, node => {
    if (node.type === 'paragraph' && node.lines) {
      node.inlineNodes = parseInlines(node.lines.join('\n'));
    }
    if (node.type === 'heading' && node.inlineText) {
      node.inlineNodes = parseInlines(node.inlineText);
    }
    if (node.type === 'listItem' && node.children.length === 1 &&
        node.children[0].type === 'paragraph') {
      // 列表项的 tight 处理:如果是 tight 列表,省略 <p>
      node.inlineNodes = node.children[0].inlineNodes;
    }
  });
  return doc;
}

function walkBlockTree(node, fn) {
  fn(node);
  if (node.children) {
    for (const child of node.children) {
      walkBlockTree(child, fn);
    }
  }
}
测试几个典型场景:

javascript
// 测试1:标题 + 强调 + 行内代码
const md1 = `# Hello **world**

This is \`inline code\` and *emphasized* text.`;

// 测试2:嵌套列表
const md2 = `- Item 1
  - Nested A
  - Nested B
- Item 2`;

// 测试3:引用块 + 围栏代码块
const md3 = `> A quote with
> \`\`\`js
> const x = 1;
> \`\`\`
> end of quote`;

console.log(renderHTML(parseMarkdown(md1)));
console.log(renderHTML(parseMarkdown(md2)));
console.log(renderHTML(parseMarkdown(md3)));
测试1验证行内解析的优先级:代码跨度优先于强调,强调内部的文本不再被解析。测试2验证列表嵌套的缩进判断——两个空格缩进被识别为子列表。测试3验证围栏代码块在引用块内的处理——围栏标记在>之后仍然有效。

八、总结
从源文本到HTML,Markdown解析器经历了块级状态机的行迭代、行内分隔符栈的回溯匹配、以及渲染阶段的转义处理。块级解析的核心是右脊维护和容器延续判断,行内解析的核心是分隔符的左右侧翼判断和部分消耗处理。

这套实现覆盖了CommonMark的核心子集,但还有大量边缘情况需要处理——链接引用定义、HTML块类型识别、列表的tight/loose判断、制表符展开、Unicode空白字符等。CommonMark规范的652个示例是检验实现完整性的黄金标准,参考实现cmark通过了全部示例,而大多数第三方实现只能通过90%左右。

对于2026年的工程实践而言,增量解析能力正在成为Markdown解析器的标准配置。AI对话场景中,解析器不再是“输入完整文档、输出完整HTML”的一次性工具,而是持续接收增量内容、维护解析状态、在16毫秒帧预算内完成重渲染的实时系统。理解了块级状态机和行内分隔符栈这两个核心机制,再去看markstream-vue或@mizchi/markdown的增量实现,会发现它们在架构上遵循的是同一套思路,差异只在缓存策略和调度优化。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。