从零开始:HTML编译器基础指南 – wiki大全

The complete article “从零开始:HTML编译器基础指南” has been written and reviewed. Here it is:

从零开始:HTML编译器基础指南

引言

在软件开发的世界里,编译器是连接人类可读代码与机器可执行指令的桥梁。我们通常将编译器与高级编程语言(如C++、Java、Python)联系起来,但你是否想过,对于看似简单的标记语言HTML,也能有“编译器”的概念?虽然HTML本身并非传统的编程语言,不需要编译成机器码,但“HTML编译器”这个术语可以泛指那些将HTML代码转换、优化或处理成另一种形式的工具。这可以是为了提高性能、增强安全性、实现特定功能,甚至是为了将HTML转化为其他格式(如PDF、图片或特定UI组件)。

本文将带你从零开始,深入探讨HTML编译器的基础概念、核心组件以及实现原理。我们将解构一个编译器是如何工作的,并逐步指导你如何构建一个能够理解、处理和转换HTML代码的工具。无论你是想深入理解前端构建工具的内部机制,还是希望为你的项目创造定制化的HTML处理流程,本文都将为你提供坚实的基础。让我们一起踏上这段探索HTML编译器奥秘的旅程吧!

编译器的核心组件

1. 词法分析 (Lexical Analysis / Tokenization)

词法分析是编译器工作的第一步,它将源代码(在这里是HTML文本)分解成一系列有意义的“词素”或“标记”(Tokens)。你可以把这个过程想象成阅读一篇文章:你首先识别出文章中的每个单词、标点符号,而不是一堆连续的字母。

对于HTML,词法分析器(Lexer 或 Tokenizer)会识别出以下类型的标记:

  • 标签开始 <: 例如 <div> 中的 <
  • 标签结束 >: 例如 <div> 中的 >
  • 结束标签开始 </: 例如 </div> 中的 </
  • 属性名: 例如 <a href="..."> 中的 href
  • 属性值: 例如 <a href="https://example.com"> 中的 "https://example.com"
  • 文本内容: 例如 <div>Hello World</div> 中的 Hello World
  • 注释: 例如 <!-- 这是注释 -->
  • DOCTYPE声明: 例如 <!DOCTYPE html>

工作原理:

词法分析器通常会逐个字符地扫描输入,根据预定义的规则(正则表达式或状态机)来识别和提取标记。当识别出一个完整的标记后,它会将其封装成一个包含类型(Token Type)和值(Token Value)的对象,并将其添加到标记流中。

示例:

假设有以下HTML片段:

“`html

Hello

“`

词法分析器可能会生成以下标记流:

[
{ type: 'TAG_START', value: '<' },
{ type: 'IDENTIFIER', value: 'p' },
{ type: 'ATTRIBUTE_NAME', value: 'class' },
{ type: 'ASSIGN', value: '=' },
{ type: 'ATTRIBUTE_VALUE', value: '"greeting"' },
{ type: 'TAG_END', value: '>' },
{ type: 'TEXT_CONTENT', value: 'Hello' },
{ type: 'END_TAG_START', value: '</' },
{ type: 'IDENTIFIER', value: 'p' },
{ type: 'TAG_END', value: '>' }
]

这些标记是构建下一步——语法分析——的基础。它们将杂乱的字符流转换成了有结构、有意义的单元,使得编译器能够更容易地理解HTML的结构。

2. 语法分析 (Syntactic Analysis / Parsing)

在词法分析将原始HTML分解为标记流之后,下一步就是语法分析。语法分析器(Parser)的任务是将这些扁平的标记流转换成一个有层次结构的、能够表示HTML语法关系的树状结构。这个树状结构通常被称为抽象语法树(Abstract Syntax Tree – AST)

你可以将语法分析想象成将一系列单词和标点符号(标记)组织成符合语法规则的句子和段落。它不仅仅是简单地堆砌标记,而是理解它们之间的关系:哪个标签是哪个标签的父级,哪些是子级,属性属于哪个标签,文本内容在哪里等等。

工作原理:

语法分析器通常会按照预定义的语法规则(比如HTML的BNF范式或DTD/Schema)来消费词法分析器产生的标记流。它会尝试将这些标记匹配到语法结构中,并构建AST。如果标记流不符合任何语法规则,语法分析器就会报告语法错误。

