综合实战:用 TypeScript 构建 Markdown 解析器

目录

第 1 节 概述

前十一篇我们系统学了 TypeScript 的各个知识点。这一篇把它们串起来。

我们要做一个轻量 Markdown 解析器。输入一段 Markdown 文本,输出结构化的语法树(AST)。

为什么选 Markdown 解析器?因为它小到能在一篇笔记讲完,大到能用上几乎所有 TS 特性

知识点在本项目中的用法
interface定义各种语法节点类型
可辨识联合type 字段区分不同节点
泛型通用的节点遍历和转换函数
映射类型从节点类型提取元数据
类型守卫处理不同类型的节点
模块按职责拆分文件

第 2 节 项目结构

1
2
3
4
5
6
7
8
markdown-parser/
├── src/
│   ├── types.ts        # 所有类型定义
│   ├── parser.ts       # Markdown → AST 解析
│   ├── traverser.ts    # AST 遍历与转换
│   └── index.ts        # 入口,整合所有模块
├── tsconfig.json
└── package.json

第 3 节 第一步:定义节点类型(types.ts)

Markdown 文档由不同类型的组成。我们先定义每种块的类型。

3.1 基础类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/types.ts

// 文本 —— 最基础的节点
export interface TextNode {
  type: "text"
  value: string
}

// 标题
export interface HeadingNode {
  type: "heading"
  level: 1 | 2 | 3 | 4 | 5 | 6
  children: TextNode[]
}

// 段落
export interface ParagraphNode {
  type: "paragraph"
  children: TextNode[]
}

// 代码块
export interface CodeBlockNode {
  type: "code"
  language: string
  content: string
}

// 列表(无序)
export interface ListNode {
  type: "list"
  items: ListItemNode[]
}

// 列表项
export interface ListItemNode {
  type: "listItem"
  content: TextNode[]
}

3.2 可辨识联合

这些节点都有各自的 type 字段。我们把它们组合成一个联合类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 所有节点类型的联合
export type BlockNode =
  | HeadingNode
  | ParagraphNode
  | CodeBlockNode
  | ListNode
  | ListItemNode

// 所有内联节点类型(嵌入在块内部的节点)
export type InlineNode = TextNode

// 任意节点 —— 递归场景使用
export type ASTNode = BlockNode | InlineNode
说明

可辨识联合是 TypeScript 中最实用的模式之一。type 字段就是"辨识器"。TypeScript 可以根据 type 的值缩小类型范围,精准推断当前分支的类型。

3.3 文档类型

整个文档就是节点的数组:

1
2
3
4
5
// 完整的文档 AST
export interface Document {
  type: "document"
  children: BlockNode[]
}

第 4 节 第二步:解析 Markdown(parser.ts)

解析器把 Markdown 文本转换成 AST。

4.1 行解析

先按行切分,逐行解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/parser.ts
import type {
  Document,
  BlockNode,
  HeadingNode,
  ParagraphNode,
  CodeBlockNode,
  ListNode,
  ListItemNode,
  TextNode,
} from "./types"

// 解析入口
export function parse(markdown: string): Document {
  const lines = markdown.split("\n")
  const blocks = parseBlocks(lines)

  return {
    type: "document",
    children: blocks,
  }
}

// 按行解析块
function parseBlocks(lines: string[]): BlockNode[] {
  const blocks: BlockNode[] = []
  let i = 0

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

    if (line.startsWith("### ")) {
      blocks.push(parseHeading(line, 3))
    } else if (line.startsWith("## ")) {
      blocks.push(parseHeading(line, 2))
    } else if (line.startsWith("# ")) {
      blocks.push(parseHeading(line, 1))
    } else if (line.startsWith("```")) {
      const result = parseCodeBlock(lines, i)
      blocks.push(result.node)
      i = result.nextLine
      continue
    } else if (line.startsWith("- ")) {
      const result = parseList(lines, i)
      blocks.push(result.node)
      i = result.nextLine
      continue
    } else if (line.trim() !== "") {
      blocks.push(parseParagraph(line))
    }
    // 空行跳过

    i++
  }

  return blocks
}

4.2 具体解析函数

标题解析:

