做前后端联调的时候,我经常遇到一种“数据神秘消失”的情况:对象在浏览器 console 里明明能看到某个字段,用 for 循环遍历它却不出现,Object.keys 里没有它,展开复制一份之后它消失,JSON.stringify 出来更是直接被过滤掉。第一次遇到时我以为是自己手滑删了字段,查了很久才发现问题出在 JavaScript 属性的可枚举性(enumerable)上。
这个话题平时不太起眼,但只要你写过稍微复杂的对象、做过数据拷贝、处理过接口序列化,就一定会踩到它。它决定了一个属性在遍历、枚举、拷贝和序列化这些操作里是“被看见”还是“被忽略”。下面我把这些年踩过的坑和整理出来的规则一次讲清楚,适合所有写 JavaScript 的开发者,尤其是被 for...in 和 JSON.stringify 折磨过的朋友。
1. 可枚举性不是玄学:属性描述符里的 enumerable 开关
1.1 对象的属性并不是一个简单的“键值对”
很多人理解 JavaScript 对象时,脑海里只有“一个 key 对应一个 value”。实际上,ES5 之后每个对象属性背后都挂着一组属性描述符(Property Descriptor),包含 value、writable、enumerable、configurable 四个布尔或值类型的配置,如果是访问器属性还会有 get 和 set。
enumerable 翻译过来是“可枚举”,含义很直接:这个属性是否会被某些遍历或者序列化机制纳入范围。它只是控制“是否被列举”,并不表示“能不能访问”。一个 enumerable: false 的属性,你依然可以正常读取,比如 obj.secret 依然有值,'secret' in obj 也返回 true,但只要遇到遍历、Object.keys、展开拷贝、JSON.stringify 这些操作,它就会被静默忽略。
看一下最典型的入门陷阱:
js复制const obj = {};
// 注意:我故意没有给 enumerable 赋值
Object.defineProperty(obj, 'token', {
value: 'abc123'
});
obj.name = 'test';
console.log(obj.name); // 'test'
console.log(obj.token); // 'abc123'
console.log(Object.keys(obj)); // ['name']
console.log(JSON.stringify(obj)); // '{"name":"test"}'
看到差别了吗?token 明明就在对象上,可读也能访问,但枚举和序列化时直接不带你玩。原因是 Object.defineProperty 创建的属性,在没有显式指定某个标志时,enumerable、writable、configurable 默认都是 false,这一点和“直接赋值”的默认行为截然相反。
1.2 不同创建方式,默认的可枚举状态完全不同
直接赋值是开发中最常见的写对象方式,它的默认属性描述符是:
js复制const user = {};
user.age = 30;
Object.getOwnPropertyDescriptor(user, 'age');
// { value: 30, writable: true, enumerable: true, configurable: true }
看到没,直接赋值出来的属性 enumerable: true。这也解释了为什么普通对象字面量里的属性都能被 JSON.stringify 正常序列化。
但如果用 Object.defineProperty 定义属性时忘了配 enumerable,那这个属性默认就是不可枚举的。这也是不少“数据为什么丢了”案例的根源,不是你逻辑写错了,而是创建属性时埋了雷。
另一个容易混淆的地方是数组和类的方法。
数组的 length 永远是不可枚举的,因为数组长度的变化不应该出现在 for...in 循环里。而 ES6 的 class 方法天然也被设计成不可枚举,原型上的一堆方法不会跑到 for...in 里。反过来,如果你在 ES5 时代习惯用构造函数,然后给 prototype 手动挂载函数,那这些函数默认是可枚举的,子孙实例用 for...in 就能“继承”到它们,这就是老代码里常见的坑。
for...in遍历数组时,得到的 key 是字符串形态的下标,如果数组对象上还有其他可枚举的自定义属性,它们也会被遍历到。所以处理数组尽量别用for...in,这点后面会细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 遍历对象不能只看 for...in:六个 API 各有各的“可见范围”
2.1 先分清“自己的属性”和“继承的属性”
“可枚举”不是唯一的筛选维度,遍历对象时还要区分这个属性是对象自己的,还是通过原型链继承来的。比如:
js复制function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {};
const p = new Person('Amy');
// for...in 会把原型上可枚举的 sayHello 也带出来
for (const key in p) {
console.log(key); // name, sayHello
}
// Object.keys 只返回自身可枚举属性
console.log(Object.keys(p)); // ['name']
for...in 设计之初就是“遍历所有可枚举属性,包含原型链上的”,但今天绝大多数场景下我们不希望遍历到原型链上的东西。如果你确实要用 for...in,务必要配合 Object.prototype.hasOwnProperty 做一次过滤:
js复制for (const key in p) {
if (Object.prototype.hasOwnProperty.call(p, key)) {
console.log(key); // name
}
}
有些同学图省事直接写 p.hasOwnProperty(key),但如果你创建一个原型为 null 的对象 Object.create(null),或者对象本身定义了同名冲突属性,代码就会报错或行为异常。用 Object.prototype.hasOwnProperty.call(p, key) 是最稳的写法。
2.2 六个遍历相关 API 到底会拿到哪些属性
我把常见 API 整理了一张表,标注它们各自的范围,方便直接对照:
| 方法或语法 | 是否包含自身属性 | 是否包含继承属性 | 是否包含不可枚举属性 | 是否包含 Symbol 键 |
|---|---|---|---|---|
for...in |
是 | 是 | 否 | 否 |
Object.keys / values / entries |
是 | 否 | 否 | 否 |
Object.getOwnPropertyNames |
是 | 否 | 是 | 否 |
Object.getOwnPropertySymbols |
是 | 否 | 是(只包含 Symbol 键) | 是 |
Reflect.ownKeys |
是 | 否 | 是 | 是 |
for...of |
与可枚举性无关,依赖对象的迭代器 | 与可枚举性无关 | 与可枚举性无关 | 与可枚举性无关 |
Object.values 和 Object.entries 本质上是 Object.keys 的延伸,它们只认“自身可枚举的字符串键属性”。要注意的是,Object.entries 不包含 Symbol 键,也不包含不可枚举属性。
Object.getOwnPropertyNames 会把不可枚举的属性也带回,但仍不包含 Symbol 键。Reflect.ownKeys 则是“通吃”,它相当于把字符串键和 Symbol 键全部放到一个数组里返回,无论是否可枚举。因此判断一个对象是否“真的空无一物”,不要只依赖 Object.keys,应该用 Reflect.ownKeys:
js复制const obj = {};
Object.defineProperty(obj, 'hidden', { value: 1 });
console.log(Object.keys(obj).length === 0); // true,给了你错误的安全感
console.log(Reflect.ownKeys(obj).length === 0); // false
2.3 数组遍历的专属坑:for...in 会把非数字键也带出来
数组也是一种对象,所以你可以往数组上挂自定义属性。比如给数组加一个 name,然后再用 for...in 去遍历时,会收获意外惊喜:
js复制const list = ['a', 'b'];
list.name = 'letters';
for (const key in list) {
console.log(key); // '0', '1', 'name'
}
for (const value of list) {
console.log(value); // 'a', 'b'
}
for...in 遍历数组时拿到的是字符串键,所以常见做法里还会有人把 key 直接用做下标去取值,比如 list[key],这通常没问题,但一旦数组对象上还有别的方法或自定义可枚举属性,就会产生莫名其妙的输出。数组要遍历元素时,首选 for...of,或者配合 forEach、map 这类原生方法,它们不会把自定义属性“送”到你面前。
3. 拷贝对象时,不可枚举属性大概率会静默缺失
3.1 展开运算符和 Object.assign 只复制“自身可枚举属性”
浅拷贝对象最常用的手段是展开运算符 { ...obj } 和 Object.assign(target, source)。它们复制属性的条件是“源对象自身可枚举属性”,所以不可枚举属性在浅拷贝过程中会被静默丢掉:
js复制const source = {};
Object.defineProperty(source, 'id', {
value: 'secret',
enumerable: false
});
source.visible = true;
const spreadCopy = { ...source };
const assignCopy = Object.assign({}, source);
console.log(spreadCopy); // { visible: true }
console.log(assignCopy); // { visible: true }
这直接影响到了类实例的拷贝。类的实例对象里,方法挂在原型上,不在实例自身;如果用展开语法去复制一个实例,你得到的往往只是一个残缺的“普通对象”,原型链、方法、以及用 defineProperty 定义在实例上的内部字段全部丢失:
js复制class Counter {
constructor(count) {
// 这个内部 id 不希望在遍历时暴露
Object.defineProperty(this, 'instanceId', {
value: crypto.randomUUID(),
enumerable: false
});
this.count = count;
}
increment() {
this.count += 1;
}
}
const counter = new Counter(1);
const copy = { ...counter };
console.log(copy); // { count: 1 }
console.log(copy.instanceId); // undefined
console.log(typeof copy.increment); // undefined
如果你本来就没打算保留类的行为,只是想把数据提取出来作为某个接口的 payload,那这种“丢失”反而很合适。但如果你的业务逻辑是需要在拷贝后继续调用方法,那一定要用专门的复制方式,比如保留原类实例的克隆方法:
js复制class Counter {
constructor(count) {
Object.defineProperty(this, 'instanceId', {
value: crypto.randomUUID(),
enumerable: false
});
this.count = count;
}
clone() {
const clone = new Counter(this.count);
// 按照你需要的方式复制内部字段
Object.defineProperty(clone, 'instanceId', {
value: this.instanceId,
enumerable: false
});
return clone;
}
}
3.2 访问器属性不是“值”,拷贝时会被读取成普通值
还有一类坑藏在 getter 里。考虑以下对象:
js复制const person = {
firstName: 'San',
lastName: 'Zhang',
get fullName() {
return `${this.lastName}${this.firstName}`;
}
};
const copy = { ...person };
console.log(copy.fullName); // 'ZhangSan'
console.log(Object.getOwnPropertyDescriptor(copy, 'fullName'));
// { value: 'ZhangSan', writable: true, enumerable: true, configurable: true }
person.fullName 原本是访问器属性,展开复制后,它变成了一个普通的 value 字段,保存的是 getter 执行结果。之后如果我再修改 copy.firstName,copy.fullName 并不会跟着变,因为它已经不是 getter 了。如果业务依赖动态计算,浅拷贝会导致行为不一致。
同样地,如果浅拷贝源对象里的某个属性带有 setter,而我们在复制时使用 Object.assign 去触发 setter,那行为也有可能会出乎意料。简单总结是:浅拷贝只负责“读值并赋过去”,对象里关于访问器、描述符这些附加信息,它不负责维护。
3.3 深拷贝更不是万能药:JSON 和 structuredClone 各有限制
很多人为了避免浅拷贝丢失数据,直接使用 JSON.parse(JSON.stringify(obj)) 做深拷贝。这个方案天然受限于 JSON 的序列化规则,前面说过于“可枚举性”的坑在这里同样生效,甚至更多:
js复制const data = {
name: 'demo',
hidden: undefined,
handler() {}
};
const copy = JSON.parse(JSON.stringify(data));
console.log(copy); // {}
因为 undefined、函数、Symbol 值本身就不会被 JSON 序列化成属性。遇到循环引用还会直接抛错:
js复制const data = {};
data.self = data;
JSON.parse(JSON.stringify(data)); // 抛出错误
现代运行环境里,structuredClone 是一个更好的深拷贝选择,它能保留 Date、Map、Set、ArrayBuffer、循环引用等复杂结构,也不会执行对象里的 toJSON 方法。但要注意,它同样不能很好处理函数和 class 实例,它会拷出一堆普通的可枚举数据字段,但类的方法和原型上的东西通常不会保留。所以拷贝类实例的可靠思路始终是:优先考虑在类内部提供明确的数据导出方法,而不是指望某个万能深拷贝函数。
4. JSON.stringify 的过滤规则比你想的更细
4.1 JSON.stringify 只输出自身可枚举属性
JSON.stringify 是前后端对接时最容易“吞字段”的地方。这个函数内部的序列化规则,对普通对象而言本质上是:“遍历对象的自身可枚举字符串键属性,把值也序列化出来。”因此所有 enumerable: false 的属性天然不会输出:
js复制const user = {};
user.name = 'Lily';
Object.defineProperty(user, 'password', {
value: '123456',
enumerable: false
});
console.log(JSON.stringify(user));
// '{"name":"Lily"}'
有时候这反而是好事。比如密码、签名、内部缓存这些字段,你本来就不希望它们被打包进请求体。用 enumerable: false 还能保持开发时能正常读取,调用 Object.keys 或 JSON 时又不会漏出,相当于给数据加了层“逻辑上的访问白名单”。
但是反过来,如果你确实需要把这些字段序列化出去,那就别把希望寄托在“再设置一次 enumerable”这种临时修改上,应该老老实实构造 payload:
js复制const user = {};
Object.defineProperty(user, 'password', {
value: '123456',
enumerable: false
});
const payload = {
name: 'Lily',
password: user.password
};
console.log(JSON.stringify(payload));
// '{"name":"Lily","password":"123456"}'
4.2 值类型为 undefined、函数、Symbol 时,即使可枚举也不会输出
很多人把“字段没序列化出来”直接归因于可枚举性,其实不够全面。JSON.stringify 对于值类型不合法的情况也是静默忽略,最常见的三种是 undefined、函数、Symbol:
js复制const obj = {
a: undefined,
b() {},
c: Symbol('c'),
d: 1
};
console.log(JSON.stringify(obj));
// '{"d":1}'
规则细看还有差异:如果这些特殊值出现在数组里,行为又不一样:
js复制const arr = [undefined, function () {}, Symbol('c'), 1];
console.log(JSON.stringify(arr));
// '[null,null,null,1]'
数组元素里的非法 JSON 值会被替换成 null,而对象属性里的则直接消失。这解释了为什么用 JSON.stringify 深拷贝数组时,数组里的空位或者 undefined 会被悄悄变成 null。
4.3 数组的额外可枚举属性在 JSON.stringify 中不会生效
数组本身也继承对象的特性,可以添加可枚举属性。但 JSON.stringify 遇到数组时,走的是数组序列化逻辑,只按索引和长度输出,额外的自定义可枚举属性不会输出:
js复制const list = ['a', 'b'];
list.custom = 'hello';
console.log(JSON.stringify(list));
// '["a","b"]'
console.log(Object.keys(list));
// ['0', '1', 'custom']
这个差异在联调时很容易造成认知错乱:你用 Object.keys(list) 明明看到了 custom 字段,想着转成 JSON 也一样有,结果服务端就是收不到。此时不要花时间找网络问题,先检查是不是数组自定义属性导致的。
4.4 toJSON 是序列化的最后一道闸门
当对象自身定义了 toJSON 方法时,JSON.stringify 会优先调用它,并把返回值作为序列化的基础。这个方法非常有用,适合做字段裁剪和格式转换:
js复制const user = {
id: 1,
password: '123456',
toJSON() {
return { id: this.id };
}
};
console.log(JSON.stringify(user)); // '{"id":1}'
注意:toJSON 方法本身如果定义在对象上且可枚举,它作为一个函数值也不能被序列化,所以不会出现在 JSON 输出里。如果它在原型上且不可枚举,那更不会影响遍历。toJSON 的设计初衷就是让对象在被序列化时拥有自定义行为,它是做 DTO(数据传输对象)和字段裁剪的利器。
5. 实际开发里的可枚举性设计建议与问题排查清单
5.1 尽量不要随手用 defineProperty 隐藏字段
把内部字段设置成不可枚举,有时候只是为了让 console 或循环列表看起来干净。但这么做有一个代价:这些字段一旦经历拷贝、深拷贝或者 JSON 序列化,就很容易被悄悄丢掉。如果别人拿到你的对象继续做二次处理,往往会对字段丢失毫无心理准备。
我现在的习惯是,能不用就尽量不用 Object.defineProperty 去隐藏普通业务字段。若确实要存储一些与对象生命周期绑定的中间数据,优先考虑使用 WeakMap,让这些数据根本不出现在对象表面上:
js复制const internalState = new WeakMap();
class Order {
constructor(orderNo) {
this.orderNo = orderNo;
internalState.set(this, { createdAt: Date.now() });
}
get createdAt() {
return internalState.get(this).createdAt;
}
}
优点很明显:WeakMap 的 key 是对象本身,不产生额外属性,也不参与任何遍历、拷贝、序列化。你既不需要担心数据被 JSON 漏掉,也不需要担心它被遍历出来,在数据的隐私性和对象的纯洁性之间取了一个巧妙平衡。
5.2 做一个习惯性动作:先检查属性描述符
如果你排查“为什么字段不见了”的问题,我建议第一步先执行:
js复制Object.getOwnPropertyDescriptors(obj)
这一步能看到对象所有自身属性的完整描述符,包括 enumerable、writable、get 等等,字段是不是不可枚举一眼便知。如果你要看某个键的属性描述符,用 Object.getOwnPropertyDescriptor(obj, 'key')。
如果是数组丢了自定义属性,则可以在序列化前把数组转成普通对象或者改用对象结构,提前规避数组自定义属性被忽略的问题。
5.3 明确“枚举白名单”比隐藏字段更符合数据传输直觉
接口联调时代码总是越透明越好。与其隐藏字段、在序列化时一不小心漏掉数据,不如在源头就约定好哪些字段需要对外输出。推荐两种做法:
第一种是在对象上提供 toJSON,让序列化时的输出固定为一份白名单:
js复制class Order {
constructor(orderNo, price, couponCode) {
this.orderNo = orderNo;
this.price = price;
this.couponCode = couponCode;
}
toJSON() {
return {
orderNo: this.orderNo,
price: this.price
// couponCode 不会被输出
};
}
}
第二种是在拷贝或上传前,显式构造一个小的 DTO 对象,不使用通用浅拷贝或 JSON 深拷贝:
js复制function toCreateOrderRequest(order) {
return {
orderNo: order.orderNo,
price: order.price
};
}
显式构造的代码看起来多几行,但可读性是最好的。别人看代码的时候,一眼就能知道这个接口会传哪些字段,而不是被隐式规则绕晕。
5.4 判断属性是否为“自身可枚举属性”
有时候你写遍历逻辑时需要精确判断某个属性是否可枚举,推荐直接使用 propertyIsEnumerable:
js复制const obj = {};
Object.defineProperty(obj, 'a', { value: 1, enumerable: true });
Object.defineProperty(obj, 'b', { value: 2 });
console.log(Object.prototype.propertyIsEnumerable.call(obj, 'a')); // true
console.log(Object.prototype.propertyIsEnumerable.call(obj, 'b')); // false
同样,检测一个对象是否为空时,请谨慎使用 Object.keys(obj).length === 0 作为唯一判断。尤其是面对可能带 Symbol 键或不可枚举属性的对象时,推荐使用 Reflect.ownKeys(obj).length。
6. 最后分享一段我认为最有用的经验:在设计对象时,先想好“这个字段会经历哪些操作”
可枚举性的坑很难靠一次记住所有 API 规则来完全规避,因为真正决定你踩不踩坑的,是你设计对象时有没有提前想到它未来会被哪些工具消费。
比如一个字段如果不是为了传输,而是纯粹的内部状态,那它就应该用 WeakMap、闭包或 symbol 等方式封装,而不是让它作为一个可见字符串键出现在对象上。一个字段如果是为了给后端使用,那就别把它设置成不可枚举,更不要指望展开运算符或 JSON 序列化帮你把隐藏字段带过去。数据要出现在 JSON 里,就必须让它自己是“可枚举的字符串键”。
说实话,我自己在很长一段时间里,也没有养成先检查属性描述符的习惯。直到有一次在做文件上传任务分片时,我用 defineProperty 给任务对象塞了一个“分片序号”,然后在构造请求参数时用展开运算符复制任务对象,结果后端反复告诉我缺了分片序号。我对着控制台检查了很久,一直以为代码逻辑有 bug,最后才发现就是那个 enumerable: false 在静默拦截。那次经历之后,我对 JavaScript 的所有“自动复制”“自动序列化”行为都多了一层警惕。
如果你眼前也正有这种“字段看起来在对象里但就是传不过去”的诡异问题,最可能的方向就是:先看看它是不是 Symbol,再看看它是不是不可枚举,再看看这个数据拷贝过程到底走的是 Object.keys、展开、Object.assign、JSON 还是 structuredClone。把这些问题从头捋一遍,你就再也不会被看似无辜的对象坑到了。
