
接口返回一段嵌套 JSON,解析的那一刻顺手把字符串两头的空格去掉,顺便校验一下业务码。很自然的一种写法:
const data = JSON.parse(text, function (key, value) {
if (key === 'code' && value !== '200') throw new Error('业务码不对');
return typeof value === 'string' ? value.trim() : value;
});
这段代码有两处会出意外,都跟 reviver 的调用时机有关。下面每个数字都是在本机跑出来的,输入统一用这一小段:
const text = '{"id":7,"user":{"name":"x","age":30},"tags":["a","b"]}';
先子后父,根那一层最后到
reviver 里只打印 key 和 value,八个节点的到达次序是:
== 1. reviver 被调用的顺序 ==
1. key=id value=7
2. key=name value="x"
3. key=age value=30
4. key=user value={对象}
5. key=0 value="a"
6. key=1 value="b"
7. key=tags value=[a b]
8. key=<根> value={对象}
id、name、age 先在叶子层跑完,然后才是包着它们的 user;数组的 0、1 跑完,才是 tags;最后一个是根,key 是空串。规范里的做法是递归下降:处理一个对象时,先对它的每个自有键递进去拿到结果,再调用 reviver。所以 reviver 收到 value 的时候,它下面那棵子树已经全部处理完了。
想校验整棵树,就只能在根那一次做,那时每个节点都已经被 reviver 的返回值替换过。
key 是字符串,数字键也是
值为 {"0":"zero","a":"A"} 的对象,三次调用拿到的 key:
== 3. key 是字符串,数字键也不例外 ==
key = "0" typeof key = string
key = "a" typeof key = string
key = "" typeof key = string
三次都是字符串。解析出来的对象键是 "0",不是数字 0,所以判断用 key === '0' 还是 key === 0 在 JSON 场景里没有第二种选择。根那一次的 key 是空串,不少框架就是靠 key === '' 判断「最外层到了」。
这一层的 this 是持有它的对象
同一个案例里,reviver 内部的 this 指向正在被处理的那个对象,不是全局对象:
== 5. 这一层的 this 指向哪个对象 ==
key=id this 的键 = [id user tags]
key=name this 的键 = [name age]
key=age this 的键 = [name age]
key=user this 的键 = [id user tags]
key=0 this 的键 = [0 1]
key=1 this 的键 = [0 1]
key=tags this 的键 = [id user tags]
key=<根> this 的键 = []
叶子层的 this 是 {name, age},tags 那两个元素的 this 是数组本身。用普通函数写法时这个上下文可用,换成箭头函数就没有了,得靠闭包传参。
返回 undefined 在三个位置有三种结果
这是最容易写错的一处。返回 undefined 不等于「保留一个 undefined 值」:
== 4. 返回 undefined 会怎样 ==
对象属性:{"id":7,"user":{"name":"x"},"tags":["a","b"]}
数组元素:JSON.stringify 得到 [1,null,3],length = 3,下标 1 还在吗 = false
数组元素:下标 1 的值 = undefined
根上返回 undefined:undefined
对象属性上返回 undefined,属性被删掉,结果里没有 age 这个键。数组元素上返回 undefined,元素变成空槽,length 还是 3,1 in arr 是 false,JSON.stringify 之后那一格变成 null。根上返回 undefined,整个 JSON.parse 的返回值就是 undefined。
用 reviver 过滤字段的人常在这条上翻车:想让数组少一个元素,写 return undefined,拿到的是一个 null 占位,下游按数组长度算偏移的地方就跟着错位。
调用次数跟节点数挂钩
对小对象来说 reviver 的开销可以忽略,数据规模上去就不一样了:
== 6. 一个 key 调用几次 ==
4 个节点的输入,reviver 调用 4 次
1000 个元素的数组,reviver 调用 1001 次
4 个节点的输入调用 4 次,1000 个元素的数组调用 1001 次,多出来的那次是根。这个次数是按节点数走的,跟 JSON 文本多长没关系,所以一个几十 KB 的浅数组和一棵同样大小的深树,调用次数差别很大。
顺序带来的后果
把 trim 和整体校验写进同一个 reviver,根那一次看到的就是已经改过的数据:
== 7. 顺序带来的一个实际后果:到根上校验时,子节点已经被改过 ==
原始文本 {"code":" 200 ","msg":" ok "}
根这一层拿到的是 {"code":"200","msg":"ok"},校验和就地修改只能挑一个
不在 reviver 里改,交给外面处理:{"code":" 200 ","msg":" ok "}
外面再改一次的结果:{"code":"200","msg":"ok"}
原始文本里 code 是 " 200 ",带空格;到根这一层,它已经变成 "200"。如果校验的规则是「字段不该有多余空白,发现就报错」,这条规则永远不会触发,因为违规的值在到达校验点之前就被修掉了。
常见的坑就在这里:同一个 reviver 里既做清洗又做校验,清洗总是先赢。
适合和不适合
reviver 的强项是类型还原和结构改写。把字符串还原成日期,把下划线命名的键改成驼峰,或者拆掉一层 { data: ... } 包装,这些都是一遍递归就能做完的事,比自己写递归函数稳。
不适合的是三件事:大范围的格式校验,此时的值已经不是原文里那个;按数组下标做增删,返回值语义跟想象的不一样;把大数组原样过一遍 reviver,调用次数按节点数走。
我的取舍是:reviver 只做类型还原,一行判断,不写业务规则。校验另起一步,拿到的是最终对象,读起来也清楚。这一条没有性能上的理由,纯粹是前面那个「清洗先赢」的坑踩过之后改的。