最近对slow-json-stringify的源码研究了一下,本文将对源码中函数的作用进行讲解。下文的源码是2.0.0版本的代码。
简介
JSON.stringify可以把一个对象转化为一个JSON字符串。slow-json-stringify
开源库是对上述功能进行了一个时间上的优化。
因为JavaScript是动态类型的语言,所以一个对象的属性值的类型在运行的时候才能确定,因此执行JSON.stringify
会有很多和确定变量类型相关的工作。
那么,如果我们事先可以知道JSON的格式,是不是就可以缩减一些时间?slow-json-stringify
正是基于这个思路去做的。所以,你需要提供一个说明属性值类型的schema
,它会根据schema
生成一个单独的stringify
方法。
基本原理就是根据提供的schema
,把字符串分割成两部分,chunks
和queue
:
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')
}
在上面使用的时候,我们发现主要用了两个函数,attr
和sjs
(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')}
对比下会发现_prepare
把attr('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"}"
// replace之后:"{"a":"__par__","b":__par__,"c":__par__}"
_makeChunks(preparedString)
通过结果会发现_makeChunks
就是把不变的部分按照顺序提取成一个数组。我们知道字符串stringify
之后,属性值是被双引号包围的,数字或者布尔值stringify
之后,属性值是不被双引号包围的,所以string__sjs
两边的双引号是需要保留放在chunk
里面的,数字和布尔类型是需要去掉双引号的。这就是上面replace
方法的作用。然后再以__par__
分割字符串。
观察上面截图中pure
属性,会发现a
属性值那比b
和c
多一个双引号。这个就是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
,就不用考虑下面的情况了。而chunk
的flag
是表明前面的属性值是不是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
遍历里面的d
和e
属性,同时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":[]}"
结语
本文到这里就结束了,与君共勉。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。