第 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)
}
}
|