面试官:来,手写一个 JSON.stringify —— 90% 的人倒在了边界情况

0 阅读12分钟

📌 前置知识:了解 JSON 基本语法、JavaScript 类型系统(原始类型 vs 引用类型)、深浅拷贝中的 typeof 陷阱。本文实现已在 Node 22 下与原生 JSON.stringify 逐项对比验证通过 ✅


🎬 面试现场还原

面试官翻了翻你的简历:「看你项目里用过 JSON.stringify,那你给我手写一个 JSON.stringify 吧。」

很多人的第一反应:这有什么好写的?JSON.stringify 不就是把对象转成字符串吗?

天真了。  这道题真正考的是:

  1. 你是否真的理解 JavaScript 的类型系统(9 种类型在序列化时的行为差异)
  2. 你是否踩过 toJSON / replacer / 循环引用 这些深水区
  3. 你写代码时边界意识强不强

而且,JSON.stringify 的行为远不止「把对象转成字符串」这么简单,它有一套完整的规范语义。不信?先看下面 5 道热身题:

js复制

// 1️⃣ 结果是什么?
JSON.stringify(NaN);

// 2️⃣ 结果是什么?
JSON.stringify([1, undefined, function(){}, Symbol('s'), null]);

// 3️⃣ 结果是什么?
JSON.stringify({ a: 1, b: undefined, c: function(){}, [Symbol('k')]: 2 });

// 4️⃣ 结果是什么?
JSON.stringify(10n);

// 5️⃣ 下面这段会输出什么?
const obj = {};
obj.self = obj;
JSON.stringify(obj);

答案(先别看后面的实现,自己心里默答一遍):

  1. "null" —— NaN 和 ±Infinity 都会被序列化成 null
  2. "[1,null,null,null,null]" —— 数组里不可序列化的值变 null
  3. '{"a":1}' —— 对象里不可序列化的值被跳过,symbol 键天然忽略
  4. 抛 TypeError: Do not know how to serialize a BigInt
  5. 抛 TypeError: Converting circular structure to JSON

答对了几题?如果你能全部答对,说明你已经有基本的边界意识了,咱们接着往下看完整实现。


📋 先明确目标:JSON.stringify 的完整签名

js复制

JSON.stringify(value[, replacer[, space]])
  • value:要序列化的值
  • replacer函数(对每个键值对做变换)或数组(指定输出哪些键)
  • space数字(缩进几个空格)或字符串(缩进用什么字符)

我们要实现的 myJSONStringify(data, replacer, space),必须做到与原生行为完全一致,包括下面这张坑位表:

#边界情况原生行为
1NaN / ±Infinity→ null
2undefined / 函数 / Symbol(对象属性值)跳过该属性
3undefined / 函数 / Symbol(数组元素)→ null
4undefined / 函数 / Symbol(顶层)→ undefined(无输出)
5BigInt抛 TypeError
6new Number(3) 等包装对象拆箱后序列化
7Date调用 toJSON → toISOString()
8RegExp / Map / Set / Error→ {}
9循环引用抛 TypeError
10symbol 键 / 不可枚举属性忽略
11字符串中的 " `` 控制字符、孤立代理对转义
12对象上有 toJSON 方法先调用 toJSON
13replacer 为数组只输出数组里列出的键
14replacer 为函数对每个键值对先做变换
15space数字截断到 0~10,字符串截取前 10 个字符
16稀疏数组 [1, , 3]空槽 → null

能把这 16 条都处理对,才算「手写完成」。


🏗️ 第一块基石:字符串转义 quote()

序列化字符串时,双引号、反斜杠、控制字符都必须转义,否则生成的 JSON 无法被 JSON.parse 还原:

js复制

const ESCAPES = {
  '"': '\"',
  '\': '\\',
  '\b': '\b',   // \u0008
  '\f': '\f',   // \u000c
  '\n': '\n',   // \u000a
  '\r': '\r',   // \u000d
  '\t': '\t',   // \u0009
};

