
做前端这么多年面试过几百人也带过不少新人发现一个挺普遍的现象很多同学能把对象用得很溜但一问到“对象到底是什么”、“原型链怎么走”、“深拷贝怎么实现”就支支吾吾说不清楚。这其实挺可惜的因为JS里的对象就像房子的地基表面看是简单的键值对容器实则暗藏了属性特性、原型链、引用传递、不可枚举、深浅拷贝这一大堆门道。这决定了你能不能写出健壮的代码能不能在团队里解决那些稀奇古怪的线上bug。这块钱不补上后面真的会一直踩坑。这篇内容不打算写成教科书我更想用实际业务里碰到的场景把JS对象从创建、原型、遍历、拷贝、去重到联动数据组织这些核心细节一次性讲透。不管你是刚入门的前端新人还是写过几年业务代码但没系统梳理过对象的朋友这篇文章都建议认真读一遍。我保证每段都是实战验证过的很多是你翻文档翻不到的经验。1. 对象的本质与创建方式1.1 先搞清楚对象到底是什么和普通容器有什么区别很多人说“JS里万物皆对象”这话对但也不全对。更准确的说法是在JS中几乎所有数据类型都能被包装成对象但原始值本身不是对象。比如数字、字符串、布尔、null、undefined、bigint、symbol前几个有对应的包装对象例如new Number(1)。我们平时直接写1它就是个原始值只有调用方法时引擎会临时包一层对象用完就丢弃。对象本身的本质是一组“键值对”的无序集合键是字符串或Symbol值可以是任意类型包括函数、数组、另一个对象。但光知道这点不够对象真正的内核是“属性”和“原型”。属性不只是一个名字对应一个值它背后还有三个描述符可写writable、可枚举enumerable、可配置configurable以及取值器getter和设置器setter。这才是对象和普通容器的分水岭。为什么说这个很重要因为当你用for...in遍历时某些属性不出现你以为对象里没有其实是被enumerable: false藏起来了。当你给对象赋值时明明写了属性却改不动多半是writable: false或用了Object.freeze。所以别再把对象当成简单的Map来用了它更像是一个带元数据的属性仓库。1.2 创建对象的六种方式我实际用下来这么选日常写代码时创建对象至少有六种常见姿势。每种都有应用场景我一个个说并给出我的使用建议。第一种对象字面量const obj { name: 小王 }。这是最推荐的方式代码直观性能也最好。绝大多数场景我都用字面量没有理由换别的。第二种new Object()const obj new Object()。等价于字面量但多了一层构造函数调用代码也啰嗦唯一的价值是动态创建空对象时能让人一眼看出“这里要放东西”。说实话可读性不如字面量我基本不单独用。第三种构造函数function Person(name) { this.name name }然后new Person(小李)。这种方式适合创建多个结构相同的对象。但ES6之后我优先用class因为写法更清晰继承也更规范。第四种class语法class Person { constructor(name) { this.name name } }。本质还是函数和原型的语法糖但比构造函数更符合直觉。新项目我都推荐class。第五种Object.create(proto, properties)创建一个指定原型的对象。这个很强大但要小心很多人用错了。Object.create(null)可以创建一个没有原型的“纯净对象”用来做字典、缓存特别合适因为不会受到Object.prototype上各种方法的影响也不会被in操作符误判。我后面讲判断空对象时会提到。第六种工厂函数一个普通函数返回对象不依赖new。适合做复杂配置的默认值或者要按条件返回不同结构的对象时使用。实际开发中字面量占了大概80%Object.create用于特殊场景比如原型式继承、纯字典class用于业务模型。其余方式知道就好不用强行用。1.3 属性特性数据属性和访问器属性藏着对象一半的秘密对象的每个属性都有一套内部描述符中文叫“属性描述符”。用Object.getOwnPropertyDescriptor(obj, name)可以查看。数据属性包括值、可写、可枚举、可配置访问器属性包括getter、setter、可枚举、可配置。举个典型例子双向绑定和数据响应式原理本质上就是拦截对象的读取和赋值。我们可以手动定义访问器属性const user { firstName: 张, lastName: 三 }; Object.defineProperty(user, fullName, { get() { return ${this.firstName}${this.lastName}; }, set(value) { [this.firstName, this.lastName] value.split(); }, enumerable: true, configurable: true }); console.log(user.fullName); // 张三 user.fullName 李四; console.log(user.firstName, user.lastName); // 李 四这个例子说明访问器属性可以让我们在读取和赋值时插入逻辑。这比直接定义fullName: 张三强多了因为数据变化能联动。Vue 2的响应式核心用的就是Object.defineProperty去重定义data中的每个属性Vue 3改用Proxy但访问器属性的思路始终没变。writable和configurable也很有用。一个属性设置为writable: false后赋值静默失效严格模式会报错。configurable: false则禁止删除和修改描述符。这些特性写库、做全局常量时很关键可以防别人误改。但业务代码里用得不多大家有个概念即可真遇到“为什么这个属性改不动”的bug知道往这里排就行。2. 函数是对象吗原型链到底怎么走2.1 函数确实是对象但它是“可调用”的特殊对象很多初学者都会问“JS中函数是对象吗”答案很明确函数是对象但函数对象有额外的内部方法[[Call]]所以它可以被调用。因为函数是对象所以函数也可以有属性、可以赋值给变量、可以当作参数传递、可以作为另一个对象的属性。这就是所谓的一等公民。但是不要因为“函数是对象”就觉得函数和普通对象完全一样。函数对象自带length形参个数、name函数名、prototype原型属性普通对象没有等特殊属性。而且普通对象用new会报错函数用new可以创建实例。有个容易混淆的点函数的prototype属性和对象内部的[[Prototype]]不是一回事。只有函数才有prototype箭头函数除外它是用来给new出来的实例做原型的。而每个对象包括函数都有一个内部原型[[Prototype]]在浏览器里可以通过__proto__访问代码里更推荐用Object.getPrototypeOf(obj)。我把这个关系打个比方原型就像班级的“公共黑板”实例是学生学生自己桌上没写的内容要抬头看黑板。函数Person的prototype属性就是指向那块“黑板”的地址而person.constructor又指回“班主任”。2.2 原型链为什么对象能调用自己没定义的方法一个对象查找属性的过程是这样的先看自己有没有这个属性如果没有就沿着原型链向上找直到Object.prototype再没有就返回undefined。这就是为什么你定义一个空对象{}却能调用toString()、hasOwnProperty()因为它们是Object.prototype上的方法被所有对象共享。代码演示一下原型链的构建class Animal { constructor(name) { this.name name; } eat() { console.log(${this.name} 吃饭); } } class Dog extends Animal { bark() { console.log(汪汪); } } const dog new Dog(旺财); console.log(dog.eat()); // 旺财 吃饭 console.log(dog.bark()); // 汪汪 console.log(dog.constructor); // Dogdog实例先从自己身上找bark找到了直接调用。找eat时自己身上没有就去Dog.prototype上找也没有再去Animal.prototype上找找到了。这就是两层原型链。再往上就是Object.prototype再往上就是null。所有对象最终都指向null所以原型链是有限的。需要注意ES6的class继承本质还是原型链的语法糖。Dog.prototype的原型指向Animal.prototype。这种结构用一句话概括继承的实质不是把父类的属性拷贝到子类身上而是让子类的实例在查找属性时沿着原型链走到父类的原型上去。2.3 用原型链解决一个真实业务问题公共方法的挂载有一类需求很常见你有多个对象结构不同但都需要一个公共方法。最粗暴的方式是每个对象都写一遍但这样浪费内存方法一变就要改多处。更优雅的方式是利用原型链把公共方法挂到它们的共同原型上。const base { getInfo() { return ${this.name} —— ${this.type}; } }; const article Object.create(base); article.name 深入JS对象; article.type 技术文章; const video Object.create(base); video.name 对象原型精讲; video.type 视频教程; console.log(article.getInfo()); // 深入JS对象 —— 技术文章 console.log(video.getInfo()); // 对象原型精讲 —— 视频教程这里article和video都没有直接定义getInfo但通过Object.create(base)让它们的原型都指向了base于是都能调用getInfo。好处很明显公共逻辑只写一份未来要扩展返回字段只改base就行。这比每个对象都复制一份方法要健壮得多。当然现代前端框架里有更高级的状态管理和函数式工具但在原生JS里原型链仍然是解决公共逻辑复用的基础手段。3. 对象遍历、判断为空与拷贝的实战细节3.1 遍历对象的五种姿势选错会漏属性遍历对象的方法多到让人眼花但它们之间的差异很大。先看五个主要的for...in遍历对象自身和原型链上所有可枚举属性不包括Symbol。如果不加hasOwnProperty判断容易把原型链上的属性也带出来这就是个隐患。Object.keys(obj)只返回自身可枚举属性组成的数组。这是最常用的。Object.values(obj)返回自身可枚举属性的值。Object.entries(obj)返回[key, value]数组配合for...of或解构很好用。Reflect.ownKeys(obj)返回自身所有属性key包括不可枚举的、Symbol类型的但不包括原型链。我举一个实际踩坑的例子定义对象时加了Object.defineProperty(obj, hidden, { value: 1, enumerable: false })然后你用Object.keys看发现没有hidden。这不是bug而是遍历方法遵循了属性描述符规则。如果你用Reflect.ownKeys就能看到它。日常遍历我推荐Object.entries因为既拿到key又拿到value不需要再多一次取值。如果只需要key用Object.keys。如果关心性能for...in配合hasOwnProperty其实不差但代码不够优雅。还需要注意属性遍历的顺序。整数键会按升序优先输出字符串键按插入顺序Symbol最后。这个顺序在特定场景下会有影响比如你要用对象模拟有序集合时最好直接用Map别用对象。3.2 判断对象是否为空别再傻傻用JSON.stringify业务里经常要判断一个后端返回或前端配置是否为空对象。新手最爱写JSON.stringify(obj) {}这个方案有三个问题第一如果对象里有undefined、函数、Symbol它们会被忽略可能误判第二如果对象是循环引用直接抛错第三性能不如原生方法。所以这个写法只是“看起来能用”并不严谨。更靠谱的做法是用Object.keys加上Object.getOwnPropertySymbols甚至Reflect.ownKeys。判断标准取决于业务定义如果你认为“空对象”指没有任何自有属性包括不可枚举和Symbol那就用Reflect.ownKeys(obj).length 0。如果你只关心可枚举的普通属性用Object.keys(obj).length 0。我平时封装一个工具函数function isEmptyObject(obj) { if (obj null || typeof obj ! object) return false; return Reflect.ownKeys(obj).length 0; }注意这里有个陷阱Object.create(null)创建的对象没有原型也没有hasOwnProperty方法所以不能直接调用obj.hasOwnProperty(xxx)。要想判断属性存在得用Object.prototype.hasOwnProperty.call(obj, xxx)。这个坑指数级上升遇到报错“hasOwnProperty is not a function”就是踩到它了。3.3 深拷贝、浅拷贝和JSON序列化拷贝的坑拷贝对象是前端高频操作。浅拷贝好理解只拷贝第一层属性属性值还是引用。Object.assign和展开运算符{...obj}都是浅拷贝。深拷贝是每一层都生成新对象让原对象和拷贝对象完全独立。最常见的深拷贝方案是JSON.parse(JSON.stringify(obj))但它的坑比深拷贝本身还要深undefined、函数、Symbol类型的属性会被丢NaN和Infinity会被转换成nullDate对象会变成字符串RegExp变成{}Map、Set、WeakMap、WeakSet不会被正确拷贝循环引用会直接抛出“Converting circular structure to JSON”原型链会丢失__proto__没了所以这个方案仅适用于纯数据的场景比如从接口拿到的普通JSON对象。如果需要完整的深拷贝要么用工具库例如lodash.cloneDeep要么自己实现一个递归版。手写深拷贝并不复杂核心代码大致如下function deepClone(target, map new Map()) { if (target null || typeof target ! object) return target; if (map.has(target)) return map.get(target); const isArray Array.isArray(target); const result isArray ? [] : {}; map.set(target, result); if (isArray) { target.forEach((item, index) { result[index] deepClone(item, map); }); } else { Object.keys(target).forEach(key { result[key] deepClone(target[key], map); }); } return result; }这里用了Map记录已经拷贝过的对象专门解决循环引用。这个函数虽然简单但面试时能把循环引用处理说出来基本分数就到手了。实际项目中如果对Date、RegExp、Map等有要求可以直接用成熟的库不用自己折腾。3.4 对象数组去重时的引用陷阱对象数组去重是业务里很常见的操作。很多人看到“去重”第一反应是用Set但直接把对象数组塞进Set是去不掉重复的因为对象比较的是引用地址两个内容相同的对象在内存中是不同的引用Set里是独一无二的。所以必须先找到“唯一标识”。最常见的做法是用Map以某个字段作为key来去重const users [ { id: 1, name: 张三 }, { id: 2, name: 李四 }, { id: 1, name: 张三 } ]; function uniqueByKey(arr, key) { const map new Map(); return arr.filter(item { if (!map.has(item[key])) { map.set(item[key], true); return true; } return false; }); } console.log(uniqueByKey(users, id));这样能根据id去重而且保留第一次出现的对象。如果两个对象的id没变但name变了去重后保留的是第一个需要你根据业务决定是否要合并。另外如果唯一标识不是单个字段而是多个字段组合可以把item[key1] _ item[key2]作为Map的key。还有一个细节如果你希望去重后对象引用是最新一个可以把filter改成从后往前遍历或者用reduce根据实际需求调整。4. 从对象数组操作到三级联动的实战演练4.1 提取数组对象中的部分字段用解构一行搞定后端接口经常一次性返回大对象数组比如用户系统里每个用户有几十个字段但前端只需要其中两三个。把整个对象塞进表格会拖垮渲染性能而且不直观。提取字段最优雅的方式是配合map和ES6解构直接忽略不需要的字段。const allUsers [ { id: 1, name: 张三, age: 18, address: 北京, password: xxx }, { id: 2, name: 李四, age: 20, address: 上海, password: yyy } ]; const simpleUsers allUsers.map(({ id, name }) ({ id, name })); console.log(simpleUsers); // [{ id: 1, name: 张三 }, { id: 2, name: 李四 }]这个写法非常直观解构参数{ id, name }把需要的字段拿出来然后返回一个新对象。好处是原数组完全不会被改动也不会意外带入password字段。如果要排除某些字段还能用rest参数const { password, ...rest } user这样rest就是去掉敏感字段后的干净对象。这个方法在联调、脱敏、表格列裁剪场景下非常实用。4.2 动态创建表格合并对象和DOM怎么配合跟“js动态创建的表格合并怎么弄成一个”类似的问题我再聊一点。动态表格的数据源就是一个对象数组表格的每一行对应数组里的一个对象每一列对应对象的一个属性。有时候需要合并单元格业务上最常见的是“单元格值相同就合并”。这种需求不要边渲染边暴力改DOM先把“哪些行需要合并”的数据结构整理出来。比如一个数组每一项记录startRow、rowspan、colspan。渲染时根据这组数据决定要不要跳过单元格、要不要设置rowSpan属性。说到底动态表格的难点不在DOM操作而在于把后台数据转成符合表格结构的对象数组。很多时候我用一个对象来映射“列key对应渲染函数”而不是在模板里写一堆if判断。const columns [ { key: date, title: 日期, render: val val }, { key: city, title: 城市, render: val val, merge: true } ];这样表格的配置本身就是一个对象数组动态生成表格只需要遍历columns再遍历数据。以后要新增列往columns里加一项就行不用动模板。4.3 三级联动数据应该如何组织对象结构比DOM操作更重要三级联动比如省市区选择、分类层级选择是对象数据组织的经典场景。很多人一上来就写change事件、清空下一级列表代码越写越乱。其实如果数据源是一棵对象树联动逻辑就是“在对象里找当前节点的children”。常见的数据结构有两种。第一种是嵌套对象province: { city: { district: [] } }第二种是扁平数组加parentId。真实项目中扁平结构更常见因为后端数据库通常这么存。const regions [ { id: 1, pid: 0, name: 广东省 }, { id: 2, pid: 1, name: 广州市 }, { id: 3, pid: 2, name: 天河区 } ];要支撑三级联动需要把一个扁平数组转换成一棵树。转换的核心就是建一个Map缓存id到对象的映射然后遍历数组把每个节点挂到对应父节点的children上function buildTree(list) { const map new Map(); const roots []; list.forEach(node { const item { ...node, children: [] }; map.set(item.id, item); if (item.pid 0) { roots.push(item); } else if (map.has(item.pid)) { map.get(item.pid).children.push(item); } }); return roots; }这个函数虽然短但信息量不小先把所有节点放入Map再通过pid寻找父节点。如果你只是每次选择后暴力filter父级性能会随数据量线性下降树结构则能保证O(1)查找。后续联动时只需把当前选中项的children作为下一级下拉的选项逻辑简单了很多。4.4 对象如何转成后端要的query字符串前端请求接口时有时要手动把对象拼成查询字符串比如{ page: 1, size: 10, keyword: JS }变成?page1size10keywordJS。最省心的方式是直接用URLSearchParamsconst params { page: 1, size: 10, keyword: JS }; const queryString new URLSearchParams(params).toString(); console.log(queryString); // page1size10keywordJS这里有个细节对象里的数组值比如tags: [a, b]URLSearchParams会序列化成tagsa%2Cb也就是tagsa,b而不是后端可能期望的tagsatagsb。如果后端是Java Spring里常见的query wrapper风格可能需要把数组展开成多个同名参数。这时候自己写一个小函数更灵活function objectToQuery(obj) { const parts []; Object.keys(obj).forEach(key { const value obj[key]; if (Array.isArray(value)) { value.forEach(v parts.push(${encodeURIComponent(key)}${encodeURIComponent(v)})); } else if (value ! undefined value ! null) { parts.push(${encodeURIComponent(key)}${encodeURIComponent(value)}); } }); return parts.join(); }特别提醒undefined和null字段一般不需要传给后端除非后端有默认值逻辑encodeURIComponent是必须的不然中文或特殊字符会分裂参数。这些小坑在实际联调时能浪费你半小时。5. 性能、错误处理与避坑指南5.1 属性名是字符串还是Symbol整数键的遍历顺序有坑JS对象的键只能是字符串或Symbol即使你写{ 1: a }这个1也会被转成字符串1。所以对象不适合当真正的Map用。而且对象里整数型键遍历时会被自动升序排列字符串键按插入顺序排列。举个例子const obj {}; obj[b] 1; obj[2] 2; obj[a] 3; console.log(Object.keys(obj)); // [2, b, a]整数键2排到了最前面。如果你依赖遍历顺序做逻辑这个坑会很难查。推荐在有顺序要求的场景直接用Map插什么顺序就是什么顺序。Map的键可以是任意值而且有专门的大小和遍历方法比对象强。5.2 可选链和空值合并让对象访问不再报错经常遇到接口返回的数据层级很深比如res.data.user.profile.address中间任何一层是null或undefined直接访问就会报“Cannot read properties of undefined”。以前只能层层判断现在用可选链?.会清爽很多。const address res?.data?.user?.profile?.address;这里?.的作用是前面的值如果是null或undefined整个表达式直接返回undefined不再继续访问。注意它只对null和undefined有效如果是空字符串或0不会中断。配套使用的还有空值合并??它的作用是当左边是null或undefined时取右边的默认值。和||不同0、、false这些非空值不会被覆盖。比如const count data.count ?? 0当count是0时你不会被替换成默认值。这个组合在处理对象字段缺省时太有用了。5.3 引用共享和对象冻结防止别人改坏你的配置对象是引用类型在函数间传递时传的是引用。如果你在函数内部改了对象的属性外部也会跟着变。有时候这是优点比如操作同一个state时不用返回新对象但有时候这是灾难比如一个公共配置对象被组件A改了组件B看到的就错了。解决办法之一是拷贝之前讲过的浅拷贝可以解决一层问题多层问题要深拷贝。方法之二是把公共配置变成只读用Object.freeze()。const CONFIG Object.freeze({ api: /api/list, timeout: 3000 });Object.freeze能让对象不可扩展、属性不可修改、不可删除严格模式下修改会报错。注意它是浅冻结对象里的嵌套对象还是可变的。要深度冻结还得递归冻结每个属性业务里用到不多但理解机制很重要。还有个容易被忽略的问题当多个对象共用一个子对象时改动子对象会互相影响。比如把profile对象同时赋给两个用户对象改了一个的昵称另外一个也跟着变。排查这种bug特别费时间所以我通常在设计数据模型时就避免对象共享或者明确拷贝。5.4 判断属性是否存在hasOwnPropertyvsinvsReflect.has判断一个对象里有没有某个属性看似简单其实也有讲究。in操作符会去原型链上查找所以toString in {}是true因为toString在Object.prototype上。而hasOwnProperty只检查自身属性所以({}).hasOwnProperty(toString)是false。业务场景如果需要判断“自身有没有这个属性”一定要用hasOwnProperty或Object.hasOwn()ES2022新增推荐。还有一个容易被问到的问题如何判断对象里某个属性值是否存在比如判断字符串是否包含某个关键字。这种如果是判断属性值里的字符串可以用String.includes。比如user.name.includes(张)。这跟判断属性本身是两码事别搞混了。6. 最后分享一段个人排错经历有一次我们线上出现一个诡异bug表格里同一行的数据用户改了几次后居然“串行”了。排查到最后发现是前端把一个公共params对象直接赋值给了每一行的编辑对象然后修改行数据时把公共对象也改了。这就是典型的引用共享问题。从那之后我在代码规范里强制要求任何从外部传入的对象如果需要修改必须浅拷贝一层如果有嵌套必须深拷贝。办法很简单但救了很多次线上事故。还有一次一个同事用JSON.parse(JSON.stringify(obj))做深拷贝结果Object里的Date字段全变了字符串导致时间显示错乱。后来我们封装了统一的deepClone方法并在代码评审中明确禁止直接使用JSON序列化做深拷贝。这两个案例让我深刻觉得对象知识不是背概念它直接决定bug率。如果你刚接触这些内容我建议先不要贪多。把对象创建、遍历、拷贝、原型链这四个模块各写几个小demo然后在实际项目里刻意使用。比如每接到一个新需求都想想“这里的数据结构是对象还是Map要不要拷贝子组件会不会改动我的数据”养成这个习惯之后你的JS水平会明显上一个台阶。