slow-json-stringify源碼解析

最近對slow-json-stringify的源碼研究了一下,本文將對源碼中函數的做用進行講解。下文的源碼是2.0.0版本的代碼。git

簡介

JSON.stringify能夠把一個對象轉化爲一個JSON字符串。slow-json-stringify開源庫是對上述功能進行了一個時間上的優化。github

由於JavaScript是動態類型的語言,因此一個對象的屬性值的類型在運行的時候才能肯定,所以執行JSON.stringify會有不少和肯定變量類型相關的工做。正則表達式

那麼,若是咱們事先能夠知道JSON的格式,是否是就能夠縮減一些時間?slow-json-stringify正是基於這個思路去作的。因此,你須要提供一個說明屬性值類型的schema,它會根據schema生成一個單獨的stringify方法。json

image
基本原理就是根據提供的schema,把字符串分割成兩部分,chunksqueue數組

  • chunks裏面用於存放字符串中不變的部分
  • queue存放生成動態屬性值相關的信息

當序列化實際對象的時候,再把這兩部分拼接起來。ide

使用

schema定義
// 咱們須要stringify的對象
var obj = {
    a: 'world', // 字符串類型
    b: 42, // 數字類型
    c: true, // 布爾類型
    d: [ // 數組中每一項都是一樣的結構
        {
            e: 'value1',
            f: 3
        },
        {
            e: 'value2',
            f: 4
        }
    ]
}
var schema = {
    a: attr('string'), // 不是'string',使用了它提供的attr方法
    b: attr('number'), // 不是'number',使用了它提供的attr方法
    c: attr('boolean'), // 不是'boolean',使用了它提供的attr方法
    d: attr('array', sjs({
        e: attr('string'),
        f: attr('number')
    }))
}

var stringify = sjs(schema) // sjs函數針對每個schema生成一個單獨的stringify方法
stringify(obj) // "{"a":"world","b":42,"c":true,"d":[{"e":"value1","f":3},{"e":"value2","f":4}]}"

簡化版本代碼分析

剛開始分析的時候,咱們能夠大體瞭解下每一個函數的功能,不用太考慮各類細節,等咱們把總體流程瞭解完成以後,再看細節部分。
咱們如下面這個最簡單的schema爲例進行講解:函數

var schema = {
    a: attr('string'),
    b: attr('number'),
    c: attr('boolean')
}

在上面使用的時候,咱們發現主要用了兩個函數,attrsjs(slow json stringify的縮寫),咱們先看下attr函數完整版:測試

const attr = (type, serializer) => {
  if (!TYPES.includes(type)) { // 容錯處理,能夠先不考慮
    throw new Error(`Expected one of: "number", "string", "boolean", "null". received "${type}" instead`);
  }

  const usedSerializer = serializer || (value => value); // 自定義每一個屬性的stringify方法,能夠先不考慮

  return {
    isSJS: true,
    type,
    serializer: type === 'array'
      ? _makeArraySerializer(serializer) // 數組類型,作特殊處理,能夠先不考慮
      : usedSerializer,
  };
};

簡化後的版本以下:優化

const attr = (type, serializer) => {
  const usedSerializer = value => value;

  return {
    isSJS: true,
    type,
    serializer: usedSerializer,
  };
};

能夠看到attr接受兩個參數:類型和自定義序列化函數,上述schema實際以下:
image.pngspa

sjs

sjs函數完整版代碼以下:

const sjs = (schema) => {
  const { preparedString, preparedSchema } = _prepare(schema);
  const queue = _makeQueue(preparedSchema, schema);
  const chunks = _makeChunks(preparedString, queue);
  const selectChunk = _select(chunks);
  ...
};

sjs函數用了多個方法,_prepare, _makeQueue, _makeChunks, _select。接下來咱們一一介紹。

_prepare
const _prepare = (schema) => {
  const preparedString = JSON.stringify(schema, (_, value) => {
    if (!value.isSJS) return value;
    return `${value.type}__sjs`;
  });

  const preparedSchema = JSON.parse(preparedString);

  return {
    preparedString,
    preparedSchema, // preparedString對應的json對象
  };
};
_prepare(schema)
// preparedString: "{"a":"string__sjs","b":"number__sjs","c":"boolean__sjs"}"
// preparedSchema: {a:"string__sjs",b:"number__sjs",c:"boolean__sjs"}
// schema: {a:attr('string'),b:attr('number'),c:attr('boolean')}

