写 JavaScript 对象,大概是我这几年最想系统复盘的一个话题。原因很简单:对象在 JavaScript 里几乎无处不在,普通对象、数组、函数、正则、日期,甚至 null 和 undefined 的诡异行为,全都要跟对象这套机制打交道。但日常开发里真正把对象用明白的人,说实话不多。很多人一提到对象就只知道 const obj = {},但遇到原型链、引用传递、属性描述符、解构赋值的边界情况,立刻就懵了。这篇文章我会基于实际项目里踩过的坑,把 JavaScript 对象从底层原理到实战用法完整拆一遍。内容不只讲概念,更会带出为什么你会遇到“修改对象 A 结果 B 跟着变了”“两个对象明明一模一样却不相等”“JSON.parse(JSON.stringify(obj)) 深拷贝居然丢了字段”这类经典问题。无论你是刚入门的前端新人,还是写了好几年业务代码但从来没系统梳理过对象机制的老手,这篇都应该能帮到你。
1. 对象形态的本质:一切非原始值都是对象,但对象之间千差万别
1.1 包装对象与普通对象的底层差别
先聊一个让很多初学者在控制台里迷惑的现象:'hello'.length 能取到 5,'hello'.toUpperCase() 能直接调用方法,但 'hello' 明明是个字符串原始值,为什么能像对象一样用?
这背后其实是 JavaScript 的自动装箱机制。当你尝试在字符串、数字、布尔这种原始值上访问属性或方法时,引擎会在内部临时把它包装成对应的 String、Number、Boolean 包装对象,操作完成后再销毁。所以你在看《JavaScript高级程序设计》这类书时,会看到“原始值不是对象”的说法,但实际用起来又跟对象很像,原因就在这里。
但包装对象跟普通对象有明显的区别。看这个例子:
javascript复制let str = 'hello';
str.custom = 'world';
console.log(str.custom); // undefined
let num = 42;
num.value = 100;
console.log(num.value); // undefined
严格模式下这两段代码甚至直接报错。原因很简单:你访问 str.custom 时,引擎临时创建了一个包装对象,custom 属性确实挂上去了,但赋值完成后这个临时对象就被回收了。下一次再访问 str.custom,又是全新的临时包装对象,自然找不到之前挂的属性。
而 null 和 undefined 比较特殊,它们没有任何包装形式,一旦尝试读取属性,立刻抛 TypeError。我见过不少线上报错就是发生在 data.user.name 这种链式访问上,接口某个字段没有返回,data.user 是 undefined,再往后访问 .name 就崩了。
1.2 对象是属性的无序集合?这个说法并不准确
很多人背概念时记着一句“对象是属性的无序集合”,实际开发中却经常被顺序问题坑到。这里直接说结论:在 ECMAScript 规范里,对象的属性键分成两类——整数索引键和字符串键(包括 Symbol)。
整数索引键(也就是数组下标形式的键,比如 '0'、'1'、'42')会按照数值升序排列。字符串键则按照属性添加的先后顺序排列。Symbol 键排在所有字符串键之后。
来验证一下:
javascript复制const obj = {};
obj['b'] = 1;
obj['a'] = 2;
obj['2'] = 3;
obj['1'] = 4;
obj['0'] = 5;
console.log(Object.keys(obj));
// ['0', '1', '2', 'b', 'a']
注意,0、1、2 虽然是字符串形式的键,但能被识别成数组索引,所以升序排在前面,'b' 和 'a' 按插入顺序排在后面。如果键是 '01' 这种带前导零的,或者 '1.5' 这种带小数点的,都不会被当作整数索引,会按字符串键的插入顺序处理。
这个特性在某些场景下很重要。比如你从接口拿到一组配置项,想遍历后按固定顺序渲染,如果这组配置是用普通对象存的,且键是 '0'、'1' 这种,遍历结果就不是你插入的顺序。所以设计数据结构时想保持顺序,优先用数组而不是对象。
1.3 为什么 typeof null 是 "object":一个历史遗留问题
typeof null === 'object' 是 JavaScript 里最著名的历史 Bug 之一。纠正一下常见误解:这不是 Brendan Eich 故意设计的,而是第一版引擎实现时,类型判断的低位标记出了问题——null 在内存里是空指针,但类型判断时把全零的低位识别成了对象标记。后来为了兼容已有网页,这个行为一直保留到现在。
这个坑在对象相关操作里的具体表现是:如果你想判断某个变量是不是真正的对象,typeof 是不可靠的,因为 null 会被误判成对象。正确做法是用 Object.prototype.toString.call:
javascript复制function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
console.log(isPlainObject({})); // true
console.log(isPlainObject(null)); // false
console.log(isPlainObject([])); // false
实际上我建议你直接把这个判断封装成工具函数,因为原型链污染、跨 iframe 对象判断、Object.create(null) 创建的空原型对象,这类情况太多了,靠 typeof 或者 value instanceof Object 都扛不住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拷贝陷阱:浅拷贝、深拷贝、引用传递,到底怎么选
2.1 引用赋值揭开“改了一个对象,另一个也变了”的谜底
几乎所有 JavaScript 新手都会遇到这个经典场景:
javascript复制const user = { name: '张三', address: { city: '北京' } };
const copy = user;
copy.name = '李四';
console.log(user.name); // 李四
原因一句话就能讲透:copy = user 并没有复制对象本身,它只把 user 变量里保存的内存地址复制给了 copy。两个变量指向同一个堆内存对象,所以通过任何一方修改属性,另一方看到的都是修改后的结果。
这个坑最隐蔽的地方在于:它不总是生效。看下面这段:
javascript复制const a = { count: 1 };
let b = a;
b = { count: 2 };
console.log(a.count); // 1
console.log(b.count); // 2
原因就在于 b = { count: 2 } 这行是给 b 重新赋值了一个全新的对象,把 b 的指向改了,而 a 仍然指向原来的对象。所以你可以这样记:对象本身存在堆里,变量只是存放地址的标签。标签可以重新贴到别处,但这不影响原来的对象。
2.2 展开运算符拷贝只解决了一层问题
展开运算符 { ...obj } 是如今最常用的浅拷贝手段:
javascript复制const original = { name: '张三', address: { city: '北京' } };
const shallowCopy = { ...original };
shallowCopy.name = '李四';
console.log(original.name); // 张三,第一层修改互不影响
shallowCopy.address.city = '上海';
console.log(original.address.city); // 上海,嵌套对象依然共享
浅拷贝只复制了第一层的属性值。对于嵌套对象,复制过去的是嵌套对象的引用。所以如果你要拷贝的对象里只有纯字符串、数字这类原始值,浅拷贝完全够用;一旦有嵌套对象、数组,就得考虑深拷贝方案。
Object.assign 本质上也是浅拷贝,行为跟展开运算符类似:
javascript复制const target = Object.assign({}, original);
它跟展开运算符最大的区别在于:展开运算符是语法层面创建的,Object.assign 会触发源对象的 setter(如果有),行为差异在特殊 getter/setter 场景下可能产生意想不到的结果。举个直观点的例子:
javascript复制const source = {
get name() {
return '张三';
}
};
const viaSpread = { ...source };
console.log(viaSpread); // { name: '张三' }
// Object.defineProperty(viaSpread, 'name', { value: '张三', ... })
// 注意:展开运算复制的是属性描述符
const viaAssign = Object.assign({}, source);
console.log(viaAssign); // { name: '张三' }
// 但 viaAssign.name 是一个值为 '张三' 的普通数据属性
这种差异在绝大多数业务代码里不会触发,但如果你在写底层库、做数据脱敏、封装响应式系统,就必须留意。
2.3 JSON 深拷贝的三大死穴
JSON.parse(JSON.stringify(obj)) 是前端圈最流行的深拷贝写法。无脑、好用、几行代码搞定。但它有三个非常致命的问题:
第一个:函数、undefined、Symbol 会被直接丢弃。
javascript复制const obj = {
fn: () => console.log('hi'),
undef: undefined,
sym: Symbol('key'),
str: '保留'
};
const result = JSON.parse(JSON.stringify(obj));
console.log(result);
// { str: '保留' }
// fn、undef、sym 全没了
第二个:Date 对象会被转成字符串。
javascript复制const obj = { time: new Date() };
const result = JSON.parse(JSON.stringify(obj));
console.log(result.time);
// "2024-06-01T08:00:00.000Z"
// 变成了字符串,不是 Date 对象
第三个:循环引用直接抛错。
javascript复制const obj = {};
obj.self = obj;
JSON.parse(JSON.stringify(obj));
// TypeError: Converting circular structure to JSON
此外,NaN 和 Infinity 会被转成 null,RegExp 对象会变成空对象 {} 或者字符串(不同引擎行为不太一样)。所以我的建议是:JSON 方案只能用于纯数据对象——没有函数、没有 undefined、没有循环引用、没有特殊内置对象的场景。例如后端 API 返回的 JSON 数据做缓存拷贝,可以放心用。
真正的深拷贝要么自己递归实现,要么借助 structuredClone。structuredClone 是现代浏览器原生提供的深拷贝 API,支持循环引用、支持 Date、RegExp、Map、Set 等类型,用起来非常稳:
javascript复制const obj = { time: new Date(), list: [{ id: 1 }] };
const clone = structuredClone(obj);
console.log(clone.time instanceof Date); // true
不过 structuredClone 仍然不能克隆函数、Symbol、DOM 节点这些,而且兼容性需要根据你的目标浏览器环境判断。
2.4 写一个可靠的深拷贝函数要处理哪些情况
如果项目里不能用 structuredClone,又不想引入 lodash,手写一个够用的深拷贝函数是可以的。但要处理好下面几种情况:
- 先判断循环引用,用 WeakMap 记录拷贝过对象的映射关系
- 区分数组、普通对象、
Date、RegExp、Map、Set - 原型要保留,不能把所有对象都拷成普通
{}
一个相对完整的最小实现大概是这个样子:
javascript复制function deepClone(value, weakMap = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof RegExp) {
return new RegExp(value.source, value.flags);
}
if (value instanceof Map) {
const clonedMap = new Map();
value.forEach((val, key) => {
clonedMap.set(deepClone(key, weakMap), deepClone(val, weakMap));
});
return clonedMap;
}
if (value instanceof Set) {
const clonedSet = new Set();
value.forEach((val) => {
clonedSet.add(deepClone(val, weakMap));
});
return clonedSet;
}
if (weakMap.has(value)) {
return weakMap.get(value);
}
const isArray = Array.isArray(value);
const cloned = isArray ? [] : {};
weakMap.set(value, cloned);
if (isArray) {
value.forEach((item, index) => {
cloned[index] = deepClone(item, weakMap);
});
} else {
Object.keys(value).forEach((key) => {
cloned[key] = deepClone(value[key], weakMap);
});
}
return cloned;
}
我实际项目里大部分场景不走到这一步,因为 lodash 的 cloneDeep 已经非常成熟。但如果公司项目有体积敏感或者不想引入第三方依赖的要求,自己实现这个函数会是个不错的加分项,同时也能帮你把对象、引用、循环引用这些概念彻底打通。
提示:对象拷贝的选型逻辑其实很简单——不打算修改原对象,只是为了传参,用引用即可;要防止外部修改,用浅拷贝;对象内部还有多层嵌套且要完全独立,再上深拷贝。
3. 属性操作细节:增删改查背后的隐藏行为
3.1 使用 in 运算符与 hasOwnProperty 判断属性的本质区别
判断对象里有没有某个属性,习惯好的人会用 in 运算符,但 in 运算符有个坑:它会顺着原型链一路查上去。
javascript复制const parent = { inherited: '父级属性' };
const child = Object.create(parent);
child.own = '自有属性';
console.log('own' in child); // true
console.log('inherited' in child); // true,原型的属性也能查到
console.log(child.hasOwnProperty('own')); // true
console.log(child.hasOwnProperty('inherited')); // false,只会查自身
所以你如果只关心“对象自身有没有这个属性”,就得用 Object.prototype.hasOwnProperty.call(obj, key)。为什么不直接写 obj.hasOwnProperty(key)?因为对象完全可以覆盖掉这个方法:
javascript复制const obj = {
hasOwnProperty: function() {
return false;
},
name: '张三'
};
console.log(obj.hasOwnProperty('name')); // false,被篡改了
console.log(Object.prototype.hasOwnProperty.call(obj, 'name')); // true
用 Object.create(null) 创建的对象连 hasOwnProperty 方法都没有,直接调用就报错。所以稳妥写法永远是 Object.prototype.hasOwnProperty.call(obj, key)。现在也推荐用 ES2022 新增的 Object.hasOwn(obj, key) 静态方法,语义更清晰,不会受对象自身同名属性的影响。
3.2 删除属性时 delete 运算符的边界
delete 运算符用来删除对象自身属性。但有几个边界情况很值得注意:
javascript复制const obj = { a: 1 };
Object.defineProperty(obj, 'b', {
value: 2,
configurable: false
});
console.log(delete obj.a); // true
console.log(delete obj.b); // false,不可配置属性无法删除
属性描述符里的 configurable 为 false 时,删除操作会静默失败(非严格模式),或者抛 TypeError(严格模式)。所以看到别人代码里 delete obj.x 没生效,先别急着怀疑“JavaScript 不能删对象属性”,大概率是 configurable 被设成了 false。
另外 delete 只能删对象属性,不能删局部变量和全局变量(用 var 声明的全局变量在全局对象上其实也是属性,特殊情况可以删,这里不展开)。想释放内存,不是靠 delete 一个对象属性能完成的,而是要确保没有任何引用指向那个对象,触发垃圾回收。
3.3 属性描述符:理解 writable、enumerable、configurable 的实战影响
每个对象的属性不只是一个键值对,它背后还有一套完整描述符。通过 Object.getOwnPropertyDescriptor 能查看到:
javascript复制const obj = { name: '张三' };
console.log(Object.getOwnPropertyDescriptor(obj, 'name'));
// { value: '张三', writable: true, enumerable: true, configurable: true }
普通赋值创建的属性全是 true。但如果你用 Object.defineProperty 创建属性,不显式指定的项默认都是 false。这三兄弟对开发的直接影响非常直接:
writable: false表示属性值不能改。严格模式下赋值直接报错,非严格模式静默失败。用在常量配置对象上很合适。enumerable: false表示该属性不会出现在for...in、Object.keys、展开运算符里。但不能说“访问不到”,直接obj.prop照样能访问。configurable: false表示这个属性不能再被删除、不能修改描述符。一旦设成 false,想改回来都不行,是单向的。
这三个标志配合使用可以做很多有意思的事。比如模拟类里的私有变量:
javascript复制function createCounter() {
let count = 0;
const obj = {
increment() {
count++;
},
get value() {
return count;
}
};
return obj;
}
const counter = createCounter();
counter.increment();
console.log(counter.value); // 1
这虽然没直接用到属性描述符,但暴露出了一个思路:通过闭包把状态保护起来,外部通过暴露的方法读写,对象上只有方法,没有可以直接篡改的 count 属性。这是比给属性设置 writable: false 更彻底的一种隐藏方案,因为你在对象上根本找不到 count。
3.4 遍历对象属性的完整方案
遍历对象方法很多,每一种的行为其实都不太一样:
| 方法 | 是否遍历自身 | 是否遍历继承属性 | 是否包含 Symbol | 是否包含不可枚举属性 |
|---|---|---|---|---|
for...in |
是 | 是 | 否 | 否 |
Object.keys() |
是 | 否 | 否 | 否 |
Object.getOwnPropertyNames() |
是 | 否 | 否 | 是 |
Object.getOwnPropertySymbols() |
是 | 否 | 是 | 是 |
Reflect.ownKeys() |
是 | 否 | 是 | 是 |
实际开发里最常用的是 Object.keys 配 for...of 或 forEach。不过有个细节可能很多人没注意:for...in 遍历出的属性顺序并不是简单的插入顺序,而是上面那一套“整数索引优先、字符串按插入序”的规则。而 Object.keys 的顺序规则也是同一套。如果你对顺序敏感,遍历数组才是更可控的选择。
Symbol 作为属性键时,Object.keys 和 for...in 都拿不到。想拿到 Symbol 键,必须用 Object.getOwnPropertySymbols 或 Reflect.ownKeys。以前端框架的状态管理为例,很多内部扩展机制都靠 Symbol 或者不可枚举属性来隐藏内部数据,避免业务遍历时污染逻辑。
4. 原型链与 this:对象方法中让人头疼的两件套
4.1 通过 new Function 理解 new 构造的整个过程
new 运算符做了哪些事情,不能只看课本上的三句话,要在对象创建的上下文里去理解。当你写 new Foo() 时:
- 创建一个全新的对象
obj - 把
obj的原型链指向Foo.prototype - 以
obj作为this执行Foo的构造函数体 - 如果构造函数没返回对象,自动返回
obj
第 4 点很容易忽略。如果构造函数里显式返回了一个对象,那么这个对象会覆盖掉创建的新对象。比如:
javascript复制function Person(name) {
this.name = name;
return { custom: true };
}
const p = new Person('张三');
console.log(p.name); // undefined
console.log(p.custom); // true
构造函数返回原始值(return 1 这种)则不会影响新对象的返回。这个机制在实现某些“工厂模式”或者隐藏内部数据时会很实用,但如果你没意识到,只会在排查 bug 时陷入困惑。
4.2 原型链查找:属性访问的冒泡机制
原型链本质上是对象之间的一种关联关系。当你访问 obj.prop 时,引擎先看看 obj 自身有没有 prop;没有,就去 obj 的原型对象上找;再没有,就继续往原型的原型上找,直到 null 为止。
javascript复制const animal = {
kind: '动物',
eat() {
console.log('正在吃');
}
};
const dog = Object.create(animal);
dog.name = '旺财';
console.log(dog.name); // 旺财,自身属性
console.log(dog.kind); // 动物,来自原型
console.log(dog.eat()); // 正在吃,来自原型的方法
你可以在控制台里用 dog.__proto__ 看到它的原型对象就是 animal。但 __proto__ 这个东西一开始并没有被写进标准,它是各浏览器自己实现的,后来才被作为遗留特性纳入规范。日常代码建议使用 Object.getPrototypeOf(dog) 来获取原型,用 Object.setPrototypeOf 来设置原型。
原型链最常见的实际应用就是给所有数组实例提供方法。所有数组的 map、filter、reduce 都存在 Array.prototype 上,你创建的新数组 const arr = [1, 2, 3],数组对象自身只存了元素,但访问 arr.map 时会沿着原型链找到 Array.prototype.map,所以任何一个数组都能用这套通用方法。
4.3 this 绑定的四种规则与箭头函数的例外
对象方法里的 this 指向谁,本质是看函数“怎么被调用”的。规则就四条:
- 默认绑定:普通函数独立调用,
this指向全局对象(严格模式下是undefined)。 - 隐式绑定:通过对象调用方法
obj.fn(),this指向obj。 - 显式绑定:
call、apply、bind手动指定this。 - new 绑定:作为构造函数使用时,
this指向新创建的对象。
对象相关的坑多数出在隐式绑定和默认绑定之间的切换上:
javascript复制const user = {
name: '张三',
sayName() {
console.log(this.name);
}
};
const getUserName = user.sayName;
getUserName();
// 严格模式下报错,非严格模式下 undefined/全局对象
// 因为这里相当于独立调用,this 没绑定到 user 上
有一个非常经典的对象方法提取问题——把对象方法赋值给变量再调用,this 就丢了。React 老版本事件处理里特别常见:
javascript复制class Button extends React.Component {
handleClick() {
console.log(this);
}
render() {
return <button onClick={this.handleClick}>点击</button>;
// 这里 handleClick 被作为回调函数传递了,this 丢失
}
}
修正方式有三种:箭头函数声明类方法、在构造器里 this.handleClick = this.handleClick.bind(this)、或者在 JSX 里写 onClick={() => this.handleClick()}。
箭头函数没有自己的 this,它会捕获外层词法作用域的 this。这一点在对象方法里非常重要:
javascript复制const obj = {
name: '张三',
waitThenSay() {
setTimeout(function() {
console.log(this.name);
}, 1000);
// 这里 this 是全局对象,输出 undefined 或报错
},
waitThenSayArrow() {
setTimeout(() => {
console.log(this.name);
}, 1000);
// 箭头函数捕获外层 waitThenSayArrow 的 this,也就是 obj
}
};
很多帖子说“箭头函数绑定了定义时的 this”,严格讲并不准确。箭头函数没有自己的 this,访问 this 时按词法作用域往外层找,找到哪层算哪层。
4.4 手写 call、apply、bind,彻底理解 this 的底层逻辑
如果你想真正吃透 this,手动模拟一遍 call、apply、bind 是最好的路径。以 call 为例,核心思路其实就是把函数临时挂到对象上调用,然后再删掉:
javascript复制Function.prototype.myCall = function(context, ...args) {
// context 可能传 null/undefined,此时视为全局对象(严格模式这里要处理)
context = context == null ? globalThis : Object(context);
const fn = Symbol('fn');
context[fn] = this;
const result = context[fn](...args);
delete context[fn];
return result;
};
const user = { name: '张三' };
function getUserName(prefix) {
return prefix + this.name;
}
console.log(getUserName.myCall(user, '你好:')); // 你好:张三
用 Symbol 当临时键是因为它不会被 Object.keys 等常规遍历捕获到,避免污染对方的属性。apply 的实现就只是把参数数组展开成 ...args。
bind 本质是垫一层函数:
javascript复制Function.prototype.myBind = function(context, ...boundArgs) {
const fn = this;
return function(...args) {
return fn.apply(context, [...boundArgs, ...args]);
};
};
注意我这里的简单实现没有处理 new 调用的场景,完整版 bind 返回的函数可以作为构造函数使用,此时原 this 绑定会被忽略。不过面试和原理理解层面先用这个简单版就够了。你真正手写一遍之后,再看那些“回调函数里 this 丢了”的问题,一眼就能定位原因。
5. 对象比较与空值判定:两个最容易被忽视的深水区
5.1 ==、===、Object.is 在对象比较里的差异
原始值的比较按值比较,对象则简单粗暴得多——引用比较。也就是两个变量只有在指向同一个内存对象时才相等:
javascript复制const a = { name: '张三' };
const b = { name: '张三' };
const c = a;
console.log(a === b); // false,内容一样但地址不一样
console.log(a === c); // true,指向同一个对象
用 == 也一样,a == b 也是 false,因为对象比较不看内容,只看引用同一性。这里就延伸出了一个高频问题:如果一个对象接口返回了两次,数据一模一样,怎么判断它俩有没有变化? 简单内容对比可以写成:
javascript复制function shallowEqual(objA, objB) {
if (Object.is(objA, objB)) return true;
if (objA == null || objB == null) return false;
if (typeof objA !== 'object' || typeof objB !== 'object') return false;
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => Object.is(objA[key], objB[key]));
}
React 里 shouldComponentUpdate、memo 的浅比较就是类似思路,不会递归比较嵌套对象,只比较第一层。所以嵌套对象更新时如果直接改内层属性而不生成新对象引用,浅比较就认为“没变化”。
Object.is 则是解决了 NaN 和 +0/-0 这两个 === 判断上的特殊情况:
javascript复制console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
console.log(0 === -0); // true
console.log(Object.is(0, -0)); // false
日常大部分代码用 === 就够了,但像 Object.is 这种语义更符合数学直觉的比较,在处理映射、缓存、状态同步时,能帮你规避掉 NaN 带来的隐性 bug。
5.2 深比较的实用做法
当对象嵌套多层且需要比较内容时,浅比较就不够了。很多人第一反应是 JSON.stringify(a) === JSON.stringify(b),但这同样有深拷贝那三大死穴的类似问题,而且属性顺序不同也会导致判断错误:
javascript复制const a = JSON.stringify({ a: 1, b: 2 });
const b = JSON.stringify({ b: 2, a: 1 });
console.log(a === b); // false,但对象内容明明一样
写一个完整的深比较也有不少边界情况,比如考虑原型、循环引用、特殊内置对象。项目里如果能用 lodash isEqual,直接用就行。自己实现的重点仍然是循环引用和类型区分,不然代码就会在极端场景里出问题。
5.3 空对象判断的五个层次
判断对象为空,你可能觉得是 Object.keys(obj).length === 0,但实际有五种细节场景要区分:
javascript复制// 1. 没有任何属性,连 Symbol 都没有
const empty1 = {};
console.log(Object.keys(empty1).length === 0); // true
// 2. 有 Symbol 键,Object.keys 看不到
const empty2 = { [Symbol('hidden')]: 'value' };
console.log(Object.keys(empty2).length === 0); // true
console.log(Reflect.ownKeys(empty2).length === 0); // false
// 3. 有不可枚举属性
const empty3 = {};
Object.defineProperty(empty3, 'hidden', {
enumerable: false,
value: 1
});
console.log(Object.keys(empty3).length === 0); // true
console.log(Object.getOwnPropertyNames(empty3).length === 0); // false
// 4. 判断拿到的是 null 或 undefined
console.log(empty1 == null); // false
console.log(undefined == null); // true
// 5. Object.create(null) 创建的无原型对象
const noProto = Object.create(null);
noProtoEmpty = {};
console.log(Object.keys(noProto).length === 0); // true
console.log(noProto.hasOwnProperty); // undefined,没有这个方法
很多工具函数的健壮性往往就差在这几个边界判断上。比如表单校验里判断“用户有没有填过字段”,就不能只看 Object.keys 长度是 0,因为字段可能值为 undefined 或空字符串。更合理的做法是过滤掉值为 undefined 的键再判断,或者按业务语义定义一个“有效值非空”的判断函数。
6. 对象扩展与转换的常用模式:开发中的高效技巧
6.1 解构赋值的默认值与重命名:优雅处理接口返回数据
从接口对象里取值,解构赋值比 res.data.user.name 这种链式访问不知道高到哪里去了:
javascript复制const response = {
code: 0,
data: {
userList: [{ id: 1, name: '张三' }],
total: 100
}
};
const { data: { userList, total: userTotal } } = response;
console.log(userList.length); // 1
console.log(userTotal); // 100
这里的关键点有两个。一是 total: userTotal 把接口返回的 total 字段重命名为本地更语义化的 userTotal,避免跟别的名字冲突。二是嵌套解构加默认值可以防御接口字段缺失:
javascript复制const { userInfo = { name: '游客' } } = response;
console.log(userInfo.name); // 游客,接口没返回 userInfo 时用默认值
解构默认值只在属性值为 undefined 时生效,值为 null 时不会兜底。这个细节经常给新手埋坑:
javascript复制const obj = { name: null };
const { name = '张三' } = obj;
console.log(name); // null,而不是 '张三'
6.2 可选链 ?. 与空值合并 ??. 配合,告别层层嵌套判断
可选链运算符 ?. 是我这几年觉得对代码改善最大的语法之一。以前要写:
javascript复制if (res && res.data && res.data.user && res.data.user.name) {
// ...
}
现在一行搞定:
javascript复制const userName = res?.data?.user?.name ?? '未登录';
?. 只在 null 或 undefined 时短路,返回 undefined,不会对空字符串、0 这些 falsy 值短路。?? 则在左边是 null 或 undefined 时取右边默认值。它跟 || 的区别很关键:
javascript复制const config = { retryCount: 0 };
const withOr = config.retryCount || 3;
const withNullish = config.retryCount ?? 3;
console.log(withOr); // 3,0 被当成假值了
console.log(withNullish); // 0,只有 null/undefined 才会走默认值
用 ?? 可以避免“明明配置了 0,却被默认值覆盖”的经典 bug。同样,布尔值 false、空字符串 '' 这种合法业务数据,也最好用 ??。
6.3 对象转原始值:为什么对象在做数学运算时行为很怪
控制台里执行 {} + {} 或者 [] + [] 会得到什么结果,是很多老梗的源头。根本原因就是对象被强制转换成原始值时,会触发 toPrimitive 内部逻辑。这个转换顺序是这样的:
- 如果对象有
Symbol.toPrimitive方法,优先调用它。 - 否则默认会依次尝试
valueOf和toString。 - 如果是默认提示(
default)或转为数字,先调用valueOf,再调toString;如果是转为字符串,先调toString,再调valueOf。
普通对象默认 valueOf 返回对象自身,toString 返回 "[object Object]",所以做加法运算时就变成字符串拼接了:
javascript复制const obj = {};
console.log(obj + ''); // "[object Object]"
如果想控制对象的转换行为,可以自己实现 Symbol.toPrimitive:
javascript复制const price = {
amount: 100,
currency: '¥',
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
if (hint === 'string') return this.currency + this.amount;
return this.amount;
}
};
console.log(+price); // 100
console.log(String(price)); // "¥100"
console.log(price + 50); // 150
这块在业务里可能用得少,但看一些库的源码时(比如日期格式化工具、数据转换器)经常能见到 Symbol.toPrimitive 的影子,理解了能少很多困惑。
6.4 通过 Object.fromEntries 在对象与数组之间来回切换
对象和数组经常需要互转。Object.entries 把一个对象转成键值对数组:
javascript复制const user = { name: '张三', age: 30, city: '北京' };
const entries = Object.entries(user);
console.log(entries);
// [['name', '张三'], ['age', 30], ['city', '北京']]
Object.fromEntries 是反操作,把键值对数组转回对象:
javascript复制const restored = Object.fromEntries(entries);
console.log(restored); // { name: '张三', age: 30, city: '北京' }
有了这两个方法,你可以先转数组做各种处理,再转回对象。比如过滤掉值为空字符串的字段:
javascript复制const formData = { name: '张三', phone: '', email: 'a@b.com' };
const cleaned = Object.fromEntries(
Object.entries(formData).filter(([key, value]) => value !== '')
);
console.log(cleaned); // { name: '张三', email: 'a@b.com' }
Map 也可以直接用 Object.fromEntries 转成普通对象,前提是键都是字符串或 Symbol:
javascript复制const map = new Map([['name', '李四'], ['age', 25]]);
const obj = Object.fromEntries(map);
console.log(obj); // { name: '李四', age: 25 }
6.5 配置对象合并的推荐写法:浅合并 + 防御
日常封装一个函数,接收用户传入的配置项,跟默认配置合并,是最常见的对象操作之一。推荐的写法是:
javascript复制const defaultConfig = {
host: 'http://localhost:3000',
timeout: 5000,
retry: 3,
headers: {
'Content-Type': 'application/json'
}
};
function request(userConfig = {}) {
const config = {
...defaultConfig,
...userConfig
};
// 注意:如果 userConfig.headers 存在,会整体替换掉默认 headers
// 如果需要深层合并,要单独写 headers 的合并逻辑
}
这里的语义是:用户配置了哪个字段,就用用户的;没配置的字段,用默认值。这样的“浅合并”覆盖了 90% 场景。如果 headers 这类嵌套对象也需要部分覆盖而不是整体替换,那得单独处理:
javascript复制const config = {
...defaultConfig,
...userConfig,
headers: {
...defaultConfig.headers,
...(userConfig.headers || {})
}
};
有些踩过坑的同学会直接上深合并,用递归遍历把所有嵌套都merge掉,但带来的问题是,比如默认配置里某个字段是数组,用户想传递空数组清空,还是想跟默认数组合并,语义会变得非常不清晰。所以我实际代码里的原则是:配置对象默认浅合并,特殊嵌套字段单独处理,别迷信深层合并。
7. 不可变更新与设计误区:真正想过才少被坑
7.1 为什么状态管理里的更新要“生成新对象”
React、Vue 这类前端框架对状态更新的核心要求是“不可变更新”。什么意思?你自己维护一个数据对象,想修改某个属性时,不要直接改原对象,而是生成一个新对象,把变更后的值放进去。好处是:框架能通过引用比较快速判断“状态变了没有”,进而决定要不要重新渲染。如果直接改原对象属性,引用没变,框架可能认不出变化。
一个典型的错误写法:
javascript复制const state = {
user: {
name: '张三',
age: 30
},
posts: []
};
// 坏做法:直接改嵌套对象
state.user.name = '李四';
updateUI(state); // 如果内部用浅比较,这个更新可能不触发
更符合不可变更新的写法:
javascript复制const newState = {
...state,
user: {
...state.user,
name: '李四'
}
};
updateUI(newState);
这种写法在 Redux reducer 里每时每刻都在发生。手写起来繁琐,但语义非常清晰。如果你觉得自己写的不可变更新总是漏层,可以考虑用 immer 这类工具库,它让你内部直接写“可变风格的修改代码”,然后自动生成不可变的新对象,底层用 Proxy 追踪操作。项目里用户操作复杂嵌套状态的场景,immer 能显著减少心智负担。
7.2 用 Object.create(null) 还是普通对象
普通对象字面量 {} 自带 Object.prototype 上一大堆方法、属性,比如 toString、hasOwnProperty,还有 __proto__ 这个访问器。在某些场景下,这些继承来的属性会影响你的逻辑。比如拿普通对象当字典(map)使用时,如果键名是 toString 或 constructor,就可能踩坑。
javascript复制const dict = {};
dict.toString = '自定义值';
console.log(dict.toString); // '自定义值'
// 但如果 dict 是空对象,直接读 dict.toString,会拿到 Object.prototype.toString 方法
当成字典使用时,如果用 in 判断键是否存在,普通对象会误报:
javascript复制const dict = {};
console.log('toString' in dict); // true
避免这类问题有两个方案:一是用 Map,这是最推荐的;二是用 Object.create(null) 创建没有原型链的对象。Object.create(null) 创建的对象干净,没任何继承属性,特别适合纯 KV 存储。不过用它的代价是:没有 toString、没有 hasOwnProperty,甚至控制台打印时表现也跟普通对象有细微差别。所以项目里如果不是明确需要“干净字典”,用 Map 会比创建无原型对象更讨喜。
7.3 遍历对象键时受到原型链影响的经典问题
for...in 会遍历到继承的可枚举属性,这在排查 bug 时偶尔会让人灵异。实际项目里我自己遇到过这样一个场景:给某个模型对象的原型加了辅助方法之后,这些方法全部出现在遍历结果里,直到页面渲染出奇怪的数据才反应过来。标准库在这点上其实早有考虑,比如 Object.keys 只返回自身可枚举属性,刻意避开了原型链,所以如果你用 for...in 遍历前再叠一层 hasOwnProperty 判断,也能得到同样效果:
javascript复制for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// 只处理自身的可枚举属性
}
}
不过既然 Object.keys 就能拿到自身可枚举属性,配合 forEach 或 for...of 会简洁很多。至于扩展原生对象原型这种事,我一直强烈反对在业务代码里做,因为太容易污染全局遍历逻辑。如果确实需要给数组加通用方法,先考虑直接定义独立工具函数,或者用 Symbol 作为键加到原型上(这样常规遍历不会捕获到),再不行就升级成包,千万别轻易改 Object.prototype 或 Array.prototype。
7.4 对象作为条件判断与真值假值的关系
直接拿对象做布尔判断时,所有普通对象都是真值,包括空对象 {} 和空数组 []。
javascript复制const emptyObj = {};
const emptyArr = [];
if (emptyObj) console.log('空对象是真值');
if (emptyArr) console.log('空数组是真值');
if (emptyArr.length === 0) {
console.log('判断空数组要看 length,不能直接 if(arr)');
}
这意味着 if (obj) 无法判断“对象是否为空对象”,它只区分了“是不是 null/undefined”和“是不是有内容”。所以那些喜欢写 if (data.obj) 判断是否为空对象的人,实际上大概率只是防了个 null,代码看起来安全,理解却很容易产生偏差。
要判断“对象是否存在且非空”,要拆两层来判断:
javascript复制if (obj && Object.keys(obj).length > 0) {
// obj 不为 null/undefined,且至少有一个可枚举自有属性
}
8. 提升日常姿势:几个实战场景的完整复盘
8.1 配置对象合并的“污染”问题与防御
我自己封装过一个小型前端监控上报库,对外暴露一个 init(options) 接口,用户会传入采样率、上报地址、是否自动捕获错误等配置项。最初实现时直接这样写:
javascript复制const defaultConfig = {
samplingRate: 1,
reportUrl: '',
captureError: true
};
export function init(options) {
for (const key in options) {
defaultConfig[key] = options[key];
}
}
这个写法有两个致命问题:第一,直接改了 defaultConfig 对象本身,如果有多个页面实例,后一个调用会把前一个的配置覆盖掉;第二,如果用户传入的 options 里带了 toString、hasOwnProperty 这种键,for...in 遍历后会把默认对象搞脏。更好的做法是把默认配置当不可变数据,每次请求都从默认配置“拷贝”出一份再合并:
javascript复制export function init(options = {}) {
const config = {
...defaultConfig,
...options
};
// 后续所有逻辑只用 config
}
这样默认配置永远不变,多次覆盖业务场景就不会互相污染。
8.2 deepClone 在处理 API 响应缓存时的应用
前端在做接口缓存时经常需要把响应存到本地或全局 Store。但如果直接把接口返回对象塞进缓存,而页面后续只是展示,可能没什么问题;可如果页面又要编辑后提交,就要小心了——编辑逻辑直接改了缓存对象,下次别的页面读缓存读到的是脏数据。稳妥做法是把接口返回深拷贝一份,原数据留存,业务组件拿副本去操作:
javascript复制async function fetchUserList() {
const cacheKey = 'userList';
const cached = cache.get(cacheKey);
if (cached) {
return deepClone(cached); // 返回副本,避免调用方污染缓存
}
const data = await api.getUserList();
cache.set(cacheKey, data);
return deepClone(data); // 保险起见,返回副本
}
这种“读缓存给副本”的模式在大厂项目里很常见。当然也可以结合 <T> 泛型和工具函数封装一个更通用的 getCacheClone,但核心思路不变。
8.3 处理数据表格的行选择状态:一种对象键管理的优雅解法
前端管理后台经常遇到“表格多选、记录选中的行”的需求。如果行 ID 是数字,新手可能这样存:
javascript复制const selectedRows = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
];
function isSelected(id) {
return selectedRows.some(row => row.id === id);
}
数据量小没什么问题,但列表数据多或频繁勾选时,some 的时间复杂度是 O(n),不理想。用对象做“选中集合”更好:
javascript复制const selectedMap = {
1: { id: 1, name: '张三' },
2: { id: 2, name: '李四' }
};
function isSelected(id) {
return selectedMap[id] !== undefined;
}
用 Set 其实也完全可以,但如果带着对应的行数据,Map 或对象更直观。而且实践中还要注意内存回收问题:行数据被移出列表后,selectedMap 里的老记录如果没清理,会一直占着引用。所以在分页切换或者列表刷新时,要主动清理那些已不在当前页的选中项,防止数据的过期持有拖累页面。
8.4 使用 Object.groupBy 做数据分组(ES2024)
JavaScript 最近新加了个很实用的静态方法 Object.groupBy,可以直接根据回调的返回值给数组分组:
javascript复制const people = [
{ name: '张三', age: 22 },
{ name: '李四', age: 30 },
{ name: '王五', age: 25 }
];
const grouped = Object.groupBy(people, person => {
return person.age >= 30 ? '资深' : '青年';
});
console.log(grouped);
// { 青年: [{ name: '张三', age: 22 }, { name: '王五', age: 25 }], 资深: [{ name: '李四', age: 30 }] }
需要注意这个方法返回的是一个没有原型链的对象(null-prototype),不能用 hasOwnProperty 方法直接调。分组键会转换成字符串,Object.groupBy 返回的组里元素是原数组的引用。用这个替代手写的“遍历 for + if 堆进不同组”确实干净不少。兼容性上如果你要跑在旧浏览器上,要么用 polyfill,要不就用 Map 自己实现。
8.5 两个对象合并时的键冲突策略
实际项目里经常要把多个来源的数据合并成一个对象,比如把用户基础信息跟用户扩展信息合并。合并策略不能一概而论,先想清楚以哪个来源为准:
javascript复制const baseInfo = { id: 1, name: '张三', age: 30 };
const extendInfo = { age: 31, address: '北京' };
// 如果想以 baseInfo 覆盖 extendInfo
const merged1 = { ...extendInfo, ...baseInfo };
// 如果想以 extendInfo 覆盖 baseInfo
const merged2 = { ...baseInfo, ...extendInfo };
简单,但容易出错的是“数组字段”的合并。业务里经常有 { key: 'special', value: 'test' } 和 [key, value] 这种键值对数组要转成对象或反过来。使用 Object.fromEntries 时要留意重复键的问题:后面的会覆盖前面的,没有报错和警告。如果数据里可能存在重复键而你又想保底或者做冲突日志,那就得自己遍历处理。
9. 对象性能与调试:大对象场景下怎么办
9.1 隐藏类与属性访问性能的关系
V8 引擎为了提升对象属性访问速度,会为对象做“形状”推断,也就是隐藏类(Hidden Class)。当你创建对象时,如果每个对象的属性结构一致,引擎就会让它们共享同一个隐藏类,访问属性时通过偏移量直接定位,速度接近访问 C 结构体字段。
如果你给对象动态增删属性,尤其在不同实例上做不同顺序的属性添加,隐藏类就会被破坏,导致引擎退回到字典模式,属性查找变慢。这在极高性能要求的场景(比如游戏逻辑、高频循环里创建大量对象)能感觉到明显差异。
一个非常典型的反模式:
javascript复制function createPoint(x, y) {
const point = {};
point.x = x;
point.y = y;
return point;
}
这样每个 point 先生成空对象,再加 x,再加 y,每加一次都可能导致隐藏类变更。更好的写法是:
javascript复制function createPoint(x, y) {
return { x, y };
}
直接一次性把所有属性声明出来,引擎从创建那一刻起就知道这个对象有两个属性,可以直接建好隐藏类。虽然这种优化对普通业务代码来说微乎其微,但它提示了一个编写风格:尽量在对象创建时就确定好结构,不要频繁动态增删属性。这也顺带让代码更可读、更好排查。
9.2 大对象转换为 JSON 时的内存考量
JSON.stringify 处理超大对象时会产生完整的字符串输出。如果服务端返回的数据有几十 MB,直接 JSON.stringify 再存储,内存峰值会双倍存在——对象原始内存一份,序列化后的字符串一份。加上字符编码、网络传输的开销,很容易把页面的内存撑爆。
实际项目的应对思路是:
- 后端直接提供精简接口,不要把全量 JSON 推到前端
- 前端只保留会用到的字段,做一次 shap 转换之后再存
- 需要持久化时用更轻量的格式或者二进制协议(如 MessagePack、Protobuf)
自测一个页面卡死案例时,就是有个大对象被反复 JSON.stringify 后存进 localStorage,结果每次序列化都要卡两秒。后来改成只保留必要字段,问题秒解。
9.3 快速定位对象相关 bug 的调试思路
对象相关的 bug 往往都跟“改了这里,那里变了”有关。定位步骤我总结了四个:
- 先在修改处前后打点,对比修改前后对象的引用和内容。
- 用
console.log打印对象时,注意展开时机。控制台显示的是对象展开时的属性值,不是打印时的值。如果你在打印后又改了对象,控制台里展开那个对象看到的是修改后的值,会误导你。 - 真正确认变化来源就改造
Object.freeze:临时冻结对象,看哪个代码在严格模式下尝试修改它从而抛错。 - 内存里如果出现“一个对象被多个地方引用”,直接查“谁把它传出去了”。
另外一个非常实用的技巧:复用 JSON.stringify(obj, null, 2) 打印对象的结构,字符串不会受后续修改影响,在 debug 时不会出现控制台展开时机问题。
9.4 Object.freeze、Object.seal、Object.preventExtensions 三个级别
这三个方法都是用来限制对象变化的,强度从弱到强:
| 方法 | 禁止新增属性 | 禁止删除属性 | 禁止修改属性值 | 禁止修改属性描述符 |
|---|---|---|---|---|
Object.preventExtensions |
是 | 否 | 否 | 否 |
Object.seal |
是 | 是 | 否 | 否 |
Object.freeze |
是 | 是 | 是 | 是 |
这三个方法都只能作用于对象自身属性(浅层),嵌套对象不会被影响。想让整个对象树都不可变,还是得递归冻结,或者靠 immer 这类库在数据变更层面约束。
实际业务中 Object.freeze 最常见的用途是在常量配置上:
javascript复制const STATUS_CONFIG = Object.freeze({
PENDING: 'pending',
SUCCESS: 'success',
FAILED: 'failed'
});
这样配置项被丢到多个模块里,也不会被某处误改。另一层意义是性能优化:V8 对冻结对象有一些额外优化,同时对象属性意外变更时你能更快发现,因为非严格模式下 Object.freeze 的属性修改是静默失败,严格模式下会直接抛错,为了提早暴露问题,建议项目开严格模式。
10. 进一步梳理与反思:对象这把钥匙能打开多少门
10.1 从对象到数组、Map、Set 的选型对照
很多时候写代码不够好,不是因为语法不会,而是因为数据结构没选对。对象、数组、Map、Set 各有适用场景:
| 需求 | 推荐数据结构 | 原因 |
|---|---|---|
| 保持顺序存储同类型元素 | 数组 | 有 index、支持各种迭代方法 |
| 按 key 快速查找,key 不是字符串 | Map | key 可以是任意值,顺序稳定 |
| 避免重复元素 | Set | 自带唯一性约束 |
| 存储有固定字段的实体数据 | 普通对象 | 语义清晰,适合 JSON |
| 当作字典/哈希表且不接受非自身键 | Map 或 Object.create(null) | 不受原型链影响 |
一个常见的选择误区是拿普通对象当 Map 用:对象键只能是字符串或 Symbol,而 Map 的键可以是对象、函数甚至 NaN。之前项目里要用“DOM 节点作为 key 来存每个节点的元数据”,如果写普通对象得给节点加 id,再存 id -> 元数据;如果用 WeakMap,直接拿节点当 key,节点被回收时条目还能自动消失,不用手动清理。
10.2 对象与类、函数式编程的关系
JavaScript 对象之上,还有一层是“怎么组织对象逻辑”。ES6 的 class 语法本质上是构造函数的语法糖,底层仍然走原型链。class 里声明的方法放在 class.prototype 上,实例通过原型链访问;用 static 关键字声明的方法放在构造器函数本身上。
对象与函数式也紧密相关。函数本身是对象,拥有一切对象特性——可以有属性、可以传给别的函数。React 函数组件里大量的“传对象参数”,本质上都是对象操作。理解对象的不同角色(普通数据容器、方法持有者、类实例、函数对象),对阅读源码非常有帮助。
10.3 继续深挖的几本资料与方向
想把“JavaScript 对象”彻底吃透,靠一篇博客远远不够。如果还要继续深入,我个人的建议路线是:
- 先精读《JavaScript高级程序设计(第4版)》第 6、8 章,这两章把对象、数组、迭代器与生成器讲得非常扎实
- 《你不知道的 JavaScript(上卷)》里面关于 this、原型、对象本质的内容很硬核,适合建立底层心智模型
- MDN 的 Object 全局对象参考页,适合当工具书随时查
- 浏览器控制台自己多实验,比如
Object.getOwnPropertyDescriptor、Object.getPrototypeOf这些 API 亲自跑一跑,比死记硬背强
工程层面,建议再看几篇关于 Vue 3 响应式原理和 React 状态管理的源码分析。Vue 的响应式系统用 Proxy 拦截对象读写,React Redux 里 reducer 不可变更新频繁操作对象展开,这些本质上都是“对象机制”在真实框架中的应用。理解了对象属性和原型链,再去看框架源码会觉得顺畅非常非常多。
10.4 一个收尾的建议:从“会用”走向“能讲”
我面试前端工程师时经常问一个问题:“JavaScript 对象里的属性查找最快能多快?什么会影响这个速度?”发现很多人业务写了好几年,却答不上来“对象属性有描述符”“隐藏类这个机制”。这不怪大家,毕竟日常业务里不直接感知。但如果你想从“完成任务”往“理解系统”的方向走,对象是绕不开的第一课——它也确实是整个 JavaScript 世界里最基础也最核心的一块基石。
我自己的体会是,JavaScript 对象的很多所谓“怪癖”和“坑”,来自三处:一是语言设计早期为了简单留下来的历史包袱(typeof null === 'object'),二是引擎实现层面的优化策略(隐藏类、自动装箱),三是你脑海里的模型问题——总把它类比成“别的语言的 class 或者 struct”,却没有按 JavaScript 引用、原型、属性描述符这套机制来理解。遇到对象相关诡异问题,先对照这三类去找原因,八成都能定位到。
这篇文章不是想让读者背下所有 API,而是希望有助于大家建立一个看待对象的框架:对象是属性的容器,属性背后有描述符,对象之间靠原型链关联,变量存的是引用。在这个框架之上,再去看什么拷贝、相等、遍历、更新、优化的问题,都不再是孤立知识点,而是一张互相连通的地图。接下来每当你手头有对象相关的诡异 bug,照着这个思路排查,应该会比以前笃定很多。
