博客 / 詳情

返回

Generator實踐:利用 Generator 和 Fetch 對 json 數據流 stream 進行邊下載邊解析

利用 Generator 和 Fetch 對 json 數據流 stream 進行邊下載邊解析

js在es6 之後,提供了 Generator 函數,可以自由控制函數的執行過程,可以在函數內部暫停執行,也可以在外部恢復執行。
這種函數最大的特點就是:對於狀態機控制可以用非常簡單明瞭的語句,來表達複雜的邏輯。
但是數年中少有實際用到 Generator 函數的實踐。本文就是一個實用的實踐,下面筆者講介紹一下如何利用 Generator 暫停json的解析過程,來實現邊下載邊解析的功能。能讓前端頁面不必等到json全部拿到後再解析渲染,如渲染一個大量數據的場景/圖表等地方,提升用户體驗。
當然筆者對 Generator 的理解也不是特別深,運用不是特別熟練,如果有不對的地方,歡迎指正。這裏我也只是拋磚引玉。

另外再簡單介紹一些的背景知識:
為啥要用Fetch?xhr不行嗎? 我們知道http是基於tcp的,在發送網絡包的時候並不是一次性把內容都發送出去,而是先切割成小塊,然後一塊一塊的發送出去。
我們如果能直接拿到每一個數據包的內容,我們就能實現邊下載邊解析的功能了。
以前的xhr並沒有給開發者提供這個功能。而Fetch就提供了這個功能,Fetch 的 Response 對象提供了一個body屬性,這個屬性是一個可讀的流,我們可以通過這個流來獲取到每一個數據包的內容。

接下來就是如何實現這個功能了。

先説一下最終所要的實現:

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,它裏面的內容並不是隨機的格式,而是一個大數組,裏面有一個個的對象。每個對象都會對應渲染一個組件,或者其他形式。
我們想要來讓解析器在解析到特定路徑下的內容後,再執行回調。比如後端返回如下格式:

{
  "data": [
    {
      "name": "a",
      "age": 1
    },
    {
      "name": "b",
      "age": 2
    },
    {
      "name": "c",
      "age": 3
    },
    ...
  ]
}

我們想要得到回調內容是, 每個對象都有完整的name和age

{
  "data": [
    {
      "name": "a",
      "age": 1
    },
  ]
}

而不是,下面回調中只有name,age在下一次回調中才能完成

{
  "data": [
    {
      "name": "a"
    },
  ]
}

實現這種效果,我們需要維護一個當前正在解析的路徑的一個棧,當解析對象的對應的 key 的時候,我們將這個key放入棧中。
如果遇到了數組,因為數組的key是數字,但每個key對我們來説都是平等的,所以我們暫定用一個特定的字符串[]來表示,當解析到數組的時候,我們將[]放入棧中。
當解析完數組的單個值後,我們判斷是不是路徑和要求的相同,進而判斷是否執行回調。這裏我們也可以用Symbol來代替[],避免和json中的key衝突。

其他的功能實現,這裏不再詳述了,有興趣的同學可以看一下源碼的實現。
目前已經發布到了npm上。

demo效果:

這裏我將網速設置為3Mb/s,完整下載此json,需要1s左右。

普通請求:

steam請求:

可以看到普通請求,頁面會等到json完全加載完成後,才開始渲染數據。有較長的白屏時間。
steam請求下,頁面會邊下邊解析。數據逐步加載的,幾乎沒有白屏時間。

其他問題:

  1. 會不會出現網絡包傳輸速度比js解析更快的情況出現,也就是會不會js還沒解析完這個網絡包,下一個網絡包就到了,然後又調用 next() ?
    理論上是不會的,js解析過程是同步代碼,只有執行完當前的代碼,才會執行下一個異步隊列中的代碼(也就是下一次網絡包的執行回調),所以不會出現這種情況。
    另外經過測試,js的解析速度是GB/s級別的,而網絡包的傳輸速度是MB/s級別的,遠遠高於以太網的傳輸速度。當然後續也可以用 wasm 來實現,進一步提升解析速度。
其他類似解決方案:

可以利用 EventSource 方式,讓服務端把數據分割,再分批推送給前端。

github:

https://github.com/maotong06/stream-json-parse

參考資料

  1. https://github.com/sidorares/json-bigint
  2. https://es6.ruanyifeng.com/#docs/Generator
  3. https://developer.mozilla.org/zh-CN/docs/Web/API/Streams_API/...
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.