前言
在学习了一款 JavaScript 解释器的项目过后,想自己写一个 JavaScript 解释器:zuluoaaa/makeJs: A sub Javascript interpreter for interpreting itself (github.com)
在数据结构和分析逻辑上面很大程度参考了以上项目,不过我在原项目的基础上进行了魔改并添加了新的功能
PS:本项目只是简单的模拟了 JavaScript 解释器的执行过程,并没有涉及 JavaScript 的灵魂(一切皆对象)
词法分析
词法分析核心实现
词法分析其实就是一个线性扫描加上正则匹配的过程,核心函数如下:
1 | function scan(){ |
- 一些简单的符号可以直接匹配,而字符串和数字则需要借助正则表达式
次步骤会将程序简单转化为 token 流,并记录于 lexList
列表中:
1 | function add(a,b){ |
- 此时 token 就只是标识符,按照顺序排布,没有任何语法上的含义
1 | [ |
语法分析
各种语句的处理
需要处理的语句有如下几种:
1 | function statement(){ |
glue结点的使用
在语法分析生成 AST 的过程中,可能会遇到一些没有关键的并列语句:
1 | var a = 1; |
这些语句看似是并列结构,但解释器往往会将其理解成有着父子结点关系的树形结构:
1 | glue |
- 解释器会先读取
var a = 1
进而生成a
节点 - 接着读取
var b = 2
生成b
节点,然后创建glue
节点作为a b
的父节点
打印出的 AST 信息如下:
1 | || [_glue] (null),(null) |
列表结点的使用
在语法分析生成 AST 的过程中,可能会遇到没法简单处理成树形结构的数据:
1 | function add(a,b,c,d,e){ |
- 由于
add(1,2,3,4,5)
中的参数必须分析为并列结构,因此这里需要使用列表来存储参数节点 - 对于函数参数和列表都需要这种处理方式
打印出的 AST 信息如下:
1 | || [_glue] (null),(null) |
普拉特分析法处理表达式
Pratt Parsing 是一种循环与递归相结合的算法,需要对以下两种表达式进行处理:
- 前缀表达式:一个操作符放在数字的前面(
-a
,!a
,"a"
,[a]
……) - 中缀表达式:操作符在两个操作数的中间(
a + b
,a * c
……)
1 | function prefix(type){ /* 处理前缀 */ |
- 前缀表达式的优先级往往是最高的
- 这里的函数调用是一个例外:原本函数调用属于前缀表达式,但本程序在识别 token 时会将函数名识别为
identifier
,将左括号识别为符号,将参数识别为identifier
,这样函数调用就被当成了中缀表达式
下面看两个例子:
1 | var c = 7*8+9; |
+
优先级小于*
,因此7 * 8
先回溯生成树,接着正常处理后续表达式生成exp + 9
的树
1 | var c = 7*(8+9); |
()
优先级小于*
,因此继续处理直到表达式结束后回溯,依次生成8 + 9
和7 * exp
的树
打印树形结构
基础版:
1 | function printTree(ast,index) { |
优化版:
1 | let printTree = function (ast, pre=[" ──"]) { |
优化版效果图:
1 | function add(a,b){ |
1 | ──[_glue] (null) |
语义分析
处理函数调用
当遇到函数调用时,可能有如下3种情况:
- 该函数是解释器内置的
- 该函数是源程序定义的
- 该函数没有定义
如果该函数是解释器内置的,解释器会尝试在 buildInMethods
中查找该函数:
1 | if(buildInMethods[astNode.value]){ |
如果该函数是源程序定义的,解释器需要知道该函数的具体实现:
1 | case ASTNodeTypes.T_FUNCALL: |
在 interpretFunCallAST
会设置 fnAST.option = "run"
并再次调用 interpretAST
:
1 | childScope.set("arguments",argument,ASTNodeTypes.T_ARGUMENT); |
再次调用 interpretAST
会进入 ASTNodeTypes.T_FUN
分支,并尝试解析该函数:
1 | case ASTNodeTypes.T_FUN: |
- 若条件
astNode.option === "run"
成立,则代表了解释器遇到了函数调用,需要知道该函数的具体实现 - 否则代表解释器遇到了一个新的函数,并将该函数记录在了
scope
中
后续的操作和处理 block 是相同的,整个过程的结果将会记录在 Scope 中并参与后续的解析
内置函数的设计
这里我一共写了3个内置函数:
1 | function logIn(...argument){ /* 等同于console.log */ |
效果如下:
1 | function add(a,b){ |
1 | ──[function] (add) |