function quote(text) {
  let out = '"';
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    const code = text.charCodeAt(i);

    if (ESCAPES[ch]) {
      out += ESCAPES[ch];               // 6 个有简写转义的特殊字符
    } else if (code < 0x20) {
      out += '\u' + code.toString(16).padStart(4, '0');  // 其余控制字符 → \uXXXX
    } else if (code >= 0xd800 && code <= 0xdbff) {
      // 高位代理:必须紧跟低位代理才是合法字符(如 emoji 😀 = \uD83D\uDE00)
      const next = text.charCodeAt(i + 1);
      if (next >= 0xdc00 && next <= 0xdfff) {
        out += ch + text[++i];          // 合法代理对:原样输出
      } else {
        out += '\u' + code.toString(16).padStart(4, '0'); // 孤立高位代理 → 转义
      }
    } else if (code >= 0xdc00 && code <= 0xdfff) {
      out += '\u' + code.toString(16).padStart(4, '0');   // 孤立低位代理 → 转义
    } else {
      out += ch;                        // 普通字符(含中文、emoji)原样输出
    }
  }
  return out + '"';
}

💡 考点:中文和 emoji 是不需要转义的,原生输出就是原文(如 "中文")。真正需要 \uXXXX 的是控制字符和孤立代理对——比如 '\uD800'(一个不完整的代理对)原生会输出 "\ud800"。很多实现图省事把所有非 ASCII 都转义,虽然也能被 JSON.parse 还原,但和原生输出不一致,面试时属于减分项。


🏗️ 第二块基石:核心递归 str(key, holder)

这是整个实现的心脏。为什么要设计成 str(key, holder) 而不是 str(value)

因为 toJSON 和 replacer 函数都需要拿到 key 和所属对象replacer 的 this 指向 holder,参数是 (key, value)),所以递归的入参必须是「从哪个对象的哪个键取值」。

步骤 1:调用 toJSON 钩子

js复制

// Date 等内建类型都靠 toJSON 变成可序列化的值
if (
  value !== null &&
  (typeof value === 'object' || typeof value === 'function') &&
  typeof value.toJSON === 'function'
) {
  value = value.toJSON(key);
}

Date.prototype.toJSON 内部调用 toISOString(),所以 JSON.stringify(new Date()) 输出的是 ISO 字符串。如果你给任意对象挂了 toJSON 方法,序列化时也会先走它——这是面试官很爱追问的「扩展点」。

步骤 2:replacer 函数变换

js复制

if (replacerFn) {
  value = replacerFn.call(holder, key, value);
}

注意执行顺序:toJSON 先于 replacer。且 replacer 的 this 必须指向 holder(当前所在的对象)。

步骤 3:包装对象拆箱

js复制

if (value !== null && typeof value === 'object') {
  const tag = Object.prototype.toString.call(value);
  if (
    tag === '[object Number]' ||
    tag === '[object String]' ||
    tag === '[object Boolean]' ||
    tag === '[object BigInt]'
  ) {
    value = value.valueOf();
  }
}

JSON.stringify(new Number(3)) → "3"new Boolean(false) → "false"。判断包装对象要用 Object.prototype.toString 的标签,而不是 instanceof(跨 iframe/realm 时 instanceof 会失效)。

步骤 4:原始类型分派(坑位 1~6 的答案都在这里)

js复制

if (value === null) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'string') return quote(value);
if (typeof value === 'number') {
  return Number.isFinite(value) ? String(value) : 'null'; // NaN/±Infinity → null
}
if (typeof value === 'bigint') {
  throw new TypeError('Do not know how to serialize a BigInt');
}
// 不可序列化:由调用方决定(对象跳过 / 数组 null / 顶层 undefined)
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
  return undefined;
}

到这里,str() 返回字符串或 undefined。返回 undefined 时如何处理,取决于调用场景:

  • 对象属性值 → 跳过不输出
  • 数组元素 → 替换成 null
  • 顶层 → 整个返回 undefined

