深入浅出JSON.parse的实现方法
前言
众所周知,JSON.parse
方法用于将一个json
字符串转换成由字符串描述的 JavaScript 值或对象,该方法支持传入2个参数,第一个参数就是需要被转换的json
字符串,第二个参数则是一个转换器函数(reviver,也叫还原函数),这个函数会针对每个键/值对都调用一次,这个转换器函数又接受2个参数,第一个参数为转换的每一个属性名,第二个参数则为转换的每一个属性值,并且该函数需要返回一个值,如果返回的是undefined,则结果中就会删除相应的键,如果返回了其他任何值,则该值就会成为相应键的值插入到结果中。
对于转换器函数更具体点讲就是:解析值本身以及它所包含的所有属性,会按照一定的顺序(从最最里层的属性开始,一级级往外,最终到达顶层,也就是解析值本身)分别的去调用 reviver
函数,在调用过程中,当前属性所属的对象会作为 this
值,当前属性名和属性值会分别作为第一个和第二个参数传入 reviver
中。如果 reviver
返回 undefined
,则当前属性会从所属对象中删除,如果返回了其他值,则返回的值会成为当前属性新的属性值。
当遍历到最顶层的值(解析值)时,传入 reviver
函数的参数会是空字符串 ""
(因为此时已经没有真正的属性)和当前的解析值(有可能已经被修改过了),当前的 this
值会是 {"": 修改过的解析值}
,在编写 reviver
函数时,要注意到这个特例。(这个函数的遍历顺序依照:从最内层开始,按照层级顺序,依次向外遍历)。
我们来看以下几个示例:
const bool = JSON.parse('true'); // true const obj = JSON.parse('{"k":1,"v":2}'); // { k:1 ,v: 2} const obj2 = JSON.parse('{"k":1,"v":2}',(k,v) => { if(k === 'k'){ return v + 2; } return v; }); // { k:3 } const obj3 = JSON.parse('{"k":1,"v":2}',(k,v) => { if(k === 'k'){ return v + 2; } // 尤其需要注意这个特例 if(k === ""){ return v; } return v + 1; }); // { k:3:,v:3 }
实现方法
前面我们已经熟悉了该方法的使用方式,接下来,我们就根据该方法的使用方式来实现这个方法。在实现这个方法之前,我们需要知道一点,那就是想要解析出合格的JSON数据,那么数据格式就必须符合规定,例如:undefined不符合正确格式的数据格式,因此在实现的时候,我们都要将这种情况给考虑进去。
从前面的使用方式,我们不难看出,实际上整个解析过程就是在对整个json字符串进行遍历,而在遍历过程中我们就需要针对不同的数据类型做不同的处理,例如如果是解析字符串,我们只需要创建一个空字符串,遍历字符串每一个字符,然后将字符拼接起来即可,当然在遍历过程中,我们还需要对一些特殊字符或者符号进行处理。
理解了整体的思路,接下来,我们就来一步一步的实现这个方法吧。
创建一个自调用函数
我们采用的是创建一个自调用函数,并且这个函数返回一个函数,而在这个返回的函数当中,我们会提供2个参数,正如前面所介绍的那样,这2个参数分别是被解析的json字符串和转换器函数,命名为source与reviver,代码如下所示:
const jsonParser = (() => { // ... return (source,reviver) => { // ... } })();
这个函数内部,我们将会定义一个变量result,用来存储最终的解析结果,并且我们会根据第二个参数reviver是否是一个函数来确定是直接返回这个结果还是返回reviver转换器函数,这个转换器函数也是一个自调用函数,由于我们的json数据可能是嵌套的对象或者数组,因此这里我们也需要定义一个函数名,方便递归调用。
一些变量的定义
这里的实现我们最后来说,接下来我们需要先定义一些变量,比如当前字符索引值,当前字符,一些特殊字符的定义,以及需要一个变量来缓存原始json字符串,同样的如果在解析字符串时出现不符合规定的字符,则需要提示错误,因此我们也会封装一个error函数,如下所示:
const jsonParser = (() => { let at, // 当前字符索引值 ch, // 当前遍历字符 text, // 缓存原始json字符串 escapee = { '"': '"', '\\': '\\', '/': '/', b: 'b', f: '\f', n: '\n', r: '\r', t: '\t' }, // 特殊字符 error = m => { const errorObj = { name: 'SyntaxError', message: m, at, text }; console.error(`${JSON.stringify(errorObj)}`); // 控制台打印错误 // 或者使用throw抛出错误,即 // throw errorObj; }, // ... return (source,reviver) => { // ... } })();
next方法
接下来,我们还需要实现一个next方法,这个方法,在这个方法中,我们会依次的去读取字符串的每一个字符,并将索引值加1,读取字符我们可以使用String.charAt
方法,该方法就是读取字符串的每一个字符。如:
const str = "hello"; str.charAt(0); // h
需要注意的就是该方法支持传入一个参数,并且如果传入的参数,不等于我们的字符ch,则需要抛出一个两者不相等的错误。
有了以上的分析,我们的next方法就很好实现了,如下所示:
const jsonParser = (() => { let at, //... next = c => { // 如果有传入参数,并且该参数值不等于当前字符,则需要给出错误提示 if(c && c !== ch){ error(`预期${c}代替${ch}`); } // 根据索引值读取当前字符 ch = text.charAt(at); at++; // 索引值加1 //返回当前字符 return ch; }, //... return (source,reviver) => { // ... } })();
依据数据类型解析
接下来,我们就要根据当前解析的json字符串属于哪一种数据类型而依次去解析了,数据类型主要分为数值number,字符串string,布尔值boolean和null,以及对象object和数组,当然还有空格字符。
也许有人好奇为什么会没有undefined类型,我们来看如下图所示:
如上图所示,JSON.parse
是不能够解析undefined的,当然如果"undefined"字符串是作为一个对象的属性值,还是可以被解析出来的,如果是undefined作为属性值,是不会被解析出来的。如:
JSON.parse('{"a":"undefined"}'); // { a:"undefined" } JSON.parse('{"a":undefined}'); // Unexpected token 'u', "{"a":undefined}" is not valid JSON // 数组解析同理
布尔值,null与空白字符的解析
我们先来看最简单的两种数据类型的解析,由于布尔值和null两者解析过程相似,因此归为一类定义一个方法来解析,空白字符只需要跳过即可。代码如下所示:
const jsonParser = (() => { let at, //... white = () => { // 注意这里为什么是使用<=而非==,因为还有类似\n这样的空白符 while(ch && ch <= ' '){ next(); } }, word = () => { 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(`意料之外的值:${ch}`); }, //... return (source,reviver) => { // ... } })();
可以看到解析布尔值和null,我们只需要根据首字符是否为该类型数据的首字母即可判定解析,然后将结果返回出去即可,如果不满足条件,则需要报错。
数值的解析
接下来我们来看数值类型数据的解析,数值类型我们需要考虑四种情况,第一种就是正负号的解析,第二种则是e字母的解析(即科学计数法),第三种则是小数点'.'的解析,最后一种则是数字的解析。在该方法内部,我们将创建2个变量,因为虽然是数值数据,但是我们是一个字符一个字符的解析,而非做计算,因此就需要拼接字符串,不过拼接完之后的字符串我们需要转换成数字,这两个变量就做这2个工作的。
首先我们需要判断是否为负号,从而直接拼接,然后继续下一个字符,下一个字符我们需要将负号当做参数传给next方法,接着我们循环当前字符是否是数字,如何判断是否是数字呢?我们只需要比较是否大于等于0并且小于等于9即可,注意这里我们比较的是字符串的码序,而不是单纯的比数字大小来判定是否是数字。即:
const isNumber = v => v >= '0' && v <= '9';
拼接数字完成之后,我们还要继续调用next方法进行下一步,注意这里调用不需要传任何参数。
完成数字的拼接之后,我们接着判断是否是小数点从而继续拼接,小数点之后会继续是数字,因此我们还要继续循环数字从而继续拼接。
最后一步就是判断当前字符是否是e字母,注意e字母不区分大小写,因此需要两个判断条件,e字母后面也有可能有正负号,因此也需要判断是否是正负号,正负号后面还会有数字,也需要继续拼接,从而调用next方法进行下一步。
最后把拼接后的字符串利用加号操作符转换成数值,从而得到最终的结果,最终的结果有可能是一个NaN,因此我们还需要判断一下是否是NaN,如果是NaN,则给出一个错误提示,否则直接返回最终的结果。
根据以上的分析,最终我们的number转换方法如下所示:
const jsonParser = (() => { let at, //... number = () => { let number,string = ''; // 定义number存储最终转换成数字的结果,定义string变量拼接字符串 // 符号的拼接,+号通常是不会写的,因此不需要判断 if(ch === '-'){ string += ch; next('-'); } // 循环数字 while(ch >= '0' && ch <= '9'){ string += ch; next(); } //小数点 if(ch === '.'){ string += ch; while(next() && ch >= '0' && ch <= '9'){ string += ch; next(); } } //科学计数法 if(ch === 'e' || ch === 'E'){ string += ch; next(); // 科学计数法e字母后还有可能是正负号 if(ch === '-' || ch === '+'){ string += ch; next(); } // 科学计数法e字母之后的数字 while(ch >= '0' && ch <= '9'){ string += ch; next(); } } // 转换成数值赋值给number变量 number = +string; //判断是否是NaN if(isNaN(number)){ error('错误的数值'); }else{ return number; } }, //... return (source,reviver) => { // ... } })();
字符串的解析
数值类型解析完成,接下来我们来看字符串的解析,字符串的解析也是需要分情况的,首先是Unicode字符,即以u字母开头的字符,最准确的说应该是类似这样的unicode字符串'\u2233'的解析。遇到这样的字符,我们会使用String.fromCharCode
方法转换成普通的字符串,这里的转换也涉及到了一个转换公式原理,我们会使用parseInt将其转换成16进制的数值,然后将该数字乘以16,并相加,初始结果为0,我们会定义一个变量uChar来用作计算后的结果。
首先第一步,我们知道字符串以"
开头,因此首先我们需要判断是否是"
,最开始我们也需要定义4个变量,即hex,i,uChar,string,其中hex用来存储parseInt转换成16进制后的结果,uChar用来存储最终的转换结果,i就是循环变量,string则是最终拼接出来的结果。
判断完成之后,我们将依次循环下一个字符,在循环当中,如果遇到另一个"
,则代表字符串已经拼接完成,直接返回string结果,并退出循环,否则遇到当前字符是"\\"
,则需要将unicode字符进行转换,首先还是调用next方法跳过该字符,然后判断是否是u字母或者我们定义好的escapee中的特殊字符,如果两者都不是,则需要跳出循环,最后将String.fromCharCode
方法转换uChar
的结果值拼接给结果变量string。
这其中额外需要注意的就是Unicode字符的计算,我们会以4为循环最终条件,去计算,并且我们在循环当中还会判断是否是一个有限的数值,从而决定是否跳出该循环。
否则就是直接字符串拼接直到循环完成,如果不满足相应的条件,我们最终也会给出错误提示。根据以上分析,最终我们拼接字符串的代码如下所示:
const jsonParser = (() => { let at, //... string = () => { let hex,i,string,uChar; if(ch === '"'){ // 从下一个字符开始循环 while(next()){ if(ch === '"'){ // 如果是另一个双引号,则是字符串的结束 next(); return string; }else if(ch === '\\'){ // 如果是Unicode字符 next(); // 如果当前字符是u字母 if(ch === 'u'){ uChar = 0; for(i = 0;i < 4;i++){ // 转换成16进制数 hex = parseInt(next(),16); // 如果hex不是一个有限数值,则跳出循环 if(!isFinite(hex)){ break; } // 计算uChar uChar = uChar * 16 + hex; } }else if(typeof escapee[ch] === 'string'){ // 如果是特殊字符,则直接拼接 string += escapee[ch]; }else{ // 跳出循环 break; } // 拼接最终结果 string += String.fromCharCode(uChar); }else{ // 否则当成普通字符拼接 string += ch; } } } // 如果当前字符不是"开头,则是一个错误的字符串 error('错误的字符串'); }, //... return (source,reviver) => { // ... } })();
数组的解析
字符串和数值以及布尔值还有null都解析完了,接下来就是数组和对象的解析了,我们先来看数组的解析。数组一定是以"["
开头的,而它里面的值有可能是字符串,或者数组或者对象等,因此在这之前我们需要先定义一个值变量value用来存储这种不可推测的值,如下所示:
const jsonParser = (() => { let at, //... value, //... return (source,reviver) => { // ... } })();
数组的解析也不复杂,我们还是会定义一个array变量用来缓存最终的结果,接着判断是否以[
开头,如果是就继续下一个字符,并且有可能该字符后面有空白,因此我们需要调用white方法,紧接着我们判断下一个字符是否是]
,如果是,就代表数组解析已结束,直接返回array结果。
否则循环当前字符,并将值(也就是我们定义的value变量)添加到array中,然后再调用一次white方法跳过空白字符,紧接着判断是否是]
字符,如果是就继续下一个字符的遍历,并返回结果,否则将逗号当做参数传给next方法,当做下一个字符的遍历,然后再调用一次white方法跳过空白字符。
否则最后我们就给出一个错误提示,错误的数组。根据以上的分析,最终可得代码如下所示:
const jsonParser = (() => { let at, //... value, array = () => { const array = []; // [开头则继续下一个字符,并跳过空白 if(ch === '['){ next('['); white(); } // ]则解析结束,返回结果 if(ch === ']'){ next(']'); return array; } // 循环字符 while(ch){ array.push(value()); white(); // ]则结束解析 if(ch === ']'){ next(']'); return array; } // 跳过逗号字符的解析 next(','); white(); } // 错误的数组数据 error('错误的数组'); } //... return (source,reviver) => { // ... } })();
对象的解析
对象的解析与数组的解析有些类似,不过对象需要考虑属性名和属性值,属性名实际上就是对字符串的解析,而属性值则与数组项一样,是不可推测的value值,遇到:
字符,我们也需要跳过,并解析下一个字符。
中间可能也会有空白字符,因此需要跳过,我们会创建2个变量,第一个变量用于缓存属性名,第二个变量则是存储结果值,我们知道对象是"{"
开始,"}"
结束的,除了这些需要注意的地方,其它就和解析数组一样差不多了。
根据以上的分析,我们最终的代码如下所示:
const jsonParser = (() => { let at, //... value, object = () => { let key,object = {}; // 存储属性名和结果所定义的变量 if(ch === '{'){ // 跳过{字符解析下一个字符 next('{'); // 可能存在空白字符 white(); // 如果是}字符,则结束解析,并返回结果 if(ch === '}'){ next('}'); return object; } // 循环字符 while(ch){ // 属性名即解析字符串 key = string(); // 可能存在空白字符,跳过 white(); // 跳过:字符 next(':'); // value值是一个函数,下文会介绍 object[key] = value(); // 跳过空白 white(); // 如果是},则解析结束 if(ch === '}'){ next('}'); return object; } // 跳过,字符解析下一个字符 next(','); // 可能存在空白字符,跳过 white(); } } // 如果不是以{开头,则对象格式不符合,抛出错误 error('错误的对象'); }, //... return (source,reviver) => { // ... } })();
不可推测的值
前文也提到了不可推测的值value,它可以是数组,对象,字符串,数值,布尔值,null等其中的一个,因此该值我们定义成一个函数,并根据当前字符以什么开头来确定数据类型,从而决定使用哪个方法解析,比如是字符串,就会以"
开头,从而调用前面实现的string方法进行解析,如果是数组对象等同理,默认当然是以数值和布尔值以及null解析为主。
当然最开始可能也会有空白字符,需要跳过,根据以上的分析,value函数最终代码如下所示:
const jsonParser = (() => { let at, //... value, //...定义完object方法之后再赋值value value = () => { // 可能存在空白字符,跳过 white(); // 判断以什么字符开头 switch(ch){ case '{': return object(); // 对象解析 case '[': return array(); // 数组解析 case '"': return string(); // 字符串解析 case '-': return number(); // 数值解析 default: return ch >= '0' && ch <= '9' ? number() : word(); // 如果是数字则当做是数值解析,否则当做布尔值或null解析 } }; return (source,reviver) => { // ... } })();
返回结果:有转换器函数与无转换器函数
最后我们来看返回的函数的实现原理,首先我们创建了4个变量,result,text = source,at = 0,ch = ' ',分别代表最终的解析结果,原始json字符串,起始解析索引值,从0开始,起始解析字符,从空白字符开始。
接着调用value方法解析值,并赋值给结果变量result,然后调用white方法跳过空白字符,跳过空白字符之后,如果还存在字符未解析,就代表解析数据不是一个合格的json字符串,则给出错误提示。
最后函数结果返回2个结果,第一个结果就是如果传入了转换器函数,则返回一个自调用函数,否则返回result。如下所示:
const jsonParser = (() => { // ... return (source,reviver) => { // 解析结果,原始字符串,起始解析索引值,起始解析字符 let result,text = source,at = 0,ch = ' '; // 解析值并赋值 result = value(); // 跳过空白字符 white(); // 如果还存在解析字符,则数据不符合json规范,给出错误 if(ch){ error('解析语法错误,不是一个合格的json数据'); } // 返回 return typeof reviver === 'function' ? (function walk(holder,key){ // ... })({ '':result },'') : result; } })();
转换器内部的实现原理
还记得前面有一个这样的示例,如下所示:
const obj3 = JSON.parse('{"k":1,"v":2}',(k,v) => { if(k === 'k'){ return v + 2; } // 尤其需要注意这个特例 if(k === ""){ return v; } return v + 1; }); // { k:3:,v:3 }
从以上特例,我们可以得知最开始会以一个空属性名作为遍历的开始,这也是为什么我们的自调用函数的第一个参数值是{ '':result }
的原因,第二个参数也是以空属性名作为遍历开始的。
在递归函数walk内部,我们会定义3个变量,即循环属性名k,缓存的属性值v,和起始属性值value = holder[key]
。起始属性值实际上就是原始解析结果开始,如果该值是一个对象,我们则需要遍历该对象,如果我们的循环属性名k是该对象的属性,则递归的赋值缓存属性值,然后判断属性值如果是undefined,则从对象中删除该属性,否则修改该属性值,最终我们会返回调用转换器函数的结果。
根据以上代码分析,我们最终转换器函数内部实现原理代码如下所示:
const jsonParser = (() => { // ... return (source,reviver) => { // ... return typeof reviver === 'function' ? (function walk(holder,key){ // 循环属性名,缓存属性值,读取值 let k,v,value = holder[key]; // 如果值是对象,则需要继续解析 if(value && typeof value === 'object'){ // 循环对象属性值 for(k in value){ // 如果value中存在该属性 if(Object.hasOwnProperty.call(value,k)){ // 继续递归 v = walk(value,k); // 如果属性值不是undefined则修改属性值,否则删除该属性 if(v !== undefined){ value[k] = v; }else{ delete value[k]; } } } } // 返回转换器函数调用的结果 return reviver.call(holder,key,value); })({ '':result },'') : result; } })();
最终
将以上代码整合起来,得到了我们的parse解析方法的实现,以上源码可以查看这里。
以上就是深入浅出JSON.parse的实现方法的详细内容,更多关于JSON.parse方法的资料请关注脚本之家其它相关文章!
相关文章
webpack打包后index.html引用文件地址问题小结
在前端开发中,src 属性指定的相对路径是相对于当前 HTML 文件的路径,而不是相对于网站的根目录,这篇文章主要介绍了webpack打包后index.html引用文件地址问题,需要的朋友可以参考下2024-05-05通过JS 获取Mouse Position(鼠标坐标)的代码
最近我发现在webpage中获取空间的绝对坐标时,如果有滚动条就会有错,后来用无名发现的方法得以解决。2009-09-09
最新评论