常用的语法分析方法有:

  • 递归下降解析(Recursive Descent Parsing):通过一组递归函数来实现,每个函数对应一个语法规则。
  • LR解析(Left-to-right, Rightmost derivation):一种强大的自底向上解析方法,常用于生成解析器。
  • LL解析(Left-to-right, Leftmost derivation):一种自顶向下解析方法。

对于HTML这种标签嵌套的结构,递归下降解析是相对直观且常用的实现方式。

示例:

让我们再次使用之前的HTML片段和词法分析器生成的标记流:

“`html

Hello

“`

词法分析器生成的标记流:

[
{ type: 'TAG_START', value: '<' },
{ type: 'IDENTIFIER', value: 'p' },
{ type: 'ATTRIBUTE_NAME', value: 'class' },
{ type: 'ASSIGN', value: '=' },
{ type: 'ATTRIBUTE_VALUE', value: '"greeting"' },
{ type: 'TAG_END', value: '>' },
{ type: 'TEXT_CONTENT', value: 'Hello' },
{ type: 'END_TAG_START', value: '</' },
{ type: 'IDENTIFIER', value: 'p' },
{ type: 'TAG_END', value: '>' }
]

语法分析器会根据HTML的语法规则,将这些标记构建成如下所示的抽象语法树(AST)结构(具体结构将在下一节详细描述):

// 简化表示
ElementNode {
tagName: 'p',
attributes: {
'class': 'greeting'
},
children: [
TextNode {
value: 'Hello'
}
]
}

可以看到,原本扁平的标记流现在变成了一个层次分明的数据结构。通过这个AST,我们可以清晰地了解HTML文档的结构和内容,为后续的处理(如渲染、转换或优化)奠定基础。

3. 抽象语法树 (Abstract Syntax Tree – AST)

在词法分析将代码分解为标记,语法分析将标记组织成结构之后,我们得到了一个核心数据结构——抽象语法树(Abstract Syntax Tree,简称AST)。AST是源代码的抽象语法结构的树状表示,它以一种简化且统一的方式捕捉了代码的结构和内容,排除了源语言中不重要的语法细节(如括号、分号等在HTML中则主要是 <> 符号)。

对于HTML而言,AST完美地映射了DOM(Document Object Model)的结构,但通常会更轻量级,因为它只包含我们关注的语法信息。

AST的特点:

  • 层次结构: AST清晰地展示了元素之间的嵌套关系(父子、兄弟)。
  • 抽象性: 它移除了词法细节(如标签的 <> 符号),只保留了关键信息,如标签名、属性和内容。
  • 独立于源语言: 一旦生成AST,后续的处理(代码生成、优化等)可以独立于原始的HTML文本格式进行。

AST的节点类型:

一个HTML的AST通常会包含以下几种主要节点类型:

  1. 根节点 (Document Node):代表整个HTML文档的顶级节点。
  2. 元素节点 (Element Node):代表一个HTML标签,例如 <div><p><a> 等。它会包含:
    • tagName: 标签的名称(如 “div”, “p”)。
    • attributes: 一个键值对的集合,表示标签的属性(如 class="my-class")。
    • children: 一个子节点数组,包含该元素内部嵌套的所有其他节点。
  3. 文本节点 (Text Node):代表HTML元素中的纯文本内容,例如 <p>Hello World</p> 中的 “Hello World”。它会包含:
    • value: 文本内容字符串。
  4. 注释节点 (Comment Node):代表HTML注释,例如 <!-- 这是注释 -->。它会包含:
    • value: 注释内容字符串。
  5. DOCTYPE节点 (Doctype Node):代表 <!DOCTYPE html> 声明。

示例:

基于之前的HTML片段:

“`html

Hello, World!

“`

其对应的简化AST结构可能如下所示:

javascript
// 抽象语法树 (AST) 示例
{
type: 'Document',
children: [
{
type: 'Element',
tagName: 'div',
attributes: {
id: 'app'
},
children: [
{
type: 'Element',
tagName: 'p',
attributes: {
class: 'greeting'
},
children: [
{ type: 'Text', value: 'Hello, ' },
{
type: 'Element',
tagName: 'span',
attributes: {},
children: [
{ type: 'Text', value: 'World' }
]
},
{ type: 'Text', value: '!' }
]
}
]
}
]
}