这就是「同样是 undefined,在对象里被丢弃、在数组里变成 null」的底层原因——同一个 undefined 返回值,在不同容器里被解释成了不同行为

步骤 5:对象与数组(含循环引用检测 + 缩进)

js复制

const isArray = Array.isArray(value);

// 循环引用:祖先链上出现过自己 → 抛错
if (stack.includes(value)) {
  throw new TypeError('Converting circular structure to JSON');
}
stack.push(value);

const prevGap = gap;
gap = prevGap + indentUnit;
const parts = [];

if (isArray) {
  // 数组:必须按 length 遍历(稀疏数组空槽也要产出 null)
  for (let i = 0; i < value.length; i++) {
    const v = str(String(i), value);
    parts.push(v === undefined ? 'null' : v);
  }
  result = parts.length === 0
    ? '[]'
    : indentUnit
      ? '[\n' + gap + parts.join(',\n' + gap) + '\n' + prevGap + ']'
      : '[' + parts.join(',') + ']';
} else {
  // 对象:replacer 数组限定键集合,否则取自身可枚举键(symbol 键天然忽略)
  const keys = propertyList ? [...new Set(propertyList)] : Object.keys(value);
  for (const k of keys) {
    const v = str(k, value);
    if (v !== undefined) {
      parts.push(quote(k) + (indentUnit ? ': ' : ':') + v);
    }
  }
  result = parts.length === 0
    ? '{}'
    : indentUnit
      ? '{\n' + gap + parts.join(',\n' + gap) + '\n' + prevGap + '}'
      : '{' + parts.join(',') + '}';
}

stack.pop();
gap = prevGap;
return result;

几个容易写错的地方:

  • 循环引用检测用「栈」而非「永久 Set」:序列化完当前分支要 pop 出去,否则 JSON.stringify({ a: shared, b: shared }) 这种**共享引用(非循环)**也会被误判成循环引用而抛错。
  • 遍历数组用 for 循环按 length 走for...of 会跳过稀疏数组的空槽,而原生要求空槽输出 null
  • 对象键集合:replacer 为数组时用它的键(去重),否则用 Object.keys(只含可枚举字符串键,symbol 键自动忽略)。
  • 缩进用「全局游标」gap:进入一层嵌套 gap += indentUnit,返回时恢复 prevGap。闭合括号 } 前要加 prevGap 而不是 gap,否则嵌套缩进会错位。