對比下會發現_prepareattr('type')形式轉化成了type_sjs形式。爲何這麼轉呢?咱們發現只要把preparedString裏面的type_sjs替換成真正的值就能夠了。因此,咱們能夠把prepareString裏面不變的部分和變的部分分開,而後按照順序再把他們拼接起來:不變的部分+變的部分+不變的部分+變的部分+...+不變的部分。因此就有了下面這兩個方法:

  • _makeQueue是把變的部分按照順序提取成一個數組。
  • _makeChunks是把不變的部分按照順序提取成一個數組。
_makeQueue
const _makeQueue = (preparedSchema, originalSchema) => {
  const queue = [];

  (function scoped(obj, acc = []) {
    // 前面_prepare生成的preparedSchema把屬性值變成了type__sjs的形式,因此若是屬性值包含__sjs,咱們能夠認爲這就是變量部分
    if (/__sjs/.test(obj)) {
      const usedAcc = Array.from(acc);
      const find = _find(usedAcc); // 從實際對象中獲取這個變量值的方法,usedAcc是這個屬性數組形式的訪問路徑
      const { serializer } = find(originalSchema); // 從原始schema獲取序列化方法

      queue.push({
        serializer, // 該屬性值序列化的方法
        find, // 從對象中獲取屬性值的方法
        name: acc[acc.length - 1], // 屬性名
      });
      return;
    }

    return Object
      .keys(obj)
      .map(prop => scoped(obj[prop], [...acc, prop]));
  })(preparedSchema);

  return queue;
};
_makeQueue(_prepare(schema).preparedSchema, schema)

image.png

能夠看到find方法是咱們獲取實際屬性值的方法。咱們看下_find函數:

const _find = (path) => {
  const { length } = path;

  let str = 'obj';

  for (let i = 0; i < length; i++) {
    // 簡單的容錯 
    str = str.replace(/^/, '(');
    str += ` || {}).${path[i]}`;
    // 若是不作容錯處理,能夠直接用下面的
    // str += `.${path[i]}`
  }

  return eval(`((obj) => ${str})`);
};

path是對象某個屬性的訪問路徑上的全部屬性名組成的數組,好比對象:

var hello = {
  a: {
    b: {
      c: 'world'
    }
  }
}

