js在es6 之后,提供了 Generator 函数,可以自由控制函数的执行过程,可以在函数内部暂停执行,也可以在外部恢复执行。 这种函数最大的特点就是:对于状态机控制可以用非常简单明了的语句,来表达复杂的逻辑。 但是数年中少有实际用到 Generator 函数的实践。本文就是一个实用的实践,下面笔者讲介绍一下如何利用 Generator 暂停json的解析过程,来实现边下载边解析的功能。能让前端页面不必等到json全部拿到后再解析渲染,如渲染一个大量数据的场景/图表等地方,提升用户体验。 当然笔者对 Generator 的理解也不是特别深,运用不是特别熟练,如果有不对的地方,欢迎指正。这里我也只是抛砖引玉。
另外再简单介绍一些的背景知识: 为啥要用Fetch?xhr不行吗? 我们知道http是基于tcp的,在发送网络包的时候并不是一次性把内容都发送出去,而是先切割成小块,然后一块一块的发送出去。 我们如果能直接拿到每一个数据包的内容,我们就能实现边下载边解析的功能了。 以前的xhr并没有给开发者提供这个功能。而Fetch就提供了这个功能,Fetch 的 Response 对象提供了一个body属性,这个属性是一个可读的流,我们可以通过这个流来获取到每一个数据包的内容。
接下来就是如何实现这个功能了。
先说一下最终所要的实现:
js
fetchStreamJson({
// 请求地址
url: './bigJson1.json',
// 解析配置
JSONParseOption: {
// 要求完整解析对应路径下的数据,才能上报(可选)
completeItemPath: ['data', '[]'],
// json解析的回调
jsonCallback: (error, isDone, value) => {
console.log('jsonCallback', error, isDone, value)
}
},
// fetch请求配置,同浏览器 fetch api
fetchOptions: {
method: 'GET',
},
})
核心逻辑其实和以前普通的解析json的逻辑相同,都是逐字读取字符,然后根据字符的不同,做不同的处理。网上也有很多json解析器的教程,这里不再详细介绍。笔者也是借用了一个较为成熟的json解析器 json-bigint,然后在其基础上做了一些修改,来实现边下载边解析的功能。
下面主要来说一下区别点:
1. 如何拿到解析了一部分的数据
原解析器是通过递归的方式来解析json的,如解析到一个 object
类型,此时如果内部的值也是 object
类型,那么就会递归调用解析函数,直到解析到最底层的值。这样实现在一次性完整数据的时候是没问题的。每次解析到 object
类型的都会执行函数,函数的执行栈增加。当解析完当前的 object
,函数也会退出当前的栈,并 return 解析后的对象。当所有递归都执行完,都弹出栈,整个json也解析完成,return 最终结果。 但是我们想json解析到一半的时候,就直接返回当前这一半的数据,就不能这样了。 例如:我们想要解析 { "a": 1, "b": 2 }
递归还是需要的,但是不能依赖函数的返回了,因为执行到一半的时候,我们想拿到一半 { "a": 1 }
,此时函数还没执行完,还没 return,我们就需要通过其他的方式来拿到这一半的数据。 这里是维护了一个当前解析json的变量resJson
。保存当前的解析过程的json,每解析一小步,就修改一次这个对象。 同时也有对应的一个set
函数,设置当前resJson
该如何修改,这个函数是会变的,如:当执行到解析 array
的时候,后面每一个值都是 array
的值,那么就需要把这个值 push
到当前 array
中,我们重置这个 set
函数,修改为 push
新的值到当前的 array
中,这样后面执行完一小步的时候,执行这个 set
函数,即可正确给这个数组加一项。 object
类型的也是同理。 后续我们每完成一小步,都执行set来设置 resJson
而不是return出去。
2. 如何实现暂停和恢复
这里我们需要在对应的位置卡住程序。哪个位置呢,其实就是当前网络包结束的位置。在进行逐字解析的时候,其实也会判断一下每个字符是否合规。如第一个字符是 'f',那么我们预测后面只能是 'a',(因为只能是 false
),如果不是,就会报错。当解析到 fal
的时候当前网络包介绍了,这里我们就可以判断是不是所有网络包都结束了,如果都结束了,就直接执行后面的语句,就会报错了。如果网络包没有都结束,在这个位置yield
卡住程序。当下一个网络包收到的时候,我们调用 next() ,让 Genarator 执行后面的语句。
3. 一个小优化,如何让json解析完特定路径下的内容后,再执行回调
通常情况下,面对非常大的这种json,它里面的内容并不是随机的格式,而是一个大数组,里面有一个个的对象。每个对象都会对应渲染一个组件,或者其他形式。 我们想要来让解析器在解析到特定路径下的内容后,再执行回调。比如后端返回如下格式:
json
{
"data": [
{
"name": "a",
"age": 1
},
{
"name": "b",
"age": 2
},
{
"name": "c",
"age": 3
},
...
]
}
我们想要得到回调内容是, 每个对象都有完整的name和age
json
{
"data": [
{
"name": "a",
"age": 1
},
]
}
而不是,下面回调中只有name,age在下一次回调中才能完成
js
{
"data": [
{
"name": "a"
},
]
}
实现这种效果,我们需要维护一个当前正在解析的路径的一个栈,当解析对象的对应的 key
的时候,我们将这个key
放入栈中。 如果遇到了数组,因为数组的key是数字,但每个key对我们来说都是平等的,所以我们暂定用一个特定的字符串[]
来表示,当解析到数组的时候,我们将[]
放入栈中。 当解析完数组的单个值后,我们判断是不是路径和要求的相同,进而判断是否执行回调。这里我们也可以用Symbol来代替[]
,避免和json中的key冲突。
其他的功能实现,这里不再详述了,有兴趣的同学可以看一下源码的实现。 目前已经发布到了npm上。
demo效果:
这里我将网速设置为3Mb/s,完整下载此json,需要1s左右。
普通请求:
steam请求:
可以看到普通请求,页面会等到json完全加载完成后,才开始渲染数据。有较长的白屏时间。 steam请求下,页面会边下边解析。数据逐步加载的,几乎没有白屏时间。
其他问题:
- 会不会出现网络包传输速度比js解析更快的情况出现,也就是会不会js还没解析完这个网络包,下一个网络包就到了,然后又调用
next()
? 理论上是不会的,js解析过程是同步代码,只有执行完当前的代码,才会执行下一个异步队列中的代码(也就是下一次网络包的执行回调),所以不会出现这种情况。 另外经过测试,js的解析速度是GB/s级别的,而网络包的传输速度是MB/s级别的,远远高于以太网的传输速度。当然后续也可以用 wasm 来实现,进一步提升解析速度。
其他类似解决方案:
可以利用 EventSource 方式,让服务端把数据分割,再分批推送给前端。