为什么 Date / RegExp / Map / Set 不需要特判?

  • Date:在步骤 1 就被 toJSON 转成字符串了 ✅
  • RegExp / Map / Set / Error:它们没有可枚举的自有属性,走普通对象分支后自然输出 {} ✅(JSON.stringify(/abc/) → '{}'

🚀 完整实现

把上面的零件组装起来(这段代码已经过 67 项用例与原生逐项对比验证):

js复制

function myJSONStringify(data, replacer, space) {
  // ===== 1. 处理 space =====
  let indentUnit = '';
  if (typeof space === 'number') {
    indentUnit = ' '.repeat(Math.max(0, Math.min(10, Math.floor(space))));
  } else if (typeof space === 'string') {
    indentUnit = space.slice(0, 10);
  }

  // ===== 2. 处理 replacer =====
  const replacerFn = typeof replacer === 'function' ? replacer : null;
  const propertyList = Array.isArray(replacer)
    ? replacer
        .filter((v) => typeof v === 'string' || typeof v === 'number')
        .map(String)
    : null;

  let gap = '';          // 当前累积缩进(全局游标)
  const stack = [];      // 循环引用检测栈

  // ===== 3. 字符串转义 =====
  const ESCAPES = {
    '"': '\"',
    '\': '\\',
    '\b': '\b',
    '\f': '\f',
    '\n': '\n',
    '\r': '\r',
    '\t': '\t',
  };

  function quote(text) {
    let out = '"';
    for (let i = 0; i < text.length; i++) {
      const ch = text[i];
      const code = text.charCodeAt(i);
      if (ESCAPES[ch]) {
        out += ESCAPES[ch];
      } else if (code < 0x20) {
        out += '\u' + code.toString(16).padStart(4, '0');
      } else if (code >= 0xd800 && code <= 0xdbff) {
        const next = text.charCodeAt(i + 1);
        if (next >= 0xdc00 && next <= 0xdfff) {
          out += ch + text[++i];   // 合法代理对(emoji)原样输出
        } else {
          out += '\u' + code.toString(16).padStart(4, '0');
        }
      } else if (code >= 0xdc00 && code <= 0xdfff) {
        out += '\u' + code.toString(16).padStart(4, '0');
      } else {
        out += ch;
      }
    }
    return out + '"';
  }

  // ===== 4. 核心递归 =====
  function str(key, holder) {
    let value = holder[key];

    // 4.1 toJSON 钩子
    if (
      value !== null &&
      (typeof value === 'object' || typeof value === 'function') &&
      typeof value.toJSON === 'function'
    ) {
      value = value.toJSON(key);
    }
    // 4.2 replacer 函数
    if (replacerFn) {
      value = replacerFn.call(holder, key, value);
    }
    // 4.3 包装对象拆箱
    if (value !== null && typeof value === 'object') {
      const tag = Object.prototype.toString.call(value);
      if (tag === '[object Number]' || tag === '[object String]' ||
          tag === '[object Boolean]' || tag === '[object BigInt]') {
        value = value.valueOf();
      }
    }
    // 4.4 原始类型
    if (value === null) return 'null';
    if (typeof value === 'boolean') return value ? 'true' : 'false';
    if (typeof value === 'string') return quote(value);
    if (typeof value === 'number') {
      return Number.isFinite(value) ? String(value) : 'null';
    }
    if (typeof value === 'bigint') {
      throw new TypeError('Do not know how to serialize a BigInt');
    }
    if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
      return undefined; // 对象:跳过;数组:null;顶层:undefined
    }

    // 4.5 对象
    const isArray = Array.isArray(value);
    if (stack.includes(value)) {
      throw new TypeError('Converting circular structure to JSON');
    }
    stack.push(value);

    const prevGap = gap;
    gap = prevGap + indentUnit;
    const parts = [];
    let result;

    if (isArray) {
      for (let i = 0; i < value.length; i++) {
        const v = str(String(i), value);
        parts.push(v === undefined ? 'null' : v);
      }
      result = parts.length === 0
        ? '[]'
        : indentUnit
          ? '[\n' + gap + parts.join(',\n' + gap) + '\n' + prevGap + ']'
          : '[' + parts.join(',') + ']';
    } else {
      const keys = propertyList ? [...new Set(propertyList)] : Object.keys(value);
      for (const k of keys) {
        const v = str(k, value);
        if (v !== undefined) {
          parts.push(quote(k) + (indentUnit ? ': ' : ':') + v);
        }
      }
      result = parts.length === 0
        ? '{}'
        : indentUnit
          ? '{\n' + gap + parts.join(',\n' + gap) + '\n' + prevGap + '}'
          : '{' + parts.join(',') + '}';
    }

    stack.pop();
    gap = prevGap;
    return result;
  }

  // ===== 5. 入口 =====
  return str('', { '': data });
}

🧪 与原生逐项对比:验证结果

我用上面这份实现跑了 67 组用例,与原生 JSON.stringify 输出(含抛错类型)逐一对比:

类别覆盖用例结果
基础类型字符串 / 数字 / 布尔 / null / undefined / 函数 / Symbol✅ 一致
特殊数字-0 / NaN / Infinity / -Infinity✅ 一致(全变 null)
数组混合数组、含不可序列化、稀疏数组✅ 一致
对象含不可序列化属性、嵌套对象✅ 一致
包装对象new Number(NaN)new Stringnew Boolean(false)✅ 一致
内建对象Date(含无效日期)、RegExpMapSetError✅ 一致
BigInt顶层 10nObject(10n) 包装✅ 均抛 TypeError
键规则数字键排序、symbol 键、不可枚举属性✅ 一致
字符串转义控制字符、emoji、孤立高低位代理、中文✅ 一致
toJSON对象自定义 toJSON、Date、函数挂 toJSON✅ 一致
replacer函数(变换/过滤/改写顶层)、数组(含不存在键、作用于数组、重复键)✅ 一致
space数字 2/4、负数、小数、超大数、字符串、超长字符串✅ 一致
引用关系对象自引用、数组自引用(抛错);共享引用非循环(正常输出)✅ 一致

