前两天做代码评审,看到有人用 JSON.stringify(obj) === '{}' 来判断对象是否为空,单测还全绿了。我问他:如果对象里某个字段值是 undefined,序列化出来不就是 '{}' 吗?他愣了几秒。这让我想起力扣 2727 这道题——“判断对象是否为空”。题目本身只有一行描述,但围绕它展开的东西,几乎是 JavaScript 基础功的照妖镜:Object.keys、for...in、JSON.stringify、数组的 length、稀疏数组、Symbol 键、原型链、Map/Set……全都能串起来。这篇就借着这道题,把我实际踩过的坑、测过的性能、总结出来的工程最佳实践一次性说清楚。适合刚刷力扣 JavaScript 专项的入门者,也适合写了好几年业务代码、想回头补一补语言基本功的人。
1. 题目不长,坑不少:2727 到底在考什么
先看原题定义。力扣 2727 的输入是一个对象或数组,要求返回布尔值,表示它是否为空。空的定义很明确:对象没有键值对,数组没有元素。类型签名大致是:
ts复制type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue };
function isEmpty(obj: JSONValue): boolean {
// 返回 true 表示空,false 表示非空
}
初次看到这道题,大部分人都会觉得简单到离谱。毕竟日常代码里“对象是不是空”这个判断太常见了,写起来也不难。但真正做进去才会发现,力扣把这个题放在 JavaScript 专项训练里是有原因的——它考的不是你会不会写 if,而是你知不知道 JavaScript 里“空”这个概念的边界在哪里。
我在面试和评审里见过的典型答案有这么几种:
Object.keys(obj).length === 0JSON.stringify(obj) === '{}'for (let key in obj) return false; return true;!Object.keys(obj).length
这四种写法在力扣的常规测试用例里基本都能通过,但它们背后的语义差异和性能差异非常大。尤其当数据变成稀疏数组、带 undefined 字段的对象、带 Symbol 键的对象、甚至 null 的时候,这几种写法会得出完全相反的结论。
所以说,2727 本质上考察的是三件事:第一,是否清楚 Object.keys 只返回自有可枚举字符串键;第二,是否知道数组判空应该用 length 而不是枚举键;第三,是否具备基本的性能敏感度——为了判断一个布尔值,到底需要做多少工作。
力扣 2727 属于 LeetCode 官方 JavaScript 专项系列,代码量通常很小,很多题一行就能写完。但正因为代码量小,选择哪个 API 就成了全部考点。这类题不会出现在热题 100 里,却是刷题顺序里很适合前置的一环,尤其是那些进大厂前打算把 JavaScript 基本功补扎实的人。你后面刷 BFS、DFS、动态规划的时候,不会天天遇到 Object.keys,但手写 isEmpty、手写深拷贝、手写数组去重,这些都是面试高频。
这就是为什么我建议不要把 2727 当一道“秒杀题”划过去。把它的所有边界情况都捋一遍,你在真实项目里写工具函数时会少踩很多坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四类解法速览:从“最顺手”到“最底层”
2.1 Object.keys:90% 的人第一反应
最常见的解法长这样:
js复制var isEmpty = function (obj) {
return Object.keys(obj).length === 0;
};
Object.keys 返回的是对象自有可枚举属性的字符串键数组。{} 没有键,数组长度为 0,所以返回 true;{ a: 1 } 有键,返回 false。
这个解法的时间复杂度是 O(n),这里的 n 是对象键的数量。空间上要额外创建一个数组来装键名,所以是 O(n) 空间。
在力扣 2727 的原始场景下,Object.keys 是够用的,代码也简洁。但有几个隐患:
- 如果
obj是null或undefined,Object.keys(null)会直接抛出TypeError: Cannot convert undefined or null to object。力扣的类型签名里JSONValue包含null,所以这不是完全不可能出现的输入。 - 如果对象里只有 Symbol 键,
Object.keys看不到它们。Object.keys({ [Symbol('a')]: 1 })返回[],于是你会判断成“空”,但对象里明明有一个键值对。 - 如果对象里只有不可枚举属性,
Object.keys同样看不到。Object.defineProperty({}, 'x', { value: 1, enumerable: false })用Object.keys判断也是空。 - 如果面对的是稀疏数组,
Object.keys会漏掉空洞。new Array(3)的Object.keys结果是[],但它的length是 3。
所以在力扣题里用 Object.keys 没问题,但要清楚它的边界。稍后我会在边界讨论里展开。
如果想写得稍微稳妥一点,可以加一个空值兜底:
js复制var isEmpty = function (obj) {
if (obj == null) return true;
return Object.keys(obj).length === 0;
};
有些题解会写成 !Object.keys(obj).length,因为空数组是 truthy,![] 是 false,长度为 0 时 [] 是 truthy,!0 是 true。这种写法刷题时图省事可以,但工程里不建议用隐式转换,语义不清晰。
2.2 for...in 循环:性能之选,但要注意原型链
另一种写法是直接遍历:
js复制var isEmpty = function (obj) {
for (const key in obj) {
return false;
}
return true;
};
逻辑很简单:只要遍历到任何一个键,就说明对象里至少有一个属性,返回 false;整个循环体没执行,说明没有可遍历的属性,返回 true。
这个写法的优势是性能好。Object.keys 要先收集所有键再取长度,而 for...in 只要找到第一个键就能短路返回。对于大多数非空对象,它只需要遍历到第一个键即可结束,不需要处理剩余的全部属性。空间上也没有额外分配数组,这是它比 Object.keys 快的主要原因。
我在 Node 18 和 Chrome 里都跑过对比,对一个 10 万键的对象做判定,for...in 的耗时大概是 Object.keys 的一半甚至更少。键越多的对象,差距越明显。
但 for...in 有一个非常关键的语义问题:它会遍历原型链上的可枚举属性。举个例子:
js复制const proto = { inheritedKey: 1 };
const obj = Object.create(proto);
Object.keys(obj).length; // 0,自有属性为空
for (const key in obj) { // 会遍历到 inheritedKey
// 会进入这里
}
如果用 for...in 不加判断,Object.create({ x: 1 }) 这种对象会被判成非空,但它的自有属性其实是空的。工程上更严谨的写法要配合 hasOwnProperty:
js复制var isEmpty = function (obj) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
}
return true;
};
在力扣 2727 的测试用例里,传进来的基本都是普通对象字面量或 Object.create(null),原型链问题不常见,所以不少题解直接省略 hasOwnProperty 也能通过。但你自己写的时候一定要明白这层含义。
另外说一句,for...in 对 null 和 undefined 在浏览器和 Node 的现代引擎中并不会抛错,而是直接跳过循环体——这是规范里 for...in 对空值比较宽容的行为。但这属于语言细节,我建议代码里还是显式判空,不要依赖这种隐式行为,万一遇到老环境就说不准了。
2.3 JSON.stringify:看起来聪明,实则是个坑
第三种解法在社区里流传很广:
js复制var isEmpty = function (obj) {
return JSON.stringify(obj) === '{}' || JSON.stringify(obj) === '[]';
};
写法上确实很“通吃”,对象返回 '{}',数组返回 '[]',一个判断全部解决。我第一次看到这个写法时也觉得挺巧,但后来在实际项目里被它坑过,从此再也不敢用它判空。
问题出在 JSON.stringify 的序列化规则上。它会把对象里的 undefined、函数、Symbol 值直接忽略。也就是说:
js复制JSON.stringify({ a: undefined }); // '{}'
JSON.stringify({ a: function () {} }); // '{}'
JSON.stringify({ [Symbol('a')]: 1 }); // '{}'
这三个对象都有键,按题目语义“没有键值对才是空”来说,它们都不是空对象。但用 JSON.stringify 判断,结果全是空。
更麻烦的是循环引用。JSON.stringify 遇到循环引用会直接抛 TypeError,一个普通业务对象里如果存在循环引用,你用这个解法判空,代码当场崩溃。
再加上性能因素:JSON.stringify 要做的是完整序列化——递归遍历所有属性、处理字符串转义、拼接字符串。仅仅为了判断“有没有键”,做一整轮全量序列化,性价比低到离谱。对一个 10 万元素的数组做 JSON.stringify,耗时大概是从几十毫秒到上百毫秒,而直接读 length 是纳秒级的。力扣题解区关于这个解法会不会超时的讨论一直没停过,我自己在本地测试的感觉是:小对象无所谓,大对象差距非常明显。
所以我的结论很直接:刷题别用 JSON.stringify 判空,工程里更别用。 它的能力边界和错误语义会让代码变得不可预测。
2.4 数组的正确答案:length 与稀疏数组陷阱
题目里明确区分了对象和数组,空数组的定义是“没有元素”。那数组判空最自然的写法就是:
js复制if (Array.isArray(obj)) {
return obj.length === 0;
}
为什么数组一定要单独用 length,不能统一走 Object.keys?因为稀疏数组会骗你。
js复制const arr = new Array(3);
arr.length; // 3
Object.keys(arr); // [],因为空洞没有可枚举索引属性
ARR;
JSON.stringify(arr); // '[null,null,null]'
new Array(3) 创建了一个长度为 3 但有 3 个空洞的数组。它的 length 是 3,显然不是空数组;但 Object.keys(arr) 返回 [],如果按 Object.keys 的结论,你会把长度 3 的数组当成空数组。for...in 也差不多,因为它只遍历可枚举索引属性,空洞没有属性,所以也会判断为空。
只有 length === 0 才是数组判空的语义正确解法。
另一个需要注意的细节是 Array.isArray 和 typeof 的区别。typeof [] 返回 'object',所以不能用 typeof 判断数组。遇到跨 iframe 或者跨 realm 的场景,instanceof Array 也可能失效,Array.isArray 是最稳的。
3. 实测性能对比:为什么序列化被按在地上摩擦
光说“性能差”没有说服力,我贴一份自己跑的简单测试,你可以直接复制到本地跑,结果会更直观。
js复制function bench(name, fn, target) {
const times = [];
for (let round = 0; round < 5; round++) {
const start = performance.now();
for (let i = 0; i < 1000; i++) {
fn(target);
}
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
console.log(`${name}: ${times[0].toFixed(2)}ms (best of 5)`);
}
// 构造一个 10 万键的对象
const bigObj = {};
for (let i = 0; i < 100000; i++) {
bigObj['key' + i] = i;
}
const isEmptyKeys = (obj) => Object.keys(obj).length === 0;
const isEmptyForIn = (obj) => {
for (const key in obj) {
return false;
}
return true;
};
const isEmptyStringify = (obj) => JSON.stringify(obj) === '{}';
// 构造一个大数组
const bigArr = new Array(100000).fill(0);
const isArrEmptyLength = (arr) => arr.length === 0;
const isArrEmptyStringify = (arr) => JSON.stringify(arr) === '[]';
同一台机器上,取最好的三组结果,大致是这样:
| 场景 | 方案 | 相对耗时 |
|---|---|---|
| 10 万键对象 | Object.keys |
基准(约 10~20ms) |
| 10 万键对象 | for...in |
约为 Object.keys 的一半 |
| 10 万键对象 | JSON.stringify |
约为 Object.keys 的 5~10 倍 |
| 10 万元素数组 | length 判空 |
约 0.01ms 级别 |
| 10 万元素数组 | JSON.stringify 判空 |
几十毫秒级别 |
为什么 JSON.stringify 这么慢?因为它不只是“看一下对象长什么样”,而是要把整个对象图完整地序列化成字符串。字符串拼接本身就是有成本的,遇到字符串值还要处理转义,遇到嵌套对象要递归,遇到数组要逐元素处理。你只是想知道“这对象里有没有键”,它却帮你把整个对象打印了一遍,属于典型的杀鸡用牛刀。
Object.keys 虽然也遍历所有键,但它只收集键名,不处理值,所以比 JSON.stringify 快得多。for...in 更快的原因在于短路——大部分非空对象第一个键就能返回,连收集键名这一步都省了。
我还遇到过一种脑洞大开的写法:“ JSON.stringify(obj).length <= 2 ”。这种写法连 JSON.stringify 返回 'null' 的情况都没考虑,还硬编码了 2 这个长度,属于把脆弱性拉满,在这里顺带提一下,希望看到的人不要学。
性能层面我的建议是:
- 普通对象判空:优先
for...in,加hasOwnProperty更严谨。 - 数组判空:无脑
length === 0,想都不用想。 - 任何场景都别用
JSON.stringify做布尔判断。
4. 边界情况大扫雷:null、Symbol、不可枚举、Map/Set、Date
这一节是最有价值的。面试问“对象判空”时,面试官真正想听的往往不是你会写 Object.keys,而是你能不能把边界情况讲清楚。我把常见的边界输入和不同写法的结论整理成一张表:
| 输入 | 语义上是否为空 | Object.keys 结论 |
for...in 结论 |
JSON.stringify 结论 |
推荐做法 |
|---|---|---|---|---|---|
{} |
是 | 空 | 空 | 空 | 任意 |
[] |
是 | 空 | 空 | 空 | length === 0 |
{ a: undefined } |
否(有键 a) |
非空 | 非空 | 误判为空 | Object.keys 或 for...in |
new Array(3) |
否(length 为 3) |
误判为空 | 误判为空 | 非空 | length === 0 |
null |
视定义,通常当作无键值对 | 抛 TypeError | 跳过循环体 | 返回 'null',误判非空 |
先显式判空 |
Object.create({ x: 1 }) |
自有属性为空 | 空 | 非空(会遍历到原型) | 空 | 按业务选择是否加 hasOwnProperty |
{ [Symbol('a')]: 1 } |
否(有键) | 误判为空 | 误判为空 | 误判为空 | Reflect.ownKeys |
| 只有不可枚举键的对象 | 视定义 | 误判为空 | 误判为空 | 误判为空 | Object.getOwnPropertyNames |
下面挑几个重点展开。
4.1 null 和 undefined
力扣的类型签名里 JSONValue 包含了 null,所以 isEmpty(null) 是一个可能被测试到的输入。按“没有键值对即为空”的语义,null 应该返回 true 还是 false?
这就出现了一个很有意思的分歧:
Object.keys(null)会抛 TypeError,所以这个解法在遇到null时直接崩。JSON.stringify(null)返回字符串'null',不等于'{}'或'[]',于是会被判成非空。for...in null在浏览器和 Node 中不会抛错,循环体不执行,于是会判成空。
我个人处理这类工具函数时,习惯把 null 和 undefined 统一视为空。理由很简单:工具函数追求语义一致性,“没有数据”就是空,这在业务里不容易出错。但你在刷力扣时,要以题目的实际测试为准,如果题目没有覆盖 null,你选哪种都无所谓;如果覆盖了,最好用显式判空来保证结果可控。
最稳的开头长这样:
js复制if (obj == null) return true;
注意这里用的是 == null,它同时覆盖 null 和 undefined,这是刻意为之的写法,不是随手写的。
4.2 Symbol 键和不可枚举键
Object.keys 只看可枚举的、字符串类型的自有键,所以 Symbol 键和不可枚举键都会被忽略。{ [Symbol('a')]: 1 } 用 Object.keys 判断是空,但它确实有一个键值对。
如果你要严格判断“对象是否存在任何自有属性”,应该用 Reflect.ownKeys:
js复制Reflect.ownKeys({ [Symbol('a')]: 1 }).length; // 1
Reflect.ownKeys({}).length; // 0
Reflect.ownKeys 返回所有自有键,包括字符串键和 Symbol 键,包括可枚举和不可枚举的。它比 Object.getOwnPropertyNames 更全面,也比手动组合 Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj)) 更简洁。
但 Reflect.ownKeys 对数组有一个很隐蔽的坑:它会把 length 也算进去。
js复制Reflect.ownKeys([]).length; // 1,返回 ['length']
Reflect.ownKeys([1, 2]).length; // 3,返回 ['0', '1', 'length']
所以 Reflect.ownKeys 不能直接用来判断数组是否为空,数组要单独走 length。这和我前面说的“数组和对象要区分处理”是同一件事。
4.3 函数、Map、Set、Date 这类“非普通对象”
虽然 2727 的类型签名里只包含对象和数组,但工程上的 isEmpty 工具函数往往要接收 unknown。这时候你还会遇到:
- 函数。
typeof fn === 'function',Object.keys(fn)返回[],因为函数的length属性(形参个数)和name属性都是不可枚举的。但你能说一个“有代码的函数”是空对象吗?显然不能,函数本身是有意义的对象。工程上要么排除函数类型,要么对函数返回一个明确的策略。 Map和Set。Object.keys(new Map([['a', 1]]))返回[],但map.size是 1。Map 和 Set 是容器,判断它们是否为空应该用size,不能用Object.keys。业务代码里我见过有人用Object.keys(new Map())判断 Map 为空,结果发现永远都是空,查了半天才发现是思路错了。Date。Object.keys(new Date())返回[],但JSON.stringify(new Date())会输出 ISO 日期字符串。如果用JSON.stringify(date) === '{}'去判断日期对象,结果也是 false。Date 本身没有“空”的语义,最合理的做法是在isEmpty里明确不处理它,或者在文档里写清楚调用方不应该传 Date。
有 toJSON 方法的自定义对象也是坑。JSON.stringify 会调用对象的 toJSON 方法,如果它返回 null,序列化结果就是 'null',你的判空代码会得到一个意料之外的结果。
4.4 原型链继承的属性
Object.create({ inheritedKey: 1 }) 这个对象,自有属性为空,但原型链上有一个可枚举属性。Object.keys 只看自有属性,所以判断为空;for...in 会遍历原型链,所以判断为非空。
哪个更正确?取决于你的业务语义。力扣的测试用例里基本不会出现这种带原型链属性的对象,因为题目的 JSONValue 类型限定了普通对象和数组。但工程上如果遇到一个从别处继承属性的对象,你需要先明确“空”到底指“没有自有属性”还是“没有可见属性”。
通常我会默认“没有自有属性”才是空,所以 for...in 里要加上 hasOwnProperty 判断。这一点在写工具函数时必须心里有数。
5. 工程落地:从力扣到生产环境的 isEmpty
刷题最终要落回工程。我给你一套可以直接用到项目里的 isEmpty 工具函数写法,它综合了前面所有讨论的结论:
ts复制function isEmpty(value: unknown): boolean {
// 1. 空值统一视为空
if (value == null) return true;
// 2. 数组和字符串用 length 判断
if (typeof value === 'string' || Array.isArray(value)) {
return value.length === 0;
}
// 3. Map 和 Set 用 size 判断
if (value instanceof Map || value instanceof Set) {
return value.size === 0;
}
// 4. 其他对象用 Reflect.ownKeys 判断自有属性数量
if (typeof value === 'object') {
return Reflect.ownKeys(value).length === 0;
}
// 5. 数字、布尔值、函数等类型,不视为“具有空对象语义”,返回 false
return false;
}
这个版本的几个设计决策说明一下:
第一,数组单独走 length。前面已经反复强调,稀疏数组会让 Object.keys 和 for...in 双双失明,只有 length 是可靠信号。
第二,Map 和 Set 单独走 size。这是工程上最容易漏掉的一点。new Map([['a', 1]]) 在 Object.keys 眼里永远是空数组,但它里头有数据。如果你在一个数据处理管道里用它判空,会直接导致数据被错误丢弃。
第三,普通对象用 Reflect.ownKeys 判断自有属性数量。它能看到 Symbol 键、不可枚举键,比 Object.keys 更全面。唯一要注意的是它不能用在数组上,这里已经分流出去了,所以不会踩到 length 被算进去的坑。
第四,对函数、数字、布尔值返回 false。一个函数不是“空对象”,一个数字也不是。工具函数有自己的语义边界,不做无谓的猜测。
如果你需要兼容不支持 Reflect.ownKeys 的老环境,可以用 Object.getOwnPropertyNames 加 Object.getOwnPropertySymbols 代替:
js复制function getOwnKeys(obj) {
return Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(obj));
}
两者的行为在 ES6 环境里基本等效。
力扣 2727 还有一个很妙的点是,它不给你任何 lodash 之类的库。于是你被迫去思考纯 JavaScript 的解决方案。很多刷题的人第一反应是“这不就一行吗”,第二反应是“怎么有这么多解法”,第三反应是“原来这些解法结论还不一样”。这三个反应走下来,基本功就扎实了一大截。
如果你在刷题顺序上刚好走到力扣的 JavaScript 专项,我的建议是别小看这些简单题。2700 多号的这类题,每一道都精准地踩在一个 JavaScript 特性上。把 Object.keys、Reflect.ownKeys、Map、Set、Array.isArray、JSON.stringify、for...in 这些 API 的边界全部搞清楚,比盲目刷十道中等题更有价值。这也是进大厂面试时手写工具函数题的核心竞争力。
最后说一个我自己的使用习惯。我在代码里通常不单独写 isEmpty(obj) 一把梭,而是按场景拆成 isEmptyObject、isEmptyArray、isEmptyCollection 之类的明确函数。原因很简单:语义越明确,调用方越不容易用错。力扣题可以追求一行写完,工程代码追求的是让下一个人读代码时,一眼就能看出你在判断什么。这也是我从这道题里得到的最重要的一条经验。
