从零实现一个 Markdown 解析器:块级状态机、行内分隔符栈与增量解析
在上一篇文章中,我们从零实现了一个栈式虚拟机,完成了从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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, ''');
}
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的增量实现,会发现它们在架构上遵循的是同一套思路,差异只在缓存策略和调度优化。
- 点赞
- 收藏
- 关注作者
评论(0)