深入理解 Babel - 微内核架构与 ECMAScript 标准化|得物技术
目录
一、Babel简介
1. Babel是什么
2. 转译过程
二、Babel微内核架构
1. 微内核架构
2. 转译模块
3. 插件模块
4. 工具模块
5. 运行时相关模块
三、标准化
1. ECMAScript
2. 如何阅读 ECMAScript
3. web标准
四、总结
随着浏览器版本的持续更新,浏览器对JavaScript的支持越来越强大,Babel的重要性显得较低了。但Babel的设计思路、背后依赖的ECMAScript标准化思想仍然值得借鉴。
本文涉及的Babel版本主要是V7.16及以下,截至发文时,Babel最新发布的版本是V7.25.6,未出现大版本更新,近2年也进入了稳定迭代期,本文的分析思路基本适用目前的Babel设计。
一
Babel简介
Babel是什么
Babel是JavaScript转译器,通过Babel,开发者可以自由使用下一代ECMAScript 语法。高版本ECMAScript语法将被转译为低版本语法,以便顺利运行在各类环境,如低版本浏览器、低版本 Node.js 等。
Babel 是转译器,不是编译器。下面是转译和编译的区别:
编译,一般指将一种语言转换为另一种语法和抽象程度等都不同的语言,常见的比如 gcc 编译器。
转译,一般指将一种语言转换为不同版本或者抽象程度相同的语言,比如 Babel 可以把 ECMAScript 6 语法转译为 ECMAScript 5语法。
利用 Babel,开发者可以使用 ECMAScript 的各种新特性进行开发,同时花极少的精力关注浏览器或其他JS运行环境对新特性的支持。甚至,开发者可以根据自身需要,创造属于自己的 JavaScript 语法。
Babel在转译的时候,会对源码进行以下处理: 语法转译(Syntax)和添加API Polyfill。
语法(Syntax)部分
Babel 支持识别高版本语法,并通过插件将源码从高版本语法转译为低版本语法,如:
箭头函数 () => {} 转为普通函数
function() {}
。const / let
转译为var
API Polyfill
有些运行时相关的 API,语法转译无法解决它们对低版本浏览器等环境的兼容性问题,因此 Babel 通过与 core-js 等工具的配合,实现 API 部分对目标环境(通常是低版本浏览器等)的兼容。
例如
[1, 2, 3].include
、Promise
等 API,Babel 在处理时,如果目标环节可能不支持原生的include / Promise
的话,Babel 会在转译结果中嵌入include / Promise
的自定义实现。有多种方式可以使用 Babel,如: 命令行(babel-cli、babel-node)、浏览器(babel-standalone)、API 调用(babel-core)、webpack loader(babel-loader)等。
转译过程
和多数转译器相同,Babel 运行的生命周期主要是 3 个阶段: 解析、转换、代码生成。
这个过程涉及抽象语法树:
抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。
AST 是树形对象,以结构化的形式表示编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
源码字符串需要经转译器生成 AST,转译器有很多种,不同转译器,生成的AST对象格式细节可能有差异,但共同点为: 都是树形对象、该树形对象描述了节点特征、各节点之间的关系(兄弟、父子等)。
以下是 Babel 生命周期的三个过程:
解析(Parsing): Code1 ==> 抽象语法树1
解析过程包括 2 个环节: 词法解析、语法解析,最终生成抽象语法树。
词法解析阶段,代码字符串被解析为 token 令牌数组,数组项是一个对象,包括: 代码字符碎片的值、位置、类型等信息。
token 数组是平铺式的数组,没有嵌套的结构信息,它是为语法解析阶段做准备的。
语法解析阶段,token 令牌数组被解析为结构化的抽放语法树对象(AST)。
babel-parser 完成该阶段的主要功能。
转换(Transformation): AST1 ==> AST2
Babel 生成 AST 后,会对 AST 进行遍历,遍历过程中,各类插件对原 AST 对象进行增删改查等操作,AST 结构被修改。
代码生成(Generation): AST2 ==> Code2
Babel 将修改后的 AST 对象转目标代码字符串。
babel-generator 完成该阶段的主要功能。
二
Babel微内核架构
微内核架构
Babel 采用微内核架构,其内核保留核心功能,其余功能利用外部工具和插件机制实现,也体现了"开放-封闭"的设计原则。
除了微内核设计架构,Babel 的模块设计也可以做如下分类:
转译模块
转译模块位于 Babel 微内核架构的"微内核"部分,该部分主要负责代码转译,也就是上面提到的"解析-转换-代码生成"过程。
该模块主要包括: babel-parser、babel-traverse、babel-generator。
babel-parser
负责将代码字符串转为 AST 对象。
有 2 点值得一提:
babel-parser 本身并不会对 AST 做转换操作,只是负责解析出 AST。AST 转换部分交由各类 plugins 和 presets 处理。
babel-parser 内置了对 ESNext/TypeScript/JSX/Flow 最新版本语法的支持,但很多默认是不开启的,目前没有开放插件机制扩展新语法。
babel-traverse
在转译过程中,babel-traverse 负责遍历 AST 节点,并根据配置的 plugins/presets,在遍历过程中,对各个 AST 节点进行增删改查的操作。
AST 是一个树形对象,遍历 AST 对象的过程也是一个深度优先遍历的过程。
babel-generator
负责将 AST 节点,转为代码字符串,同时也可以生成 source-map。
插件模块
插件模块包括 plugins、presets。
plugins
丰富的插件,帮助 Babel 成为一个非常成功的转译工具。
对 AST 的遍历、转换是 Babel 转译的核心功能,但 Babel 本身并不参与该过程,将这些功能作为插件引入到运行时。
具体来说,babel-core 作为核心工具,不提供对 AST 的修改逻辑,通过调用各类插件,实现对 AST 的修改。
Babel的插件分为语法插件和转换插件。
语法插件
babel-plugin-syntax-decorators
负责开启 babel-parser 对装饰器的语法支持。
babel-plugin-syntax-dynamic-import
负责开启 babel-parser 对
import
语句的语法支持。babel-plugin-syntax-jsx
负责开启 babel-parser 对 jsx 语法的支持。
转换插件
转换插件就是社区里常说的 Babel 插件,负责转换 AST 节点。
在介绍babel-traverse时提到,它负责遍历AST对象,每个AST节点会被访问到,等待转换,转换的部分,由"转换插件"负责。
转换插件会提供一个叫做"Visitor"的对象,该对象的 Key 为节点名称,Value 部分提供进入该节点时、离开该节点时的回调函数,在回调函数里,可以对该节点进行一系列操作。
"Visitor" 又称为 "访问者"。
babel-plugin-transform-strict-mode
babel-plugin-transform-object-assign
值得注意的是,babel-parser 负责将 JavaScript 代码解析出抽象语法树(AST),它支持全面识别 ESNext/TypeScript/JSX/Flow 等语法,目前由 Babel 团队开发维护,不支持插件化。
Babel 插件生态中的语法插件,其功能就是作为"开关",配置是否开启 babel-parser 的某些语法转译功能。
语法插件在 Babel 源码中,以 babel-plugin-syntax
开头。
举个例子:
// plugin 提供 visitor,在 visitor 中对 AST 节点操作
const visitor = {
Program: {
enter() {},
exit() {},
},
CallExpression: {
enter() {},
exit() {},
},
NumberLiteral: {
enter() {},
exit() {},
}
};
traverse(ast, visitor);
babel-plugin-transform
开头。该插件拦截 Program
节点,也就是整个程序的根节点,添加 "use strict"
指令。
visitor 节点值为函数时,是 enter 回调的快捷方式。
{
name: "transform-strict-mode",
visitor: {
Program(path) {
const { node } = path;
for (const directive of node.directives) {
if (directive.value.value === "use strict") return;
}
path.unshiftContainer(
"directives",
t.directive(t.directiveLiteral("use strict")),
);
},
},
};
}
该插件负责拦截函数调用表达式节点 CallExpression
,将 Object.assign
转为 extends
写法。
{
name: "transform-object-assign",
visitor: {
CallExpression(path, file) {
if (path.get("callee").matchesPattern("Object.assign")) {
path.node.callee = file.addHelper("extends");
}
},
},
}
Presets
Babel 插件的功能是细粒度的,大部分插件承担了单一功能。
而在实际开发过程中,往往需要支持对各类语法的支持。此时,有两种做法:
需要支持哪些特性,就分别引入支持该特性的插件
直接引入一个插件集合,涵盖所需的各类插件功能
很显然,第一种做法是相对麻烦的。针对第二种做法,Babel提供了插件集 preset。
preset 在 Babel 源码中,以 babel-preset 开头。
例如,Babel 已经提供了几种常用的 preset 供开发者使用:
babel-preset-env
babel-preset-react
babel-preset-flow
babel-preset-typescript
插件运行顺序
Babel 配置项中,plugins 和 presets 均以数组的形式配置,执行时有先后顺序。
plugins 在 presets之前运行
plugins 按照数组正序执行
presets 按照数组倒序执行
工具模块
工具模块提供 Babel 相关模块所需的各类工具,以下一一简要介绍:
babel-core
babel-core 对外提供了 Babel 多项功能的 API,如转译文件、转译代码、创建/获取配置等,在 Babel 官方文档介绍的比较详细,不再赘述。
值得注意的是,转译类的 API 均提供了同步和异步版本,如
transformSync/transfomAsync
、parseSync/parseAsync
。
babel-cli
Babel 的命令行工具,可以直接转译文件夹/文件,它也提供了很多配置项做其他工作,官方文档介绍的比较详细,感兴趣的同学可以去 Babel 官网查看详细配置。
babel-standalone
Babel 对外服务的很多包是基于 node 环境下使用的,babel-standalone 提供了浏览器下转译的方案。
babel-standalone 内置了所有 Babel 插件,所以体积还是比较大的,而且在浏览器端转译需要时间,比较适合开发、学习使用,不适合在生产环境使用。
举个例子:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>test babel-standalone</title>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const arr = [1, 2, 3];
console.log(...arr);
</script>
</head>
<body></body>
</html>
在浏览器运行该 html,可以看到,页面结构变成了:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>test babel-standalone</title>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const arr = [1, 2, 3];
console.log(...arr);
</script>
<script>
"use strict";
var _console;
var arr = [1, 2, 3];
(_console = console).log.apply(_console, arr); //# sourceMappingURL=data:application/json;charset=utf-8;base64...
</script>
</head>
<body></body>
</html>
babel-node
// index.js 里可以使用高级语法
babel-node -e index.js
babel-register
在源文件中,引入
babel-register
,如 index.js:require('babel-register');
require('./run');
import fs from 'fs';
console.log(fs);
执行 node index
时,run.js 就不需要被转码了。babel-register 通过拦截 node require 方法,为 node 运行时引入了 Babel 的转译能力。
babel-loader
babel-types
const t = require('@babel/types');
const binaryExpression = t.binaryExpression('+', t.numericLiteral(1), t.numericLiteral(2));
babel-template
import { smart as template } from '@babel/template';
import generate from '@babel/generator';
import * as t from '@babel/types';
const buildRequire = template( var %%importName%% = require(%%source%%); );
const ast = buildRequire({
importName: t.identifier('myModule'),
source: t.stringLiteral("my-module"),
});
const code = generate(ast).code
console.log(code)
运行结果:
var myModule = require("my-module");
babel-code-frame
const { codeFrameColumns } = require('@babel/code-frame');
const testCode = `
class Run {
constructor() {}
}
`;
const location = {
start: {
line: 2,
column: 2,
}
};
const result = codeFrameColumns(testCode, location);
console.log(result);
1 | class Run {
> 2 | constructor() {}
| ^
3 | }
4 |
babel-highlight
运行时相关模块
Babel 配合其插件可以对静态代码进行转译,但有一些遗漏点:
对于运行时涉及的一些高版本 API,并没有提供兼容目标环境的 Polyfill。
转译产物代码可能有些臃肿。
为此,运行时模块(runtime)关注的是转译产物的运行时环境,对运行时提供 API polyfill、代码优化等,该模块涉及几个子包:
babel-preset-env
babel-plugin-transform-runtime
babel-runtime
babel-runtime-corejs2/3
core-js
接下来以案例解释 runtime 模块的作用。
源码文件 index.js 的内容:
const a = 1; // const 为语法部分
class Base {} // class 为语法部分
new Promise() // Promise 为 API 部分
这段源码包含了语法和 API 部分:
const
、class
为语法部分Promise
为 API 部分
如果希望这段源码转为 ES5 版本,使构建产物可以运行在不支持 ES6 和 Promise 的环境里,该怎么做呢?
用 babel 命令行执行转译,其中源文件为 index.js,转译产物文件为 index-compiled.js。
npx babel index.js --out-file index-compiled.js
需要配置.bab
elrc
帮助 Babel 完成语法和 API 部分的转译:
.babelrc
:
{
"presets": [
[
"@babel/preset-env"
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": 3
}
]
]
}
简要解释下该配置的原理:
babel-preset-env 可以完成语法部分转译,即
const
转译为var
但构建产物中,有些辅助代码如
_classCallCheck
是以硬编码的形式直接写入转译产物的:
"use strict";
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var a = 1;
var Base = function Base() {
_classCallCheck(this, Base);
};
new Promise();
这样的后果就是构建产物比较臃肿。
babel-plugin-transform-runtime 可以将上述
_classCallCheck
置于通用包中,以引用的形式写入转译产物:"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
var a = 1;
var Base = function Base() {
(0, _classCallCheck2["default"])(this, Base);
};
new Promise();
babel-plugin-transform-runtime 的配置参数
corejs
用于转译 API 部分,如Promsie
"use strict";
var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");
var _promise = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/promise"));
var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/classCallCheck"));
var a = 1;
var Base = function Base() {
(0, _classCallCheck2["default"])(this, Base);
};
new _promise"default";
Babel 转译过程的运行时优化是一个繁琐的过程,为此将单独用一章讲解运行时优化,感兴趣的同学可以直接阅读 "Babel Runtime" 章节详细了解。
三
标准化
Babel 生态涉及的一些标准化组织。无论是 JavaScript、HTML、DOM、URL 等领域,均需要统一的标准,才能在不同的运行环境下有统一的表现。Babel 转译也需要遵循这些标准,包括 ECMAScript、web标准等。
ECMAScript
JavaScript诞生
1995 年,JavaScript 的第一个版本发布。用时间线的方式描述 JavaScript 的诞生过程会更清晰:
ECMAScript发布
1996 年,微软模仿 JavaScript 实现了 JScript 并内置在 IE3.0,随后,Netscape 公司着手推动 JavaScript 标准化。
这里涉及几个组织:
Ecma International
Ecma International 是一家国际性会员制度的信息和电信标准组织。1994年之前,名为欧洲计算机制造商协会(European Computer Manufacturers Association)。因为计算机的国际化,组织的标准牵涉到很多其他国家,因此组织决定改名表明其国际性。
CD-ROM 格式(之后被国际标准化组织批准为ISO 9660)
C# 语言规范
C++/CLI 语言规范
通用语言架构(CLI)
Eiffel 语言
电子产品环境化设计要素
Universal 3D 标准
OOXML
Dart 语言规范
ECMAScript 语言规范(以 JavaScript 为基础)ECMA-262
其中就包括 JavaScript 标准语言规范 ECMAScript。cma International 拥有 ECMAScript 的商标。
ECMA TC39
维护和更新 ECMAScript 语言标准
识别、开发、维护 ECMAScript 的扩展功能库
开发测试套件
为 ISO/IEC JTC 1 提供标准
评估和考虑新添加的标准
ISO
ECMAScript 发展过程中的关键节点
ECMAScript 各版本
ECMAScript 经历了多个版本,每个版本有自己的特点,简单列举如下:
ECMAScript 迭代过程
一个 ECMAScript 标准的制作过程,包含了 Stage 0 到 Stage 4 共 5 个阶段,每个阶段提交至下一阶段都需要 TC39 审批通过。
特性进入 Stage-4 后,才有可能被加入标准中,还需要 ECMA General Assembly 表决通过才能进入下一次的 ECMAScript 标准中。
如何阅读 ECMAScript
ECMAScript 文档结构
ECMAScript 的规格,可以在 ECMA 国际标准组织的官方网站免费下载和在线阅读。
查看ECMAScript 不同版本的地址:https://ecma-international.org/publications-and-standards/standards/ecma-262/。
截至 2023年底,已发布的版本如下:
ECMA-262 5.1 edition, June 2011
(https://262.ecma-international.org/5.1/index.html)
ECMA-262, 6th edition, June 2015
(https://262.ecma-international.org/6.0/index.html)
ECMA-262, 7th edition, June 2016
(https://262.ecma-international.org/7.0/index.html)
ECMA-262, 8th edition, June 2017
(https://262.ecma-international.org/8.0/index.html)
ECMA-262, 9th edition, June 2018
(https://262.ecma-international.org/9.0/index.html)
ECMA-262, 10th edition, June 2019
(https://262.ecma-international.org/10.0/index.html)
ECMA-262, 11th edition, June 2020
(https://262.ecma-international.org/11.0/index.html)
ECMA-262, 12th edition, June 2021
(https://262.ecma-international.org/12.0/index.html)
ECMA-262, 13th edition, June 2022
(https://262.ecma-international.org/13.0/index.html)
ECMA-262, 14th edition, June 2023
(https://262.ecma-international.org/14.0/index.html)
每个版本有独立的网址,格式为: https://262.ecma-international.org/{version}/,比如 ECMAScript 14.0 版本的网址为 https://262.ecma-international.org/14.0/。
从章节数量上,ECMAScript 6.0
、ECMAScript 7.0
有 26 章,之后的版本有 27-29 章,虽然章节数量不同,规格章节的分布是保持一定规律的,以 ECMAScript 11.0
版本为例:
Introduction: 介绍部分
第 1 章到第 3 章: 描述了规格文件本身,而非语言
第 1 章用一句话描述了该规格的描述范围。
第 2 章描述了基于规格的"实现"的一致性要求:
"实现"必须支持规格中描述的所有类型、值、对象、属性、函数以及程序的语法和语义
"实现"必须按照 Unicode 标准和 ISO/IEC 10646 的最新版本处理文本输入
"实现"如果提供了应用程序编程接口(API),那么该 API 需要适应不同的人文语言和国家差异,且必须实现最新版本的 ECMA-402 所定义的与本规范相兼容的接口
"实现"可以支持该规格中没有提及的类型、值、对象、属性、函数、正则表达式语法以及其他编程写法
"实现"不能实现该规格中禁止的写法
第 3 章描述了该规格的一些参考资料:
ISO/IEC 10646
ECMA-402
EMCA-404 JSON 数据交换格式规范
第 4 章: 对这门语言总体设计的描述。
第 5 章到第 8 章: 语言宏观层面的描述。
第 6 章介绍数据类型。
第 7 章介绍语言内部用到的抽象操作。
第 8 章介绍代码如何运行。
第 9 章到第 27 章: 介绍具体的语法。
一般而言,除非写编译器,开发者无需阅读 ECMAScript 的规格,规格的内容非常多,如无必要也无需通读。只是在遇到一些奇怪的问题时,阅读官方规格,是最稳妥的办法。
通过阅读规格解决一些问题
(以ECMAScript 11.0为例)
识别关键词和保留字,并高亮
关键词(keywords)
关键词首先是标识符,同时有语义,包括
if、while、async、await...
,个别关键词是可以用作变量名的。保留字(reserved word)
保留字首先是标识符,但不能用作变量名。
部分关键词是保留字,但部分不是:
if、while
是保留字;await
只有在async
方法和模块中才是保留字;async
不是保留字,它可以作为普通的变量名使用。保留字列表
await
break
case
catch
class
const
continue
debugger
default
delete
do
else
enum
export
extends
false
finally
for
function
if
import
in
instanceof
new
null
returns
uper
switch
this
throw
true
try
typeof
var
void
while
with
yield
读完上述规格,也就知道哪些字符单元是需要识别为保留字与关键词,并高亮的了。
识别全局对象,并高亮
全局属性
全局属性有:
globalThis
、Infinity
、NaN
、undefined
。全局方法
全局构造函数
全局方法有: eval(x)
、isFinite
、isNaN
、parseFloat
、parseInt
、decodeURIComponent
、encodeURIComponent
等。
全局的构造函数有:
Array
ArrayBuffer
BigInt
BigInt64Array
BigUnit64Array
Boolean
DataView
Date
Error
EvalError
Float32Array
Float64Array
Function
Int8Array
Int16Array
Int32Array
Map
Number
Object
Promise
Proxy
RangeError
ReferenceError
RegExp
Set
SharedArrayBuffer
String
Symbol
SyntaxError
TypeError
Uint8Array
Uint8ClampedArray
Uint16Array
Uint32Array
URIError
WeakMap
WeakSet
其他的全局属性
Atomics
、JSON
、Math
、Reflect
。很显然,当字符单元的名称是上述名称中的一员时,我们可以对其进行高亮处理了(若上下文中无重新定义的同名变量)。
自定义 Error
class LoaderError extends Error {
constructor(err) {
super();
const { name, message, codeFrame, hideStack } = format(err);
this.name = "BabelLoaderError";
this.message = ${name ? ${name}: ` : ""}${message}\n\n${codeFrame}\n`;
this.hideStack = hideStack;
Error.captureStackTrace(this, this.constructor);
}
}
可以看到,babel-loader 自定义了错误实例的 name
、message
、hideStack
属性,那么,问题是,原生的 Error
类有哪些属性和方法,哪些是开发者可以自定义的呢?Error
的各类规范:Error
作为函数被调用时(Error(...)
),表现和new Error(...)
一致,均会创建并返回Error
的新实例Error
可以被继承,比如通过extends
的方式,子类必须提供constructor
方法,且该方法内必须提供super()
调用Error
构造函数必须有prototype
属性Error.prototype
属性需有以下属性:
Error.prototype.constructor
: 指向构造函数Error.prototype.message
: 描述错误信息,默认是空字符串Error.prototype.name
: 描述错误名称,默认值是 Error
从 LoaderError
的源码可以看到,LoaderError
做了以下几件事情:
LoaderEr
ror
继承自Error
实例自定义了
name
、message
属性,明确 babel-loader 的信息实例自定义的
hideStack
属性是非标准属性,用于 babel-loader 内部
web标准
是在解决 API Polyfil 的时候,Babel 配合使用的 core-js 除了提供 ECMAScript 标准下的 JavaScript API 实现,也提供了 DOM/URL 等实现。而 DOM/URL 所属的 web 标准,由 W3C/WHATWG 制定。
经过多年发展,WHATWG 和 W3C 目前是合作关系,其中,WHATWG 维护 HTML 和 DOM 标准,W3C 使用 WHATWG 存储库中的 HTML 和 DOM 标准描述,W3C 在 HTML 部分的工作集中在 XHTML/XML 上。
四
总结
本文介绍了 Babel 的概述/微内核架构/ECMAScript标准化方面的设计思想和部分实现原理。
上述内容其实在很早之前就已经成型了,笔者也查看了Babel最近的迭代内容,发现并没有太大的变化。至于代码转译领域,目前是Babel还是其他工具哪个更有优势,不在本文的讨论范围内。除了比较社区哪些工具更好而言,“Babel的设计思路、其与标准规范是怎么配合的”等也是很值得学习的地方,也是这篇文章的产生背景。
希望本文对你有所帮助!
往期回顾
2. 浅析JVM invokedynamic指令和Java Lambda语法|得物技术
文 / hoperyy
关注得物技术,每周一、三更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
“
扫码添加小助手微信
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:
线下活动推荐
快快点击下方图片报名吧!