📌 前置知识:了解 JSON 基本语法、JavaScript 类型系统(原始类型 vs 引用类型)、深浅拷贝中的 typeof 陷阱。本文实现已在 Node 22 下与原生
JSON.stringify逐项对比验证通过 ✅
🎬 面试现场还原
面试官翻了翻你的简历:「看你项目里用过 JSON.stringify,那你给我手写一个 JSON.stringify 吧。」
很多人的第一反应:这有什么好写的?JSON.stringify 不就是把对象转成字符串吗?
天真了。 这道题真正考的是:
- 你是否真的理解 JavaScript 的类型系统(9 种类型在序列化时的行为差异)
- 你是否踩过 toJSON / replacer / 循环引用 这些深水区
- 你写代码时边界意识强不强
而且,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);
答案(先别看后面的实现,自己心里默答一遍):
"null"—— NaN 和 ±Infinity 都会被序列化成 null"[1,null,null,null,null]"—— 数组里不可序列化的值变 null'{"a":1}'—— 对象里不可序列化的值被跳过,symbol 键天然忽略- 抛
TypeError: Do not know how to serialize a BigInt - 抛
TypeError: Converting circular structure to JSON
答对了几题?如果你能全部答对,说明你已经有基本的边界意识了,咱们接着往下看完整实现。
📋 先明确目标:JSON.stringify 的完整签名
js复制
JSON.stringify(value[, replacer[, space]])
value:要序列化的值replacer:函数(对每个键值对做变换)或数组(指定输出哪些键)space:数字(缩进几个空格)或字符串(缩进用什么字符)
我们要实现的 myJSONStringify(data, replacer, space),必须做到与原生行为完全一致,包括下面这张坑位表:
| # | 边界情况 | 原生行为 |
|---|---|---|
| 1 | NaN / ±Infinity | → null |
| 2 | undefined / 函数 / Symbol(对象属性值) | 跳过该属性 |
| 3 | undefined / 函数 / Symbol(数组元素) | → null |
| 4 | undefined / 函数 / Symbol(顶层) | → undefined(无输出) |
| 5 | BigInt | 抛 TypeError |
| 6 | new Number(3) 等包装对象 | 拆箱后序列化 |
| 7 | Date | 调用 toJSON → toISOString() |
| 8 | RegExp / Map / Set / Error | → {} |
| 9 | 循环引用 | 抛 TypeError |
| 10 | symbol 键 / 不可枚举属性 | 忽略 |
| 11 | 字符串中的 " `` 控制字符、孤立代理对 | 转义 |
| 12 | 对象上有 toJSON 方法 | 先调用 toJSON |
| 13 | replacer 为数组 | 只输出数组里列出的键 |
| 14 | replacer 为函数 | 对每个键值对先做变换 |
| 15 | space | 数字截断到 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 String、new Boolean(false) | ✅ 一致 |
| 内建对象 | Date(含无效日期)、RegExp、Map、Set、Error | ✅ 一致 |
| BigInt | 顶层 10n、Object(10n) 包装 | ✅ 均抛 TypeError |
| 键规则 | 数字键排序、symbol 键、不可枚举属性 | ✅ 一致 |
| 字符串转义 | 控制字符、emoji、孤立高低位代理、中文 | ✅ 一致 |
| toJSON | 对象自定义 toJSON、Date、函数挂 toJSON | ✅ 一致 |
| replacer | 函数(变换/过滤/改写顶层)、数组(含不存在键、作用于数组、重复键) | ✅ 一致 |
| space | 数字 2/4、负数、小数、超大数、字符串、超长字符串 | ✅ 一致 |
| 引用关系 | 对象自引用、数组自引用(抛错);共享引用非循环(正常输出) | ✅ 一致 |
其中最容易写错的三个点再次划重点:
- 共享引用 ≠ 循环引用——检测栈用完后要 pop,否则
{a: shared, b: shared}会被误杀;- 稀疏数组的空槽要输出
null——用 for 按 length 遍历,别用 for…of;- 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/Infinity;bigint 抛错
3️⃣ 讲三个「不可序列化」场景:对象跳过 / 数组 null / 顶层 undefined
4️⃣ 讲对象分支:toJSON → replacer → 循环引用检测 → 键集合 → 缩进拼接
5️⃣ 讲字符串转义:6 个简写 + 控制字符 \uXXXX + 孤立代理对
6️⃣ 主动补坑:共享引用 vs 循环引用、稀疏数组空槽、replacer 数组不作用于数组
7️⃣ 加分延伸:键排序规则、深拷贝的坑、BigInt 为什么抛错
📚 延伸阅读
- ECMA-262 规范 - JSON.stringify(Serialization 全套抽象操作)
- MDN - JSON.stringify
- MDN - structuredClone(现代深拷贝替代方案)
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 📂,有疑问可以在评论区交流!关注我,面试题系列持续更新中。