通过这个AST,我们可以非常方便地遍历、查询、修改或转换HTML文档的任何部分。它是编译器的核心,承载了所有后续处理所需的信息。

4. 代码生成或解释 (Code Generation / Interpretation)

在HTML编译器的语境下,”代码生成” 或 “解释” 阶段是将抽象语法树 (AST) 转换为最终目标形式的关键一步。与传统编程语言编译器将AST转换为机器码或字节码不同,HTML编译器的目标通常更为多样化,包括但不限于:

  • 重新生成HTML: 对AST进行修改(例如,添加、删除、修改元素或属性),然后将其序列化回优化或转换后的HTML字符串。这是最常见的应用场景,例如HTML压缩器、模板引擎、或在构建时进行组件转换。
  • 生成其他格式: 将HTML结构转换为非HTML格式,例如:
    • PDF或图片: 用于离线查看或打印。
    • 特定UI框架的组件: 例如,将HTML片段转换为React、Vue或Angular组件的VNode结构。
    • 数据结构: 提取HTML中的特定数据并将其转换为JSON或其他结构化数据。
  • 执行特定操作 (解释): 直接遍历AST,并根据节点类型执行相应的行为,而不是生成新的代码。例如,一个简单的HTML渲染器可以直接读取AST并将其呈现在一个自定义的画布上,或者用于模拟浏览器行为进行测试。

工作原理:

这个阶段的核心是遍历AST。通常采用深度优先(Depth-First)或广度优先(Breadth-First)的方式,访问AST中的每一个节点。对于每个节点,根据其类型(元素节点、文本节点、注释节点等)和预期的目标,执行相应的操作。

以重新生成HTML为例:

  1. 遍历器 (Walker/Visitor): 实现一个遍历AST的函数,该函数会递归地访问子节点。
  2. 节点处理器 (Node Handler): 为每种节点类型(如 ElementNode, TextNode)定义一个处理函数。
  3. 构建输出:
    • 当遇到 ElementNode 时,根据 tagName 拼接开始标签(例如 <div>),然后处理其 attributes,接着递归处理 children,最后拼接结束标签(例如 </div>)。
    • 当遇到 TextNode 时,直接将其 value 添加到输出中。
    • 当遇到 CommentNode 时,根据需要决定是否保留或移除。

示例(重新生成HTML的伪代码):

