自己实现JSON、XML的解析 没那么难

4,596 阅读6分钟

本文的目的,不是针对现有的可用于生产环境的JSON、XML解析器源码进行剖析,而是介绍文本扫描的基础方法next(char),并以此为核心武器,根据目标语言的词法和语法特点,一步步地组织出条例清晰、易维护的解析器代码。希望这会是一篇实践性强,让您有所收获的文章。

另外,这里需要提前说明的是,本文所实现的解析器仅作为coding练习使用。一些目标语言的规范中提到的语法,可能无法正常解析。另外,本文所实现的解析器也缺少大量的实例进行测试。请不要用于生产用途。

前言

作为一个非科班前端程序员,我最近特别痴迷于自学《编译原理》这门课。原因在于,自己大学时代的专业是语言学,其中的理论有颇多相似之处;再加上前端工作中,模版编译成render function,webpack通过loader加载文件等都方面涉及到了编译。我也希望自己能多了解一些编译知识,说不定能在日后的前端工作中能够发挥奇效。

看了一些youtube上的公开课资源,啃了一点龙书这样的编译原理经典作品后,我感觉自己只了解了一堆关于词法法解析、语法解析的理论总结,很难从中获得“学会了”、“会用了”这样的成就感。于是在稍稍有了一点知识基础后,我开始寻找github上关于解析器的源码。

JSON的解析

这里想给大家推荐的是JSON之父,Douglas Crockford的repo: JSON-js中的这个源代码,它也是本文的灵感源泉:

JSON-js/json_parse.js at master · douglascrockford/JSON-js

据代码注释,这个文件实现了JSON.parse方法,使用的解析手段是recursive decending(递归下降分析)。

在同一个repo里还有一个json_parse_state.js文件,也是JSON.parse方法的实现,使用的解析手段是state machine(状态机)。

其实我个人认为上文链接中的源代码使用的解析手段也是state machine,因为recursive decending应该是语法分析使用的方法来着= =。

但从代码的清晰度上来看,json_parse.js要好不少,所以更推荐阅读。

快速地过一遍源码,我们可以发现一个核心函数:

var next = function (c) {

// If a c parameter is provided, verify that it matches the current character.

    if (c && c !== ch) {
        error("Expected '" + c + "' instead of '" + ch + "'");
    }

// Get the next character. When there are no more characters,
// return the empty string.

    ch = text.charAt(at);
    at += 1;
    return ch;
};

这个方法相当于一个字符扫描器,其中使用的全局变量at是当前扫描光标所处位置的索引,ch是当前扫描光标所处位置的字符。调用next方法时,如果传入了参数c(也是一个字符),则会比较此字符与当前扫描器所在的字符,如果不相同就会报错,并且扫描光标不会向前移动;如果未传参数,扫描光标的位置和所指的字符都会向前更新一个位置。

这份代码中的其他函数,充斥着对next的调用,让我们来看几个例子感受一下next的用法。

var word = function () {

    // true, false, or null.

    switch (ch) {
    case "t":
        next("t");
        next("r");
        next("u");
        next("e");
        return true;
    case "f":
        next("f");
        next("a");
        next("l");
        next("s");
        next("e");
        return false;
    case "n":
        next("n");
        next("u");
        next("l");
        next("l");
        return null;
    }
    error("Unexpected '" + ch + "'");
};

word函数用来处理JSON中的三个常量token,即true, falsenull。整个函数根据首字母,分别接收t->r->u->e,f->a->l->s->e,n->u->l->l这样的字符输入。如果其中出现了其他顺序的字符输入,都会抛出Error。word方法还会在匹配token的同时,返回所匹配到的token的值。3个return语句所出现的位置,表示word函数已经接受了这段字符输入,并成功解析出了一个值。

再来看另一个不传参调用next()的例子:

var white = function () {

// Skip whitespace.

    while (ch && ch <= " ") {
        next();
    }
};

white函数的作用仅在于跳过空白,只要当前字符是属于空白的,就不停地调用next()作无条件后跳。

源码中还有number和string函数,其用途和上面的word, white一样,只不过逻辑更为复杂,可以解析出不定长度、不定字符组合的数字和字符串。

一步一步地写出这些“零件”的解析函数后,我们就可以进一步写出一些复合结构的解析函数了,也就是源码中的array和object函数。

最后,源码中实现了可以解析任意一个JSON元素的value函数。从语法的角度讲,这里所定义的value,可以是任何一个string, number, array或object,至此,我们就完成了解析所有JSON元素需要的函数。

以上就是解析的核心代码了,个人认为十分地易于理解并且有明确的分层,易于维护以及以后增加功能。我也在这里用同样的next函数的手法,尝试重写了这个JSON解析器源代码。作为练习,我没有实现escape或revive等功能,但把各个解析函数拆分得更加精细(例如为每个单字符token都写了解析函数,将array拆解为[ + elements + ]等),使得代码更易读。地址是:

18 JSON parser

XML的解析

有了上面的JSON解析器实现的“手感”,我又尝试着用同样的next函数手法,部分地实现了XML的解析。和JSON相比,个人在实现过程中发现的坑点主要在于:

  • JSON对象基本上就是JavaScript中Object对象的字面化表示,所以每次解析出来一小段之后,直接以JavaScript数列或对象的形式保存即可。XML节点需要为其定义类似下面的数据结构,所以代码的复杂度略有增加:
Node {
  tagName //节点标签名
  attrs //节点上的属性,为key/value的数组
  children //节点的子节点,为Node的数组
}
  • XML对象必须作语法分析,也就是close tag有没有匹配的问题。诸如<a><b></a></b>这样的XML需要提示解析错误。不过实现这个也很简单,使用一个nodeStack栈,在opentag时推入节点;在closetag时检查当前节点是否和栈尾的tag相匹配,匹配则推出末尾的节点;在comment节点或text节点时不作处理即可。
  • comment节点的结束判断。comment节点的格式是<!--content-->,因此在解析content部分时,每输入一个字符,需要作3个字符的提前判断。即,如果当前所读到的字符的接下来三个字符分别是-->时,停止解析。

我所实现的XML解析器的代码如下(没有实现self-closing tag的解析功能,例如<br>, <input>等。所有tag必须成对出现):

20 XML parser