1
2
3
4
5
6
7
8
function parseHeading(line: string, level: number): HeadingNode {
  const text = line.replace(/^#{1,6}\s+/, "")
  return {
    type: "heading",
    level,
    children: parseInline(text),
  }
}

代码块解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function parseCodeBlock(lines: string[], start: number): { node: CodeBlockNode; nextLine: number } {
  const firstLine = lines[start]
  const language = firstLine.replace("```", "").trim()
  const contentLines: string[] = []
  let i = start + 1

  while (i < lines.length && !lines[i].startsWith("```")) {
    contentLines.push(lines[i])
    i++
  }

  return {
    node: {
      type: "code",
      language,
      content: contentLines.join("\n"),
    },
    nextLine: i + 1, // 跳过结束的 ```
  }
}

列表解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function parseList(lines: string[], start: number): { node: ListNode; nextLine: number } {
  const items: ListItemNode[] = []
  let i = start

  while (i < lines.length && lines[i].startsWith("- ")) {
    const content = lines[i].replace(/^- /, "")
    items.push({
      type: "listItem",
      content: parseInline(content),
    })
    i++
  }

  return {
    node: {
      type: "list",
      items,
    },
    nextLine: i,
  }
}

段落与内联解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function parseParagraph(line: string): ParagraphNode {
  return {
    type: "paragraph",
    children: parseInline(line),
  }
}

// 内联解析 —— 现在只处理纯文本
function parseInline(text: string): TextNode[] {
  return [
    {
      type: "text",
      value: text,
    },
  ]
}
技巧

当前的内联解析函数很简单。你可以扩展它,支持 **加粗***斜体*`代码` 等内联语法——这就是不同的 InlineNode 子类型。

第 5 节 第三步:遍历与转换(traverser.ts)

AST 建好了,现在要对它做操作。用泛型写一个通用的遍历函数:

5.1 通用遍历器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/traverser.ts
import type { Document, BlockNode, InlineNode, ASTNode }

// 遍历回调类型 —— 使用者决定做什么
type Visitor<T> = (node: ASTNode) => T

// 通用的遍历函数 —— 泛型让它可以用于任何场景
export function traverse<T>(doc: Document, visitor: Visitor<T>): T[] {
  const results: T[] = []

  function visit(nodes: ASTNode[]) {
    for (const node of nodes) {
      results.push(visitor(node))

      // 根据类型访问子节点
      switch (node.type) {
        case "document":
          visit(node.children)
          break
        case "heading":
        case "paragraph":
          visit(node.children)
          break
        case "list":
          visit(node.items)
          break
        case "listItem":
          visit(node.content)
          break
        // code 和 text 没有子节点,不处理
      }
    }
  }

  visit(doc.children)
  return results
}

5.2 用遍历器实现功能

统计节点数量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 统计每种节点的数量
function countNodes(doc: Document): Record<string, number> {
  const counts: Record<string, number> = {}

  traverse(doc, (node) => {
    const type = node.type
    counts[type] = (counts[type] || 0) + 1
  })

  return counts
}

// 使用
const doc = parse("# 标题\n\n一段文字\n\n- 列表项1\n- 列表项2")
const counts = countNodes(doc)
// { document: 1, heading: 1, paragraph: 1, list: 1, listItem: 2, text: 4 }

提取所有标题:

1
2
3
4
5
6
7
8
function extractHeadings(doc: Document) {
  return traverse(doc, (node) => {
    if (node.type === "heading") {
      return `H${node.level}: ${node.children.map(t => t.value).join("")}`
    }
    return null
  }).filter(Boolean)
}

5.3 节点转换器

traverse 只读访问。如果需要转换(修改)AST,可以写一个 transform 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 通用的节点转换函数 —— 用泛型保持类型安全
type Transformer<T extends ASTNode> = (node: T) => T

// 转换文档中的所有标题级别
function transformHeadings(doc: Document, delta: number): Document {
  function transformNode(node: ASTNode): ASTNode {
    if (node.type === "heading") {
      const newLevel = Math.min(6, Math.max(1, node.level + delta)) as 1 | 2 | 3 | 4 | 5 | 6
      return { ...node, level: newLevel }
    }

    if (node.type === "document") {
      return {
        ...node,
        children: node.children.map(child => transformNode(child)) as BlockNode[],
      }
    }

    if (node.type === "list") {
      return {
        ...node,
        items: node.items.map(item => transformNode(item)) as ListItemNode[],
      }
    }

    return node
  }

  return transformNode(doc) as Document
}

第 6 节 第四步:用映射类型提取信息

定义一些工具类型,从现有的节点类型中提取元信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/types.ts — 追加

// 提取所有节点类型名称的联合
type NodeTypeName = ASTNode["type"]
// "document" | "heading" | "paragraph" | "code" | "list" | "listItem" | "text"

// 映射:节点类型名 → 对应的节点类型
type NodeTypeMap = {
  [K in ASTNode["type"]]: Extract<ASTNode, { type: K }>
}
// {
//   document: Document
//   heading: HeadingNode
//   paragraph: ParagraphNode
//   code: CodeBlockNode
//   list: ListNode
//   listItem: ListItemNode
//   text: TextNode
// }

// 提取所有有 "children" 属性的节点
type ParentNode = Extract<ASTNode, { children: ASTNode[] }>
// HeadingNode | ParagraphNode | Document

// 提取所有块级节点名称(不含 inline)
type BlockType = BlockNode["type"]
// "heading" | "paragraph" | "code" | "list" | "listItem"

第 7 节 第五步:串联运行(index.ts)

把所有模块整合到入口文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// src/index.ts
import { parse } from "./parser"
import { traverse } from "./traverser"
import type { Document, ASTNode } from "./types"

// 完整的解析 + 处理流程
function processMarkdown(input: string) {
  // 1. 解析为 AST
  const doc = parse(input)

  // 2. 遍历统计
  const counts: Record<string, number> = {}
  traverse(doc, (node) => {
    const type = node.type
    counts[type] = (counts[type] || 0) + 1
  })

  // 3. 提取目录
  const toc = traverse(doc, (node) => {
    if (node.type === "heading") {
      return {
        level: node.level,
        text: node.children.map(t => t.value).join(""),
      }
    }
    return null
  }).filter(Boolean)

  return { doc, stats: counts, toc }
}

// 测试
const markdown = `
# TypeScript 笔记

TypeScript 是 JavaScript 的超集。

## 安装

\`\`\`shell
npm install typescript -g
\`\`\`

## 基础类型

- string
- number
- boolean

## 结语

开始使用 TypeScript 吧!
`

const result = processMarkdown(markdown)
console.log("节点统计:", result.stats)
console.log("\n目录:")
result.toc.forEach((item, i) => {
  const indent = "  ".repeat(item.level - 1)
  console.log(`${indent}H${item.level}. ${item.text}`)
})

运行输出:

1
2
3
4
5
6
7
节点统计: { document: 1, heading: 4, paragraph: 2, code: 1, list: 1, listItem: 3, text: 10 }

目录:
H1. TypeScript 笔记
  H2. 安装
  H2. 基础类型
  H2. 结语

第 8 节 扩展方向

这个解析器是一个最小可行产品。你可以沿着几个方向扩展:

内联语法支持:为 **加粗***斜体*`行内代码` 添加对应的 InlineNode 子类型。

嵌套列表:当前列表只有一层。可以在 ListItemNode 中添加可选的子列表字段。

行内代码高亮:将代码块的语言映射到语法高亮规则上。

HTML 输出:写一个 renderer.ts,将 AST 渲染为 HTML 字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 扩展后的渲染函数签名
function render(node: ASTNode): string {
  switch (node.type) {
    case "document":
      return node.children.map(render).join("\n")
    case "heading":
      const tag = `h${node.level}`
      return `<${tag}>${renderInline(node.children)}</${tag}>`
    case "paragraph":
      return `<p>${renderInline(node.children)}</p>`
    case "code":
      return `<pre><code class="language-${node.language}">${escapeHtml(node.content)}</code></pre>`
    case "list":
      return `<ul>${node.items.map(render).join("\n")}</ul>`
    case "listItem":
      return `<li>${renderInline(node.content)}</li>`
    case "text":
      return escapeHtml(node.value)
  }
}

目录