``javascript
function generateHtml(astNode) {
if (astNode.type === 'Document') {
return astNode.children.map(generateHtml).join('');
} else if (astNode.type === 'Element') {
const attributes = Object.entries(astNode.attributes)
.map(([key, value]) =>
${key}=”${value}”)
.join(' ');
const childrenHtml = astNode.children.map(generateHtml).join('');
return
<${astNode.tagName}${attributes ? ‘ ‘ + attributes : ”}>${childrenHtml}</${astNode.tagName}>`;
} else if (astNode.type === ‘Text’) {
return astNode.value;
}
// 可以添加更多节点类型,如 Comment, Doctype 等
return ”;
}

// 假设我们有一个名为 rootAstNode 的AST根节点
// const finalHtml = generateHtml(rootAstNode);
“`

这个阶段是HTML编译器实现其核心价值的地方。通过对AST的灵活操作,开发者可以实现各种复杂的HTML处理逻辑,从而满足特定项目或性能需求。

如何构建HTML编译器:逐步指南

理解了HTML编译器的核心组件后,现在我们将通过一个逐步指南来探讨如何从零开始构建一个简单的HTML编译器。

步骤1:定义你的目标

在开始编写代码之前,明确你的编译器要解决什么问题至关重要。你想实现什么功能?

  • HTML优化器? 移除注释、空白符、压缩属性值?
  • HTML转换器? 将特定标签转换为另一个,或根据条件改变结构?
  • HTML模板引擎? 支持自定义指令或数据绑定?
  • 特定框架的预处理器? 将类HTML语法转换为框架原生组件?

明确目标将指导你后续的选择,包括选择哪些编程语言、库以及需要处理哪些HTML特性。

步骤2:选择你的工具和语言

你可以使用任何你熟悉的编程语言来构建编译器。流行的选择包括:

  • JavaScript/TypeScript: 对于前端开发者而言,这是最自然的选择,因为可以直接在浏览器或Node.js环境运行。有许多现成的库可以帮助你,例如 acorn (虽然是JS解析器,但其思路可借鉴), posthtmlcheerio (用于DOM操作,可作为AST操作的替代)。
  • Python: 拥有强大的字符串处理能力和丰富的解析库(如 BeautifulSoup 用于DOM解析,ply 用于构建词法/语法分析器)。
  • Go/Rust: 追求高性能的场景,这些语言提供了更底层的控制。

对于简单的HTML编译器,你甚至可以不依赖任何外部库,从头开始手写词法分析器和语法分析器,但这将是一个更具挑战性的任务。

步骤3:实现词法分析器 (Lexer/Tokenizer)

词法分析器的目标是将原始HTML字符串分解成一系列标记。

实现思路:

  1. 输入流: 维护一个指向当前处理字符的指针。
  2. 状态机: 根据当前字符和上下文,切换内部状态。例如,当遇到 < 时,进入“标签开始”状态;当遇到 " 时,进入“属性值”状态。
  3. 模式匹配: 使用正则表达式或逐字符匹配来识别不同的标记模式。
    • 例如,识别 <tagName 模式。
    • 识别 attribute="value" 模式。
    • 识别文本内容,直到遇到 < 符号。
  4. 生成标记: 每识别一个完整的词素,创建一个 Token 对象(包含 typevalue),并将其添加到标记列表中。

示例(JavaScript 伪代码):

“`javascript
function tokenize(htmlString) {
let cursor = 0;
const tokens = [];

while (cursor < htmlString.length) {
    let char = htmlString[cursor];

    if (char === '<') {
        if (htmlString.substring(cursor, cursor + 4) === '<!--') {
            // 处理注释
            const endIndex = htmlString.indexOf('-->', cursor);
            if (endIndex === -1) { /* 错误处理:未闭合的注释 */ break; }
            tokens.push({ type: 'COMMENT', value: htmlString.substring(cursor + 4, endIndex) });
            cursor = endIndex + 3;
        } else if (htmlString.substring(cursor, cursor + 2) === '</') {
            // 处理结束标签
            const endIndex = htmlString.indexOf('>', cursor);
            if (endIndex === -1) { /* 错误处理:未闭合的标签 */ break; }
            const tagName = htmlString.substring(cursor + 2, endIndex).trim();
            tokens.push({ type: 'END_TAG', value: tagName });
            cursor = endIndex + 1;
        } else {
            // 处理开始标签
            const endIndex = htmlString.indexOf('>', cursor);
            if (endIndex === -1) { /* 错误处理:未闭合的标签 */ break; }
            const tagAndAttrs = htmlString.substring(cursor + 1, endIndex);
            const tagNameMatch = tagAndAttrs.match(/^([a-zA-Z0-9-]+)/);
            if (tagNameMatch) {
                const tagName = tagNameMatch[1];
                tokens.push({ type: 'START_TAG', value: tagName });
                // 提取属性,这部分会更复杂,可能需要一个子词法分析器
                // ... 简化的属性处理,实际需更复杂逻辑
                const attrString = tagAndAttrs.substring(tagName.length).trim();
                const attrRegex = /([a-zA-Z0-9-]+)=(".*?"|'.*?'|[^\s>]+)/g;
                let match;
                while ((match = attrRegex.exec(attrString)) !== null) {
                    tokens.push({ type: 'ATTRIBUTE_NAME', value: match[1] });
                    tokens.push({ type: 'ASSIGN', value: '=' });
                    tokens.push({ type: 'ATTRIBUTE_VALUE', value: match[2] });
                }
            }
            cursor = endIndex + 1;
        }
    } else if (/\s/.test(char)) {
        // 跳过空白符,或将其作为WHITESPACE标记
        cursor++;
    } else {
        // 处理文本内容
        let textEndIndex = cursor;
        while (textEndIndex < htmlString.length && htmlString[textEndIndex] !== '<') {
            textEndIndex++;
        }
        const textContent = htmlString.substring(cursor, textEndIndex);
        if (textContent.trim().length > 0) { // 避免空文本节点
            tokens.push({ type: 'TEXT', value: textContent });
        }
        cursor = textEndIndex;
    }
}
return tokens;

}
“`

步骤4:实现语法分析器 (Parser)

语法分析器将词法分析器生成的标记流转换成抽象语法树(AST)。对于HTML,通常使用递归下降解析器。

实现思路:

  1. 节点定义: 定义AST节点的结构,例如 ElementNode, TextNode, AttributeNode 等。
  2. 当前标记指针: 维护一个指针,指向当前正在处理的标记。
  3. peek()consume(): peek() 函数查看下一个标记而不移动指针,consume() 函数获取当前标记并移动指针。
  4. 递归函数: 为HTML的每个主要语法结构(例如 parseElement(), parseAttributes(), parseText())编写递归函数。
    • parseElement(): 期望遇到 START_TAG,然后解析属性,接着递归调用 parseChildNodes() 来解析子节点,最后期望遇到 END_TAG
    • parseChildNodes(): 循环解析文本节点或子元素节点。

示例(JavaScript 伪代码):

“`javascript
// 假设有 tokenStream 和一个 currentTokenIndex 指针
function parse(tokens) {
let current = 0;

function eat(type, expectedValue = null) {
    const token = tokens[current];
    if (token && token.type === type && (expectedValue === null || token.value === expectedValue)) {
        current++;
        return token;
    }
    // console.error(`Syntax error: Expected ${type}${expectedValue ? ' ' + expectedValue : ''} but got ${token ? token.type + ' ' + token.value : 'EOF'}`);
    // 实际实现中应抛出更具体的错误
    return null;
}

function parseAttributes() {
    const attributes = {};
    while (current < tokens.length && tokens[current].type === 'ATTRIBUTE_NAME') {
        const nameToken = eat('ATTRIBUTE_NAME');
        eat('ASSIGN', '='); // Consume '='
        const valueToken = eat('ATTRIBUTE_VALUE');
        if (nameToken && valueToken) {
            attributes[nameToken.value] = valueToken.value.replace(/^["']|["']$/g, ''); // 移除引号
        }
    }
    return attributes;
}

function walk() {
    if (current >= tokens.length) return null;

    let token = tokens[current];

    if (token.type === 'START_TAG') {
        eat('START_TAG'); // Consume the start tag token
        const node = {
            type: 'Element',
            tagName: token.value,
            attributes: parseAttributes(), // 调用属性解析
            children: []
        };

        // HTML标签自闭合判断 (简化,实际需考虑更多自闭合标签)
        // 这里假设 '>' 是在属性之后立即出现
        if (tokens[current] && tokens[current].type === 'TAG_END' && tokens[current].value === '>') {
             eat('TAG_END', '>'); // Consume the closing '>' of the start tag
        }

        while (current < tokens.length && (tokens[current].type !== 'END_TAG' || tokens[current].value !== node.tagName)) {
            let child = walk(); // Recursively parse child nodes
            if (child) {
                node.children.push(child);
            } else {
                // 如果遇到无法解析的标记,跳过以避免死循环
                current++;
                if (current >= tokens.length) break;
            }
        }

        // 检查并消费结束标签
        if (tokens[current] && tokens[current].type === 'END_TAG' && tokens[current].value === node.tagName) {
            eat('END_TAG'); // Consume the end tag token
            eat('TAG_END', '>'); // Consume the closing '>' of the end tag
        } else {
            // 错误处理: 缺少结束标签
            // console.warn(`Missing closing tag for <${node.tagName}>`);
        }
        return node;
    }

    if (token.type === 'TEXT_CONTENT') {
        eat('TEXT_CONTENT');
        return {
            type: 'Text',
            value: token.value
        };
    }

    if (token.type === 'COMMENT') {
        eat('COMMENT');
        return {
            type: 'Comment',
            value: token.value
        };
    }

    // 遇到不能识别的token,为了避免死循环,跳过
    current++;
    return null;
}

const ast = {
    type: 'Document',
    children: []
};

while (current < tokens.length) {
    let node = walk();
    if (node) {
        ast.children.push(node);
    }
}

return ast;

}
“`

步骤5:遍历和处理AST

一旦你有了AST,就可以开始对其进行遍历和操作,以实现你的编译器目标。

实现思路:

  1. 遍历函数: 编写一个递归函数,能够访问AST中的每个节点。
  2. 访问者模式 (Visitor Pattern): 定义一个 visitor 对象,其中包含针对不同节点类型的方法(例如 enterElement(node), exitElement(node), visitText(node))。遍历函数在访问节点时调用 visitor 中相应的方法。这使得你可以清晰地分离遍历逻辑和节点处理逻辑。
  3. 转换逻辑: 在 visitor 方法中实现对节点的修改、添加、删除或提取信息等操作。

示例(JavaScript 伪代码 – 移除注释):

“`javascript
function traverse(ast, visitor) {
function walkNodes(nodes) {
return nodes.filter(node => {
if (visitor[node.type]) {
const result = visitornode.type;
if (result === false) return false; // Visitor can halt traversal or remove node
}
if (node.children) {
node.children = walkNodes(node.children);
}
return true;
});
}
// 假设AST的根节点直接包含children数组,如果没有Document节点
if (ast.type === ‘Document’ && ast.children) {
ast.children = walkNodes(ast.children);
} else if (Array.isArray(ast)) { // 如果AST是节点数组
ast = walkNodes(ast);
} else { // 如果AST是单个节点
if (ast.children) {
ast.children = walkNodes(ast.children);
}
}
return ast;
}

const removeCommentsVisitor = {
‘Comment’: (node) => {
// 返回 false 表示从AST中移除此节点
return false;
}
};

// // 示例用法
// let myAst = parse(tokenize(‘

Hello

‘));
// myAst = traverse(myAst, removeCommentsVisitor); // AST现在不包含注释节点
“`

步骤6:输出或执行结果

最后一步是将处理后的AST转换为目标形式。

  • 生成HTML: 如果你的目标是输出优化或转换后的HTML,你需要一个“代码生成器”或“渲染器”,它会遍历AST并将其序列化回HTML字符串,类似于“代码生成或解释”部分中描述的 generateHtml 函数。
  • 生成其他格式: 根据目标格式(如JSON、React组件),实现相应的遍历和转换逻辑。
  • 执行: 如果你的编译器是一个解释器,那么在遍历AST的过程中,你会直接执行相应的操作,例如渲染到屏幕,而不是生成新的代码。

实际应用与高级概念

构建HTML编译器不仅仅是一个学术练习,它在现代前端开发中有着广泛的应用:

  • 构建工具: Webpack、Rollup、Parcel 等打包工具在处理HTML文件时,常常会内部使用类似编译器的技术进行优化。
  • 框架: React 的 JSX、Vue 的单文件组件(SFC)以及 Angular 的模板,都涉及到将自定义的或增强的HTML语法“编译”成JavaScript运行时可以理解和执行的指令。
  • 静态站点生成器: Gatsby、Next.js 等工具在构建时会处理HTML文件,生成最终的静态页面。
  • 文档生成: Markdown 编译器将 Markdown 文本转换为HTML。
  • SEO优化: 在构建时对HTML进行预渲染,以提供更好的搜索引擎可见性。

高级概念:

  • 错误恢复: 当遇到语法错误时,编译器如何尝试恢复并继续解析,以提供更全面的错误报告。
  • 源映射 (Source Maps): 将生成代码的某个位置映射回原始代码的相应位置,方便调试。
  • 插件系统: 允许用户通过插件扩展编译器的功能,例如 PostHTML 的插件生态。

结论

通过本文,我们深入探讨了“HTML编译器”这一概念,它虽然不同于传统编程语言的编译器,但在前端开发和Web构建流程中扮演着日益重要的角色。我们从词法分析、语法分析、抽象语法树(AST)到代码生成/解释,逐步解构了编译器的各个核心组件,并通过伪代码示例展示了每个阶段的工作原理。

从最初的原始HTML字符串,经过词法分析将其分解为有意义的标记,再通过语法分析构建出层次分明的抽象语法树,最终利用AST进行各种转换、优化或生成新的输出,这一过程揭示了前端构建工具和框架背后强大的处理能力。

掌握这些基础知识,不仅能帮助你更好地理解现代前端工具链的工作机制,还能让你具备构建定制化HTML处理解决方案的能力。无论你是希望实现一个简单的HTML压缩器,还是为你的项目构建一个高级的模板引擎,亦或是深入研究特定框架的实现原理,本文都希望能为你点亮前行的方向。

“从零开始”构建一个完整的HTML编译器虽然充满挑战,但也充满了乐趣和学习的机会。现在,你已经拥有了基础的理论知识和实践思路,是时候动手尝试,开启你的HTML编译器之旅吧!

滚动至顶部