其中最容易写错的三个点再次划重点:

  1. 共享引用 ≠ 循环引用——检测栈用完后要 pop,否则 {a: shared, b: shared} 会被误杀;
  2. 稀疏数组的空槽要输出 null——用 for 按 length 遍历,别用 for…of;
  3. replacer 数组只作用于对象的键,不过滤数组元素——JSON.stringify([1,2,3], ['0']) 依然是 "[1,2,3]"

🎯 面试官可能的追问与加分回答

追问 1:为什么 JSON.stringify({a: undefined}) 是 {},而 JSON.stringify([undefined]) 是 [null]?

因为序列化容器时对「不可序列化返回值」的解释不同:对象属性没有值可以占位,只能丢弃;数组有位置概念,必须用 null 占位(数组是定长的有序结构,丢掉一个元素会破坏索引语义)。这也是底层 str() 返回 undefined 后由调用方决定如何处理的原因。

追问 2:如何用 JSON.stringify 实现深拷贝?有什么坑?

经典一行:JSON.parse(JSON.stringify(obj))。但要注意它丢数据

js复制

JSON.parse(JSON.stringify({
  a: undefined,       // 丢
  b: () => {},        // 丢
  c: NaN,             // → null
  d: new Date(),      // → 字符串,不再是 Date
  e: /abc/,           // → {}
  f: new Map(),       // → {}
}))

所以它只适合拷贝纯 JSON 数据。这也是面试官常用来引出 structuredClone(2022 年后的原生深拷贝)的桥段。

追问 3:JSON.stringify 能保证键的顺序吗?

能,但只保证「数字键升序在前,字符串键按插入顺序」(ES2019+ 规范 + V8 的 Object.keys 行为):

js复制

JSON.stringify({ 2: 'b', 1: 'a', a: 'x', 10: 'c' });
// '{"1":"a","2":"b","10":"c","a":"x"}'

追问 4:JSON.stringify 为什么不能序列化 BigInt?

规范层面的设计决定:JSON 只有 number 类型,而 BigInt 的精度可能超出 number 的安全整数范围(2^53 - 1),静默转成 number 会丢精度,所以规范选择直接抛错,把决定权交给你(比如先 toString() 再序列化,或者用 replacer 转换)。

追问 5:为什么 toJSON 的执行顺序在 replacer 之前?

因为 replacer 收到的是「toJSON 处理后的值」——这样你既能用 toJSON 做类型归一化(如 Date → ISO 字符串),又能在 replacer 里对归一化后的结果做二次加工,职责更清晰,不会互相打架。


📋 面试答题结构总结

code复制

1️⃣ 明确目标:完整签名 value + replacer + space,行为与原生一致
2️⃣ 讲类型分派:null/boolean/string 直出;number 处理 NaN/Infinitybigint 抛错
3️⃣ 讲三个「不可序列化」场景:对象跳过 / 数组 null / 顶层 undefined
4️⃣ 讲对象分支:toJSON → replacer → 循环引用检测 → 键集合 → 缩进拼接
5️⃣ 讲字符串转义:6 个简写 + 控制字符 \uXXXX + 孤立代理对
6️⃣ 主动补坑:共享引用 vs 循环引用、稀疏数组空槽、replacer 数组不作用于数组
7️⃣ 加分延伸:键排序规则、深拷贝的坑、BigInt 为什么抛错

📚 延伸阅读


如果这篇文章对你有帮助,欢迎点赞 👍、收藏 📂,有疑问可以在评论区交流!关注我,面试题系列持续更新中。