关于前端:slowjsonstringify源码解析

4次阅读

共计 12076 个字符,预计需要花费 31 分钟才能阅读完成。

最近对 slow-json-stringify 的源码钻研了一下,本文将对源码中函数的作用进行解说。下文的源码是 2.0.0 版本的代码。

简介

JSON.stringify 能够把一个对象转化为一个 JSON 字符串。slow-json-stringify开源库是对上述性能进行了一个 工夫 上的优化。

因为 JavaScript 是动静类型的语言,所以一个对象的属性值的类型在运行的时候能力确定,因而执行 JSON.stringify 会有很多和确定变量类型相干的工作。

那么,如果咱们当时能够晓得 JSON 的格局,是不是就能够缩减一些工夫?slow-json-stringify正是基于这个思路去做的。所以,你须要提供一个阐明属性值类型的 schema,它会依据schema 生成一个独自的 stringify 办法。


基本原理就是依据提供的 schema,把字符串宰割成两局部,chunksqueue

  • chunks外面用于寄存字符串中 不变 的局部
  • queue寄存生成动静属性值相干的信息

当序列化理论对象的时候,再把这两局部拼接起来。

应用

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 理论如下:

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)

能够看到 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)


通过后果会发现 _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')

咱们接着往下看,

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) // "{}"

从上图中看出其实后果就是chunks[0].isUndef + chunks[1].bothUndef + chunks[2].bothUndef + chunks[3].prevUndef,所以最初的后果是"{}"

再看上面的例子

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


从上图中看出其实后果就是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":[]}"

结语

本文到这里就完结了,与君共勉。

正文完
 0