前端知库

怎样手写 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。

JavaScript
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 往后挪。

JavaScript
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。