怎样手写 JSON.stringify 和 JSON.parse 的简化版?
stringify 递归处理值:先调 toJSON,字符串加引号并转义,NaN 和 Infinity 变 null,对象里的 undefined、函数、Symbol 被跳过,数组里的变 null,BigInt 和循环引用抛 TypeError。parse 是一个从左到右读字符的递归下降解析器,按第一个字符决定读对象、数组、字符串、数字还是字面量,不合法就抛 SyntaxError。
简答
把表单数据存进
localStorage前要先转成字符串,读出来再还原,这就是JSON.stringify和JSON.parse。手写stringify的核心是按类型递归:字符串加双引号并转义,数字原样输出,对象和数组逐个处理子元素再拼起来。难点在边界规则。有
toJSON方法的值先调用它,Date就是这样变成 ISO 字符串的;NaN、Infinity输出null;undefined、函数、Symbol 在对象里被整个跳过,在数组里变成null;BigInt 和循环引用会抛TypeError。手写
parse是写一个小解析器:用一个下标从左往右读,看到{读对象,[读数组,"读字符串,数字或负号读数字,其余只能是true、false、null。JSON 比 JS 字面量严格,键必须是双引号字符串,不能有尾随逗号,数字不能有前导零,遇到这些都要抛SyntaxError。简化版省略了
replacer、space缩进、reviver等参数,面试时说清楚省略了什么即可。
stringify:按类型递归拼字符串
先看整体结构:walk 处理一个值,返回它的 JSON 文本,返回 undefined 表示“这个值不该出现”,由调用方决定跳过还是写 null。
const ESCAPES = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t' };
function quote(str) {
return '"' + str.replace(/["\\\u0000-\u001f]/g, (c) => ESCAPES[c] ?? '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')) + '"';
}
function stringify(value) {
const stack = new Set();
function walk(val, key) {
if (val !== null && (typeof val === 'object' || typeof val === 'bigint') && typeof val.toJSON === 'function') {
val = val.toJSON(key);
}
if (val instanceof Number || val instanceof String || val instanceof Boolean) val = val.valueOf();
if (val === null) return 'null';
if (typeof val === 'string') return quote(val);
if (typeof val === 'number') return Number.isFinite(val) ? String(val) : 'null';
if (typeof val === 'boolean') return String(val);
if (typeof val === 'bigint') throw new TypeError('BigInt 不能序列化');
if (typeof val !== 'object') return undefined; // undefined、函数、Symbol
if (stack.has(val)) throw new TypeError('存在循环引用');
stack.add(val);
let out;
if (Array.isArray(val)) {
out = '[' + val.map((item, i) => walk(item, String(i)) ?? 'null').join(',') + ']';
} else {
const parts = [];
for (const k of Object.keys(val)) {
const s = walk(val[k], k);
if (s !== undefined) parts.push(quote(k) + ':' + s);
}
out = '{' + parts.join(',') + '}';
}
stack.delete(val);
return out;
}
return walk(value, '');
}
const data = { a: 1, b: [undefined, () => 1, NaN], c: undefined, d: 'x"\n', e: new Date(0), f: { g: null } };
console.log(stringify(data)); // {"a":1,"b":[null,null,null],"d":"x\"\n","e":"1970-01-01T00:00:00.000Z","f":{"g":null}}
console.log(stringify(data) === JSON.stringify(data)); // true
console.log(stringify(undefined), JSON.stringify(undefined)); // undefined undefined
const loop = {};
loop.self = loop;
try {
stringify(loop);
} catch (e) {
console.log(e.message); // 存在循环引用
}
const shared = { x: 1 };
console.log(stringify([shared, shared])); // [{"x":1},{"x":1}]
为什么 b 里三个值都成了 null,而 c 整个消失了?MDN 写明 undefined、函数和 Symbol 不是合法的 JSON 值:出现在对象里时属性被省略,出现在数组里时换成 null。数组不能随便删元素,否则后面元素的下标全错了,所以只能占位。NaN 和 Infinity 也按 MDN 的规则输出 null。
e 变成了字符串,是因为 Date 实现了 toJSON,返回值和 toISOString() 相同。stringify 在处理每个值前都会检查 toJSON,这也是自定义序列化结果的标准方式。
循环检测用的是“当前递归路径上的对象”,处理完一个对象就把它从 stack 删掉。所以最后一行里 shared 出现两次不会被误判为循环,它只是被引用了两次,按 JSON 的规则会输出两份。MDN 写明 JSON 格式不支持对象引用,遇到循环引用会抛 TypeError。
转义只处理了双引号、反斜杠和 0x00 到 0x1F 的控制字符,这是 JSON 字符串必须转义的部分。规范还要求把单独出现的代理项(U+D800 到 U+DFFF)写成 \u 转义,简化版没有处理。
parse:从左到右的递归下降解析
“递归下降”是说每种结构写一个读取函数,读对象时遇到值就调用读值的函数,值又可能是对象或数组,于是函数之间互相递归。下标 i 记录读到哪里,每个函数读完自己那部分就把 i 往后挪。
function parse(text) {
let i = 0;
const fail = (msg) => {
throw new SyntaxError(`${msg},位置 ${i}`);
};
const skip = () => {
while (' \t\n\r'.includes(text[i]) && i < text.length) i++;
};
function value() {
skip();
const c = text[i];
if (c === '{') return object();
if (c === '[') return array();
if (c === '"') return string();
if (c === '-' || (c >= '0' && c <= '9')) return number();
for (const [word, v] of [['true', true], ['false', false], ['null', null]]) {
if (text.startsWith(word, i)) {
i += word.length;
return v;
}
}
fail('意外的字符');
}
function object() {
const obj = {};
i++;
skip();
if (text[i] === '}') return i++, obj;
while (true) {
skip();
if (text[i] !== '"') fail('键必须是双引号字符串');
const key = string();
skip();
if (text[i++] !== ':') fail('缺少冒号');
Object.defineProperty(obj, key, { value: value(), writable: true, enumerable: true, configurable: true });
skip();
if (text[i] === ',') i++;
else if (text[i] === '}') return i++, obj;
else fail('缺少逗号或 }');
}
}
function array() {
const arr = [];
i++;
skip();
if (text[i] === ']') return i++, arr;
while (true) {
arr.push(value());
skip();
if (text[i] === ',') i++;
else if (text[i] === ']') return i++, arr;
else fail('缺少逗号或 ]');
}
}
function string() {
const m = /^"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/.exec(text.slice(i));
if (!m) fail('字符串不合法');
i += m[0].length;
const ESC = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
return m[0].slice(1, -1).replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, e) =>
e[0] === 'u' ? String.fromCharCode(parseInt(e.slice(1), 16)) : ESC[e] ?? e,
);
}
function number() {
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
if (!m) fail('数字不合法');
i += m[0].length;
return Number(m[0]);
}
const result = value();
skip();
if (i < text.length) fail('多余的内容');
return result;
}
const text = '{"name":"\\u5f20\\n","list":[1,-2.5e1,true,null],"nested":{"ok":false}}';
console.log(parse(text)); // { name: '张\n', list: [ 1, -25, true, null ], nested: { ok: false } }
console.log(JSON.stringify(parse(text)) === JSON.stringify(JSON.parse(text))); // true
for (const bad of ["{'a':1}", '[1,]', '01', '{"a":1} x']) {
try {
parse(bad);
} catch (e) {
console.log(e.name, e.message);
}
}
// SyntaxError 键必须是双引号字符串,位置 1
// SyntaxError 意外的字符,位置 3
// SyntaxError 多余的内容,位置 1
// SyntaxError 多余的内容,位置 8
const evil = parse('{"__proto__":{"admin":true}}');
console.log(Object.hasOwn(evil, '__proto__'), evil.admin); // true undefined
console.log(JSON.parse('{"__proto__":{"admin":true}}').admin); // undefined
四个错误输入对应 JSON 和 JS 字面量的几处差别,MDN 列出了这些规则:属性名必须是双引号字符串,不允许尾随逗号,数字不能有前导零。'01' 报的是“多余的内容”,因为数字的正则只认 0,读完 0 后还剩个 1,这样同一条规则就挡住了前导零。最后一个是合法的 JSON 后面跟了别的东西,解析完必须确认已经读到结尾。
为什么给对象加属性用 Object.defineProperty 而不是 obj[key] = ...?因为键可能是 __proto__。赋值会触发原型的 setter,把整个对象的原型换掉,evil.admin 就能读到 true。MDN 说明 JSON.parse 把 "__proto__" 当作普通的属性名,这是 JSON 文本和同样写法的 JS 字面量唯一的区别。最后两行显示,手写版和原生版都没有改原型。
字符串用一个正则一次匹配完:引号内要么是普通字符,要么是合法的转义序列,未转义的控制字符和不认识的转义都会让匹配失败。\u 转义用 String.fromCharCode 还原,代理对会被拆成两个码元分别还原,拼起来仍是原来的字符。
常见追问
为什么 JSON.parse(JSON.stringify(obj)) 不适合做深拷贝?
它会丢掉
undefined、函数和 Symbol,把Date变成字符串,NaN变成null,Map、Set变成空对象,遇到循环引用直接报错。MDN 建议深拷贝改用structuredClone(),它支持循环引用,详见《怎样手写深拷贝?和 structuredClone 有什么区别?》。
数字太大时 JSON 怎样保证精度?
JSON.parse把数字转成 JS 的number,超过安全整数范围就会丢精度,而stringify遇到 BigInt 直接抛错。MDN 给出的做法是把大数序列化成字符串,解析时再用reviver还原成 BigInt。