Tokenizer

2023-08-17 • ☕️☕️ 10 min read

在日常工作的大多数的时候,我们通常不用考虑编译器。但是编译器其实一直在我们身边,我们使用的很多工具都是基于编译器的概念而创造的。理解编译器背后的原理还是很重要的,所以我打算参考这个项目一步步写个简单的 JavaScript 编译器,以从实践中更多地了解其背后的原理。

Babel 基础

Babel 是 JavaScript 编译器,你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。Babel 可以在不需要执行代码的前提下就对代码进行分析,它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。

Babel 对代码的处理可以分为解析、转换和生成三个阶段,每个阶段之间就像管道连接起来一样,上一个阶段的输出会作为下一阶段的输入。

    解析
    Parse
    转换
    Transform
    生成
    Generate

解析阶段

当你看到像 1/2+3.4 的一个算式,你会立刻明白这个算式的含义,甚至在下一刻就能得出这个算式的答案。在这个过程中,你意识到在这算式中存在 3 个数字,然后 /+ 这两个运算符把它们组合在一起,紧接着你可能还记得除法的优先级高于加法的规则,所以你会先计算 1/2,然后再把相除后的结果和 3.4 相加得出最后结果。

    1/2+3.4

上面对算式的处理过程,其实类似于编译器对代码的解析过程。在解析阶段,编译器会把代码字符串作为输入,然后把代码转换为一个 token 列表,如上面将算式分解成数字和运算符。token 是代码字符串中具有含义的最小单元,可以理解为汉语中的词语或英语中的单词。

但是为什么要把代码转换为 token 列表呢?

  1. 将代码转换为机器可读的格式。代码是人类可读的,但对于计算机来说,代码中的字符串是没有语法意义的。
  2. 消除代码中的歧义。代码中可能存在歧义,例如,相同的字符串可以是标识符也可以是常量。编译器在解析阶段可以通过对代码进行分析,消除这些歧义。
const hello /* 标识符 */ = 'hello'; /* 常量 */

遍历输入代码

现在开始我会将算式例子换成下面这段 javascript 代码,然后我会一步步将这段代码解析为 token 列表。

function hello() {
  console.log('hello, world!');
}

在继续看下去之前,我们可以思考下编译器是如何在解析阶段处理这段代码的呢?

其实并没有什么魔法存在于编译器中,编译器不像人类可以大致扫一眼代码就能理解其背后的含义。编译器只能逐个字符遍历代码字符串,然后在遍历的过程中对代码字符串进行切分和添加含义方便后面的步骤。

f

u

n

c

t

i

o

n

h

e

l

l

o

(

)

{

\n

c

o

n

s

o

l

e

.

l

o

g

(

'

h

e

l

l

o

,

w

o

r

l

d

!

'

)

\n

}

有些心细的读者可能会注意到上面的示例中出现的 \n。这个字符代表了换行的含义,它通常在我们编辑的代码中是不可见的,但是为了从编译器的视角来理解解析的过程,所以将它展示了出来。

这个例子只展示了遍历输入的代码的过程,但是还没有添加如何将代码切分成 token 的逻辑。在继续添加相关逻辑之前,我们先花一点篇幅了解下 JavaScript 中的标识符(identifier)和关键词(keyword)。

标识符和关键词

标识符是代码中用来标识变量、函数或属性的字符序列,就是代码中的一段字符串用来指代程序中运行的数据。在我们要解析的代码中,helloconsolelog 都是标识符,分别代表了函数,对象和方法。

当然标识符也是有其命名规则,在 javascript 中,标识符只能包含字母、数字、下划线(“_”)和美元符号(“$”),且不能以数字开头。

不能以数字开头的规则,按我的理解是如果标识符可以以数字开头,那么词法分析就需要进行更复杂的判断才能确定一个字符串是数字常量还是标识符。例如,对于字符串 "123abc",词法分析需要先判断"123"是数字常量还是标识符,然后再判断"abc"` 是标识符还是其他记号。

let $abc; // ok
let _abc; // ok
let 1abc; // not ok

functionconstimport 等等的字符串被称为关键词。关键词是在 javascript 中具有特殊意义的字符串,是 javascript 语法中的一部分,所以标识符的命名还不能和关键词重复。

// not ok
function import() {}

代码中的标识符和关键词就是我们所需要切分的 token。当然还有出现在代码中的像括号,花括号之类的符号和各种运算符也是有意义的,所以它们也属于 token 。而像空白符和换行符之类的标记,它们提升了代码的可读性,并将 token 区分开来。

正如单词有名词、动词和形容词等各种类型,不同的 token 也有不同的类型,像 function 这个关键词 token 的类型为 Keyword,而标识符则为 Identifier

最后,我们要解析的代码将会被切分成如下所示。

    Function
    function
    Identifier
    hello
    LeftParen
    (
    RightParen
    )
    LeftCurly
    {
    Identifier
    console
    Dot
    .
    Identifier
    log
    LeftParen
    (
    String
    hello, world!
    RightParen
    )
    RightCurly
    }

代码的切分

在遍历代码的过程中为了区分不同类型的 tonken,可以按照这个通用流程。

  1. 检查当前字符类型;
  2. 如果是空白符,当前遍历索引 +1,跳到下一个循环,否则进入对应 token 类型的处理函数;
  3. 将对应函数处理后的结果 push 到 tokens;
  4. 遍历完所有字符,返回 tokens。
0/45
Starting 🔍

f

u

n

c

t

i

o

n

h

e

l

l

o

(

)

{

\n

c

o

n

s

o

l

e

.

l

o

g

(

'

h

e

l

l

o

!

'

)

\n

}

Keyword
function
KnownCharacters
(
)
{
}
.
;

    在遍历完所有字符后,我们会获得一个 tokens 列表。

    总结

    在编译器的解析阶段,目标是将代码字符串转换成令牌 (token) 列表。编译器通过逐个字符遍历代码字符串,根据字符类型生成对应的令牌。代码中的标识符、关键字、符号都可以视为令牌,拥有不同的类型。主要的解析流程是检查字符类型,调用令牌处理函数,生成令牌列表。解析阶段消除了代码的歧义,将代码转换成了机器可读的令牌列表,为编译器的后续处理打下基础。理解并实现一个简单的 token 解析器,可以帮助我们深入掌握编译器的工作原理(由 gpt 总结)。

    参考