屬性值'world'的訪問路徑就是['a', 'b', 'c'],咱們把這個path傳給_find,就會給咱們返回一個使用eval動態生成的函數(obj) => (((obj.a || {}).b || {}).c

若是使用上面我說的不作容錯處理的版本,那麼返回的函數就是(obj) => obj.a.b.c

_makeChunks
const _makeChunks = (str, queue) => str
  .replace(/"\w+__sjs"/gm, type => (/string/.test(type) ? '"__par__"' : '__par__'))
  .split('__par__')
  .map((chunk, index, chunks) => {
    const matchProp = `("${(queue[index] || {}).name}":(\"?))$`;
    const matchWhenLast = `(\,?)${matchProp}`;
    const isLast = /^("}|})/.test(chunks[index + 1] || '');
    const matchPropRe = new RegExp(isLast ? matchWhenLast : matchProp);
    const matchStartRe = /^(\"\,|\,|\")/;

    return {
      flag: false, // 代表前面的屬性值是否是undefined
      pure: chunk,
      prevUndef: chunk.replace(matchStartRe, ''),
      isUndef: chunk.replace(matchPropRe, ''),
      bothUndef: chunk
        .replace(matchStartRe, '')
        .replace(matchPropRe, ''),
    };
  });

上面的咋一看挺複雜,好多正則正則表達式,他們是用來處理屬性值是undefined的狀況。

JSON.stringify轉換成json字符串的過程當中,若是這個屬性值是undefined,這個屬性不會出如今最終的字符串中,以下:

JSON.stringify({a: 'hello', b: undefined}) // "{"a":"hello"}"

咱們能夠先不考慮屬性值是undefined的狀況,那麼,_makeQueue能夠簡化以下:

// str是前面經過_prepare生成的preparedString
const _makeChunks = (str) => str
  .replace(/"\w+__sjs"/gm, type => (/string/.test(type) ? '"__par__"' : '__par__'))
  .split('__par__')
  .map((chunk, index, chunks) => {
    return {
      flag: false, // 代表前面的屬性值是否是undefined
      pure: chunk
    };
  });
var preparedString = _prepare(schema).preparedString
// "{"a":"string__sjs","b":"number__sjs","c":"boolean__sjs"}"
_makeChunks(preparedString)

image.png
經過結果會發現_makeChunks就是把不變的部分按照順序提取成一個數組。咱們知道字符串stringify以後,屬性值是被雙引號包圍的,數字或者布爾值stringify以後,屬性值是不被雙引號包圍的,因此string__sjs兩邊的雙引號是須要保留放在chunk裏面的,數字和布爾類型是須要去掉雙引號的。這就是上面replace方法的做用。而後再以__par__分割字符串。

觀察上面截圖中pure屬性,會發現a屬性值那比bc多一個雙引號。這個就是replace方法在起做用。

select方法
const _select = chunks => (value, index) => {
  const chunk = chunks[index];

  if (typeof value !== 'undefined') {
    if (chunk.flag) {
      return chunk.prevUndef + value;
    }
    return chunk.pure + value;
  }

  chunks[index + 1].flag = true;

  if (chunk.flag) {
    return chunk.bothUndef;
  }
  return chunk.isUndef;
};

前面咱們說了不考慮屬性值是undefined的狀況,因此第一個if判斷就是true,就不用考慮下面的狀況了。而chunkflag是代表前面的屬性值是否是undefined的,在不考慮屬性值是undefined的狀況下,這個flag永遠是false。這兩步精簡後的_select函數以下:

const _select = chunks => (value, index) => {
  const chunk = chunks[index];
  return chunk.pure + value;
};

chunk.pure就是前面_makeChunks生成的,使用__par__分割生成的字符串。

_select方法用來拼接不變的部分chunk和經過queue獲得的實際屬性值。

下面咱們接着講sjs函數:

const sjs = (schema) => {
  const { preparedString, preparedSchema } = _prepare(schema);
  const queue = _makeQueue(preparedSchema, schema);
  const chunks = _makeChunks(preparedString, queue);
  const selectChunk = _select(chunks);

  const { length } = queue;

  return (obj) => {
    let temp = '';
    let i = 0;
    while (true) {
      if (i === length) break;
      const { serializer, find } = queue[i];
      const raw = find(obj); // 找到這個屬性的實際屬性值

      temp += selectChunk(serializer(raw), i);

      i += 1; // 處理下一個屬性值
    }

    const { flag, pure, prevUndef } = chunks[chunks.length - 1]; // 拼接最後一個不變的部分

    return temp + (flag ? prevUndef : pure);
  };
};

sjs函數返回了一個函數,這個函數的參數是咱們將要stringify的json對象。這個函數會經過循環的方式遍歷queue數組,queue數組存儲的就是變量的部分。經過find方法找到變量的原始值,而後經過serializer方法返回自定義的值,經過selectChunk方法返回該屬性值前面不變的部分+屬性值。

最後在加上最後一個不變的部分,這個過程就完成了。咱們會發現queue的長度始終比chunks的長度小一。

完整版本代碼分析

咱們經過幾個例子來對應看下在簡化版本咱們忽略的部分

例子一:嵌套對象
var schema = {
    a: attr('string'),
    b: attr('number'),
    c: {
        d: attr('string'),
        e: attr('number')
    }
}
_makeQueue

咱們來分析下_makeQueue方法下面的Object.keys():

const _makeQueue = (preparedSchema, originalSchema) => {
  const queue = [];

  (function scoped(obj, acc = []) {
    if (/__sjs/.test(obj)) {
      // ...
    }

    return Object
      .keys(obj)
      .map(prop => scoped(obj[prop], [...acc, prop]));
  })(preparedSchema);

  return queue;
};

scoped函數開始執行的時候,首先是一個if判斷,剛開始obj就是preparedSchema,是一個對象,那麼正則表達式的test函數接受一個對象作爲參數作了什麼呢?

由於test函數的含義就是測試一個字符串是否知足正則表達式,當遇到非字符串參數的時候,會首先把參數轉化爲字符串,因此給test傳入preparedSchema的時候,首先調用了對象的toString方法,普通對象調用toString方法通常返回[object Object]

/__sjs/.test({name: 'hello'}) // false
/\[object Object\]/.test({name: 'hello'}) // true

scoped函數剛開始執行的時候if判斷失敗,會走到Object.keys。同理,若是遇到嵌套的對象,就像上面這個例子,當分析到屬性c的值的時候,也會使用Object.keys遍歷裏面的de屬性,同時acc變量會把當前訪問路徑加進去。

不過,若是定義的時候沒有使用attr屬性,就有可能會致使堆棧溢出:

// 沒有按照規範定義schema
var schema = {
  a: 'string',
  b: attr('string')
}

在_prepare方法裏面JSON.stringify的時候,咱們直接使用的'string'!value.isSJS這個判斷中成功,因此直接返回了value

const _prepare = (schema) => {
  const preparedString = JSON.stringify(schema, (_, value) => {
    if (!value.isSJS) return value;
    // ...
  });
  // ...
};

因此_prepare返回值裏面的preparedSchema以下:

{a: "string", b: "string__sjs"}

接下來執行_makeQueue的時候,當完成preparedSchema處理以後,會開始處理屬性a的屬性值,也就是scoped('string', ['a']),首先if判斷是失敗的,執行Object.keys('string')

Object.keys('string') // ["0", "1", "2", "3", "4", "5"]
Object.keys('hello') // ["0", "1", "2", "3", "4"]
Object.keys(123) // []
Object.keys(true) // []

這裏隱藏着另一個知識點:Object.keys會首先把參數轉換成對象,也就是new String('string')
image.png

咱們接着往下看,

Object.keys('string').map(prop => scoped(obj[prop], [...acc, prop])

map的時候prop就是0,1,2,3,4,5,obj[prop]就是每一個字符,因此會進入scoped('s', ['a', '0']), scoped('t', ['a', '1']) ...

看第一個scoped('s', ['a', '0']),就會進入和上面同樣的分析過程,只不過原先的參數'string'變成了's',因此會進入到scoped('s', ['a', '0', '0']),而後再進入到scoped('s', ['a', '0', '0', '0']) ...,直到堆棧溢出。

例子二:屬性值undefined
var schema = {
    a: attr('string'),
    b: attr('number'),
    c: attr('string')
}
// 須要stringify的對象
var obj = {
  a: undefined,
  b: undefined,
  c: undefined
}

咱們前面講過_makeChunks是用來提取stringify後的字符串裏面不變的部分的,簡化版本刪除了和屬性值是undefined相關的代碼,咱們如今來看下:

const _makeChunks = (str, queue) => str
  .replace(/"\w+__sjs"/gm, type => (/string/.test(type) ? '"__par__"' : '__par__'))
  .split('__par__')
  .map((chunk, index, chunks) => {
    const matchProp = `("${(queue[index] || {}).name}":(\"?))$`;
    const matchWhenLast = `(\,?)${matchProp}`;
    const isLast = /^("}|})/.test(chunks[index + 1] || '');
    const matchPropRe = new RegExp(isLast ? matchWhenLast : matchProp);
    const matchStartRe = /^(\"\,|\,|\")/;

    return {
      flag: false, // 代表前面的屬性值是否是undefined
      pure: chunk,
      // Without initial part
      prevUndef: chunk.replace(matchStartRe, ''),
      // Without property chars
      isUndef: chunk.replace(matchPropRe, ''),
      // Only remaining chars (can be zero chars)
      bothUndef: chunk
        .replace(matchStartRe, '')
        .replace(matchPropRe, ''),
    };
  });

queue參數就是前面_makeQueue方法生成的用於存放變的部分的相關信息。當屬性值是undefined的時候,屬性名也不會出如今最終的字符串中。可是咱們生成的chunks是包含屬性名的,因此須要用正則把屬性名給刪掉。

matchProp匹配的屬性的鍵值部分,也就是"key":"或者"key":,後面這個引號是字符串類型的時候會有,其餘類型的時候沒有,和前面的replace方法對應。

matchWhenLast匹配當undefined的屬性是這個對象最後一個屬性的時候,這個屬性前面的逗號也要去掉。

isLast是用於判斷這個屬性是否是對象的最後一個屬性的,根據這個判斷是用mathProp仍是matchWhenLast

matchPropRe就是根據前面isLast判斷以後的最終的正則表達式。

matchStartRe是,當前面一個屬性是undefined的時候,該靜態字符串前面用於拼合前面屬性的部分。

因此返回值裏面的這幾個屬性分別表示:

  • flag: 前面的屬性值是否是undefined
  • pure: 前面的屬性值和該靜態字符串後面的屬性值都不是undefined的時候用這個原始靜態字符串,咱們簡版裏面就是使用的這個字段
  • prevUndef: 只有前面的屬性值是undefined的時候,用這個處理事後的靜態字符串
  • isUndef: 只有該靜態字符串後面的屬性值是undefined的時候,用這個處理事後的靜態字符串
  • bothUndef: 前面的屬性值和該靜態字符串後面的屬性值都是undefined的時候,使用這個處理後的靜態字符串

接下來分析_select方法,這幾個字段是在_select中被消費的:

const _select = chunks => (value, index) => {
  const chunk = chunks[index];

  if (typeof value !== 'undefined') {
    if (chunk.flag) {
      return chunk.prevUndef + value; // 11
    }
    return chunk.pure + value; // 12
  }

  chunks[index + 1].flag = true; // 標記後面靜態字符串前面的屬性值是undefined

  if (chunk.flag) {
    return chunk.bothUndef; // 21
  }
  return chunk.isUndef; // 22
};
  • 11對應只有前面的屬性值是undefined,因此使用了prevUndef
  • 12對應先後屬性值都不是undefined,因此使用了prue
  • 21對應先後屬性值都是undefined,因此使用了bothUndef
  • 22對應只有後面的屬性值是undefined,因此使用了isUndef

下面看下例子:

var schema = {
 a: attr('string'),
 b: attr('number'),
 c: attr('string')
}
obj = {
  a: undefined,
  b: undefined,
  c: undefined
}
sjs(schema)(obj) // "{}"

image

從上圖中看出其實結果就是chunks[0].isUndef + chunks[1].bothUndef + chunks[2].bothUndef + chunks[3].prevUndef,因此最後的結果是"{}"

再看下面的例子

obj = {
  a: undefined,
  b: 3,
  c: undefined
}
sjs(schema)(obj) // "{"b":3}"

image
從上圖中看出其實結果就是chunks[0].isUndef + chunks[1].prevUndef + 3 + chunks[2].isUndef + chunks[3].prevUndef,因此最後的結果是"{"b":3}"

例子二:數組
var schema = {
  a: attr('array', sjs({
    b: attr('string'),
    c: attr('number'),
  }))
}
var obj = {
  a: [
    {
      b: 'hello',
      c: 1
    },
    {
      b: 'hello',
      c: 2
    }
  ]
}
var stringify = sjs(schema)
stringify(obj) // "{"a":[{"b":"hello","c":1},{"b":"hello","c":2}]}"

咱們看下attr方法裏面和數組類型相關的代碼

const attr = (type, serializer) => {
  // ...
  return {
    isSJS: true,
    type,
    serializer: type === 'array'
      ? _makeArraySerializer(serializer) // 數組類型,作特殊處理
      : usedSerializer,
  };
};

看下_makeArraySerializer方法:

const _makeArraySerializer = (serializer) => {
  if (serializer instanceof Function) {
    return (array) => {
      // Stringifying more complex array using the provided sjs schema
      let acc = '';
      const { length } = array;
      for (let i = 0; i < length - 1; i++) {
        acc += `${serializer(array[i])},`;
      }

      // Prevent slice for removing unnecessary comma.
      acc += serializer(array[length - 1]);
      return `[${acc}]`;
    };
  }

  return array => JSON.stringify(array);
};

從上述代碼能夠發現,若是沒有定義能夠的序列化方法,會直接調用JSON.stringify方法,也就是咱們的schema能夠直接寫成:

var schema = {
  a: attr('array')
}
sjs(schema)(obj) // {"a":[{"b":"hello","c":1},{"b":"hello","c":2}]}

可是這種stringify字符串的時候仍是使用原生的JSON.stringify方法。

當定義了可用的數組序列化方法的時候,咱們會發現其實這個方法是用來stringify每一項的方法,因此數組的序列化方法要作的就是:

把數組的每一項使用序列化方法調用一下,而後把結果拼成數組的形式。須要拼湊的部分包括先後的[]以及每項之間的分割符,

這段代碼遍歷前面length - 1個元素,每一個元素後面拼上逗號,最後再拼上最後一個數據項。可是,拼上最有一個數據項的時候沒有作任何判斷,若是數組長度是0也會拼上一項,因此致使最後的結果是多一個{}

var schema = {
  a: attr('array', sjs({
    b: attr('string'),
    c: attr('number'),
  }))
}
var obj = {
  a: []
}
var stringify = sjs(schema)
stringify(obj) // "{"a":[{}]}"

var schema = {
  a: attr('array')
}
var stringify = sjs(schema)
stringify(obj) // "{"a":[]}"

JSON.stringify(obj) // "{"a":[]}"

結語

本文到這裏就結束了,與君共勉。

相關文章
相關標籤/搜索