比 JSON.stringify 快兩倍的fast-json-stringify

前端南玖發表於2022-12-05

前言

相信大家對JSON.stringify並不陌生,通常在很多場景下都會用到這個API,最常見的就是HTTP請求中的資料傳輸, 因為HTTP 協議是一個文字協議,傳輸的格式都是字串,但我們在程式碼中常常操作的是 JSON 格式的資料,所以我們需要在返回響應資料前將 JSON 資料序列化為字串。但大家是否考慮過使用JSON.stringify可能會帶來效能風險?,或者說有沒有一種更快的stringify方法。

如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新文章~

JSON.stringify的效能瓶頸

由於 JavaScript 是動態語言,它的變數型別只有在執行時才能確定,所以 JSON.stringify 在執行過程中要進行大量的型別判斷,對不同型別的鍵值做不同的處理。由於不能做靜態分析,執行過程中的型別判斷這一步就不可避免,而且還需要一層一層的遞迴,迴圈引用的話還有爆棧的風險。

我們知道,JSON.string的底層有兩個非常重要的步驟:

  • 型別判斷
  • 遞迴遍歷

既然是這樣,我們可以先來對比一下JSON.stringify與普通遍歷的效能,看看型別判斷這一步到底是不是影響JSON.stringify效能的主要原因。

JSON.stringify 與遍歷對比

const obj1 = {}, obj2 = {}
for(let i = 0; i < 1000000; i++) {
    obj1[i] = i
    obj2[i] = i
}

function fn1 () {
    console.time('jsonStringify')
    const res = JSON.stringify(obj1) === JSON.stringify(obj2)
    console.timeEnd('jsonStringify')
}

function fn2 () {
    console.time("for");
    const res = Object.keys(obj1).every((key) => {
        if (obj2[key] || obj2[key] === 0) {
          return true;
        } else {
          return false;
        }
      });
    console.timeEnd("for");
}
fn1()
fn2()


json-1.png

從結果來看,兩者的效能差距在4倍左右,那就證明JSON.string的型別判斷這一步還是非常耗效能的。如果JSON.stringify能夠跳過型別判斷這一步是否對型別判斷有幫助呢?

定製化更快的JSON.stringify

基於上面的猜想,我們可以來嘗試實現一下:

現在我們有下面這個物件

const obj = {
  name: '南玖',
  hobby: 'fe',
  age: 18,
  chinese: true
}

上面這個物件經過JSON.stringify處理後是這樣的:

JSON.stringify(obj)
// {"name":"南玖","hobby":"fe","age":18,"chinese":true}

現在假如我們已經提前知道了這個物件的結構

  • 鍵名不變
  • 鍵值型別不變

這樣的話我們就可以定製一個更快的JSON.stringify方法

function myStringify(obj) {
    return `{"name":"${obj.name}","hobby":"${obj.hobby}","age":${obj.age},"chinese":${obj.chinese}}`
}

console.log(myStringify(obj) === JSON.stringify(obj))  // true

這樣也能夠得到JSON.stringify一樣的效果,前提是你已經知道了這個物件的結構。

事實上,這是許多JSON.stringify加速庫的通用手段:

  • 需要先確定物件的結構資訊

  • 再根據結構資訊,為該種結構的物件建立“定製化”的stringify方法

  • 內部實現依然是這種字串拼接

更快的fast-json-stringify

fast-json-stringify 需要JSON Schema Draft 7輸入來生成快速stringify函式。

這也就是說fast-json-stringify這個庫是用來給我們生成一個定製化的stringily函式,從而來提升stringify的效能。

這個庫的GitHub簡介上寫著比 JSON.stringify() 快 2 倍,其實它的最佳化思路跟我們上面那種方法是一致的,也是一種定製化stringify方法。

語法

const fastJson = require('fast-json-stringify')
const stringify = fastJson(mySchema, {
  schema: { ... },
  ajv: { ... },
  rounding: 'ceil'
})
  • schema: $ref 屬性引用的外部模式。
  • ajv: ajv v8 例項對那些需要ajv.
  • rounding: 設定當integer型別不是整數時如何舍入。
  • largeArrayMechanism:設定應該用於處理大型(預設情況下20000或更多專案)陣列的機制

scheme

這其實就是我們上面所說的定製化物件結構,比如還是這個物件:

const obj = {
  name: '南玖',
  hobby: 'fe',
  age: 18,
  chinese: true
}

它的JSON scheme是這樣的:

{
  type: "object",
  properties: {
    name: {type: "string"},
    hobby: {type: "string"},
    age: {type: "integer"},
    chinese: {type: 'boolean'}
  },
  required: ["name", "hobby", "age", "chinese"]
}

AnyOf 和 OneOf

當然除了這種簡單的型別定義,JSON Schema 還支援一些條件運算,比如欄位型別可能是字串或者數字,可以用 oneOf 關鍵字:

"oneOf": [
  {
    "type": "string"
  },
  {
    "type": "number"
  }
]

fast-json-stringify支援JSON 模式定義的anyOfoneOf關鍵字。兩者都必須是一組有效的 JSON 模式。不同的模式將按照指定的順序進行測試。stringify在找到匹配項之前必須嘗試的模式越多,速度就越慢。

anyOfoneOf使用ajv作為 JSON 模式驗證器來查詢與資料匹配的模式。這對效能有影響——只有在萬不得已時才使用它。

關於 JSON Schema 的完整定義,可以參考 Ajv 的文件,Ajv 是一個流行的 JSON Schema驗證工具,效能表現也非常出眾。

當我們可以提前確定一個物件的結構時,可以將其定義為一個 Schema,這就相當於提前告訴 stringify 函式,需序列化的物件的資料結構,這樣它就可以不必再在執行時去做型別判斷,這就是這個庫提升效能的關鍵所在。

簡單使用

const fastJson = require('fast-json-stringify')
const stringify = fastJson({
  title: 'myObj',
  type: 'object',
  properties: {
    name: {
      type: 'string'
    },
    hobby: {
      type: 'string'
    },
    age: {
      description: 'Age in years',
      type: 'integer'
    },
    chinese: {
      type: 'boolean'
    }
  }
})

console.log(stringify({
  name: '南玖',
  hobby: 'fe',
  age: 18,
  chinese: true
}))
// 

生成 stringify 函式

fast-json-stringify是跟我們傳入的scheme來定製化生成一個stringily函式,上面我們瞭解了怎麼為我們物件定義一個scheme結構,接下來我們再來了解一下如何生成stringify

這裡有一些工具方法還是值得了解一下的:

const asFunctions = `
function $asAny (i) {
    return JSON.stringify(i)
  }

function $asNull () {
    return 'null'
  }

function $asInteger (i) {
    if (isLong && isLong(i)) {
      return i.toString()
    } else if (typeof i === 'bigint') {
      return i.toString()
    } else if (Number.isInteger(i)) {
      return $asNumber(i)
    } else {
      return $asNumber(parseInteger(i))
    }
  }

function $asNumber (i) {
    const num = Number(i)
    if (isNaN(num)) {
      return 'null'
    } else {
      return '' + num
    }
  }

function $asBoolean (bool) {
    return bool && 'true' || 'false'
  }

  // 省略了一些其他型別......
`

從上面我們可以看到,如果你使用的是 any 型別,它內部依然還是用的 JSON.stringify。 所以我們在用TS進行開發時應避免使用 any 型別,因為如果是基於 TS interface 生成 JSON Schema 的話,使用 any 也會影響到 JSON 序列化的效能。

然後就會根據 scheme 定義的具體內容生成 stringify 函式的具體程式碼。而生成的方式也比較簡單:透過遍歷 scheme,根據不同資料型別呼叫上面不同的工具函式來進行字串拼接。感興趣的同學可以在GitHub上檢視原始碼

總結

事實上fast-json-stringify只是透過靜態的結構資訊將最佳化與分析前置了,透過開發者定義的scheme內容可以提前知道物件的資料結構,然後會生成一個stringify函式供開發者呼叫,該函式內部其實就是做了字串的拼接。

  • 開發者定義 Object 的 JSON scheme
  • stringify 庫根據 scheme 生成對應的模版方法,模版方法裡會對屬性與值進行字串拼接
  • 最後開發者呼叫生成的stringify 方法

最後

原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」,如果你想進前端交流群一起學習,請點這裡

我是南玖,我們下期見!!!

相關文章