
对象这东西在 JavaScript 里几乎无处不在。你要是把整个语言比作一辆车对象就是发动机舱里最核心的那套结构函数是对象数组是对象连 Promise、DOM 节点、模块导出底层全都挂着对象的标签。很多人写了两年代码天天用{}和new Object()可一到面试或者读框架源码时碰到prototype、__proto__、Object.create就直接发懵。这篇我就把这个主题彻底拆开从最基础的对象字面量讲起依次覆盖工厂函数、构造函数、Object.create、ES6 class再到原型链底层查找机制最后聊一聊 V8 引擎在底层到底怎么“造”对象。内容适合两类人一是基础不牢、想系统补一遍的初中级前端二是工作中经常被“原型链和性能优化”绕晕的人。看完你不仅能说出 10 种创建方式还能把每种的底层差异讲明白。1. 十种对象创建方式逐个拆解先亮个底JavaScript 创建对象的方式很多但本质都绕不开两件事——分配内存、设置原型。表面写法不同最终生成的对象形态却可能天差地别。我把它们分成五组来讲每一组都给你能直接跑的示例代码顺带说清楚各自的适用场景。1.1 对象字面量与 new Object()对象字面量是最高频也最自然的一种方式// 方式1对象字面量 const user { name: 张三, age: 25, greet() { console.log(你好我是${this.name}); } }; user.greet(); // 你好我是张三对象字面量的好处是直观、代码量少创建出来的对象原型默认指向Object.prototype。你不需要关心 new、构造函数这些细节适合一次性、临时性的数据结构。// 方式2new Object() const user2 new Object(); user2.name 李四; user2.age 30;new Object()和{}在绝大多数情况下等价原型也是Object.prototype。区别非常小如果硬要说字面量在解析阶段就完成了对象字面量表达式的求值而new Object()多了一次构造函数调用另外new Object(某个值)有类型转换的副作用比如传字符串会返回包装对象。实际项目里我几乎不用new Object()除非是某些代码规范明确要求或者你想借助函数调用动态决定构造哪种类型。适用场景简单数据映射、配置项、传递参数对象直接用字面量。1.2 工厂函数与构造函数如果你需要批量创建结构相同但数据不同的对象工厂函数是最早的“模板”思路// 方式3工厂函数 function createUser(name, age) { return { name, age, greet() { console.log(我是${this.name}今年${this.age}岁); } }; } const u1 createUser(王五, 28); const u2 createUser(赵六, 32);工厂函数本质就是一个普通函数通过返回值把对象交给调用方。它灵活、无 new 约束还能利用闭包给对象塞私有状态。缺点也明显每次创建都会生成新方法没法复用内存上不划算。再看构造函数// 方式4构造函数 new function User(name, age) { this.name name; this.age age; } User.prototype.greet function () { console.log(我是${this.name}今年${this.age}岁); }; const u3 new User(孙七, 27); const u4 new User(周八, 35);构造函数把公共方法挂到prototype上所有实例通过原型链共享同一个 greet不会像工厂函数那样每建一个对象就复制一遍方法。这也是 new 与普通函数调用的关键差异new会在后台帮我们创建对象、绑定this、设置原型、返回对象。适用场景需要多个相似对象时推荐构造函数追求代码简单、不在乎方法复用时工厂函数也能顶上。1.3 Object.create() 与 class 语法Object.create(proto, propertiesObject)是另一种思路——它不通过构造函数而是直接指定新对象的原型// 方式5Object.create() const userProto { greet() { console.log(你好我是${this.name}); } }; const u5 Object.create(userProto); u5.name 吴九; u5.age 28; u5.greet(); // 你好我是吴九 // 第二个参数还能顺便定义属性 const u6 Object.create(userProto, { name: { value: 郑十, writable: true }, age: { value: 22 } });这种方式的控制力最强想继承哪个原型就传哪个甚至可以Object.create(null)得到一个完全“裸”的对象。后面讲原型链时你会看到它多有用。ES6 引入的 class 是另一层包装// 方式6ES6 class class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name}发出声音); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed breed; } speak() { super.speak(); console.log(${this.name}汪汪叫); } } const dog new Dog(旺财, 金毛);很多人以为 class 是全新的面向对象机制实际上它只是一个语法糖本质还是构造函数 原型链。class的方法定义在Dog.prototype上extends则把Dog.prototype的原型指向Animal.prototype。但不要因为它是“语法糖”就轻视class 的可读性、继承写法、以及“必须先 new 才能调用”的限制让它在大型项目里远比裸构造函数友好。适用场景需要清晰表达继承关系时用 class需要自由指定原型时用Object.create()。1.4 Object.assign、展开运算符与 JSON.parse这三种方式的核心不是“从零创建”而是“基于已有对象创建新对象”在数据拷贝、合并和响应式处理时非常常见。// 方式7Object.assign() const base { type: admin, role: manager }; const profile { name: 冯十一, age: 40 }; const merged Object.assign({}, base, profile); // merged { type: admin, role: manager, name: 冯十一, age: 40 } // 方式8展开运算符 const merged2 { ...base, ...profile };Object.assign和展开运算符都是浅拷贝只复制一层属性如果属性值是引用类型新旧对象会共享同一个引用。展开运算符写起来更短而且能直接覆盖同名属性几乎成了现代项目里的首选。Object.assign还有一个不同点它会触发 setter所以某些框架内部用它来实现属性合并和默认值填充。// 方式9JSON.parse(JSON.stringify(obj)) 深拷贝式创建 const raw { name: 陈十二, tags: [a, b], info: { age: 20 } }; const copy JSON.parse(JSON.stringify(raw));JSON 方式算是一个“纯数据创建”的偏方先把对象序列化成字符串再重新解析成全新的对象实现深拷贝。但代价是丢失函数、undefined、Symbol、循环引用还会把Date转成字符串。所以它适合纯 JSON 数据的快照千万别拿来处理带方法或特殊类型的复杂对象。适用场景合并配置、复制状态、接口数据回显时根据是否需要深拷贝来选择方式。1.5 进阶Object.defineProperties 与 Proxy 包装最后两种属于偏底层的元编程方式。比如你需要精确控制属性的可枚举性、可写性、配置性时Object.defineProperties非常好用// 方式10Object.defineProperties() const obj Object.defineProperties({}, { name: { value: 钱十三, writable: false, enumerable: true, configurable: false }, age: { get() { return this._age; }, set(val) { this._age val; } } }); obj.name 改不了; // 非严格模式下静默失败严格模式抛 TypeError而Proxy也能创建“代理对象”const target { message: 原始数据 }; const proxy new Proxy(target, { get(obj, prop) { console.log(访问属性: ${String(prop)}); return obj[prop]; } }); console.log(proxy.message); // 拦截到访问严格说 Proxy 创建的是包装层不是替代传统对象创建方式更常用于拦截、校验、响应式追踪。你把它放在“对象创建”语境里理解是因为很多现代框架的响应式系统就是在“创建一个可以被拦截的对象”这一步做文章。适用场景定义不可变属性、实现访问拦截、构建响应式数据流时使用。1.6 十种方式对比速查表创建方式原型来源是否复用方法特殊点对象字面量{}Object.prototype不复用最简单高频使用new Object()Object.prototype不复用与字面量等价低频工厂函数由返回对象决定不复用灵活但方法重复创建构造函数 new构造函数.prototype复用经典模式Object.create()手动指定看原型对象原型控制力最强ES6 class类.prototype复用语法糖继承清晰Object.assign()Object.prototype不复用浅拷贝合并展开运算符Object.prototype不复用浅拷贝合并简洁JSON.parse/stringifyObject.prototype不复用深拷贝但丢失类型defineProperties/Proxy目标/代理对象不复用精确控制或拦截行为2. 原型链底层原理精讲讲了这么多创建方式接下来就必须回答“为什么”。你写的obj.someMethod()到底是怎么样顺着一条“暗线”找到方法的这一切都指向原型链。2.1 prototype、proto与 constructor 的三角关系JavaScript 里函数也是对象所以函数上可以挂属性。每个普通函数都有一个prototype属性这是个对象里面默认带一个constructor属性指回函数本身。而用这个函数创建出来的实例内部会有一个[[Prototype]]指针指向函数的prototype对象。浏览器里我们可以通过__proto__去访问它。function User(name) { this.name name; } const user new User(小明); console.log(user.__proto__ User.prototype); // true console.log(User.prototype.constructor User); // true console.log(Object.getPrototypeOf(user) User.prototype); // true把三者关系用一句话说死实例.__proto__指向构造函数.prototype构造函数.prototype.constructor指回构造函数。我在实际项目中排查问题时最常用的其实是Object.getPrototypeOf(obj)因为__proto__虽然是浏览器普遍支持但在严格规范里属于遗产属性建议新代码尽量用前者。2.2 new 底层到底做了什么new不是魔法它做了一个四步操作。我建议你把下面这段逻辑记牢因为所有构造函数与 class 的行为都由此派生创建一个新的普通对象{}同时让它的原型指向构造函数的prototype属性把构造函数的this绑定到新对象执行构造函数内部代码给this添加属性、做初始化如果构造函数显式返回了一个对象那就返回那个对象否则返回刚创建的this。// 模拟 new 的核心逻辑 function myNew(Constructor, ...args) { const obj Object.create(Constructor.prototype); const result Constructor.apply(obj, args); return (result typeof result object) ? result : obj; } const user2 myNew(User, 小红); user2 instanceof User; // true这段代码也解释了为什么构造函数里return 普通对象会覆盖默认对象而return 基本类型会被忽略。很多新手写构造函数时容易踩坑比如忘记new直接调用这时候this指向全局对象会莫名其妙地给全局加属性。解决方案是开启严格模式use strict它会直接报错拦住你。2.3 原型链的查找机制与属性遮蔽当你访问obj.prop时引擎执行的是这么一套流程看对象自身有没有这个属性如果自身没有就沿着__proto__指向的原型对象上找还没有就继续往上直到Object.prototype如果Object.prototype上也没有就返回undefined。const grandParent { family: grand }; const parent Object.create(grandParent); const child Object.create(parent); child.name child; console.log(child.name); // child自身属性 console.log(child.family); // grand从原型链上层找到 console.log(child.toString); // Object.prototype.toString 方法这段链路就是“原型链”。有个细节值得注意当你在对象上定义一个与原型同名的属性时并不是去修改原型的属性而是在对象自身新增一个属性把原型的属性“遮住”这叫属性遮蔽。如果你确实想修改原型上的属性得明确拿到原型对象再去改但绝大部分时候我不建议你这么做因为那会影响到所有继承该原型的对象。继承的本质也在这里子类构造函数 prototype 的原型指向父类的 prototype。用 class 写法时extends会做两件事——设置SubClass.prototype的原型为SuperClass.prototype并且把SubClass.__proto__指向SuperClass后者是为了让子类能继承父类的静态方法。3. 引擎视角V8 到底是怎么建对象的很多前端只关心语法不关心引擎但一旦你开始写高并发的数据处理、渲染循环、复杂表格就会发现某些写法的性能天差地别。接下来的内容就来自 V8 的实际优化机制。3.1 隐藏类Hidden Class与内联缓存V8 为了让对象属性访问接近静态语言的速度并不像普通解释器那样每次都做字典查找。它会给对象创建一个“形状”描述叫隐藏类记录属性的名字和在内存中的偏移量。当你连续创建多个结构一样的对象时它们会共享同一个隐藏类属性访问就能走一条非常快的路径内联缓存也会记住这个调用点对应的隐藏类下次再遇到同样形状的对象直接复用上次的结果。function Point(x, y) { this.x x; this.y y; } // 推荐保持固定结构立即初始化 const p1 new Point(1, 2); const p2 new Point(3, 4); // p1 和 p2 的隐藏类一样访问 x/y 很快但如果写法不统一比如先const p {}再往里面加属性而且不同分支加的属性顺序不同V8 就得不断创建新的隐藏类优化直接失效。所以一个经验法则对象形状要稳定属性命名一致最好在构造函数或对象字面量里一次性把属性都声明出来。3.2 属性存储的两种模式对象属性在 V8 里分为两种存储方式一种叫“快速属性”属性名在隐藏类里有固定索引存在对象自身上一段连续数组里另一种叫“字典模式”当属性特别多、需要频繁增删时V8 会退化成用哈希表管理访问速度会下降但增删更灵活。什么情况会触发字典模式比较常见的是大量使用delete删除属性、动态添加几十个以上不同的属性、或者属性的插入顺序不稳定。Object.seal和Object.freeze则能在一定程度上固定对象结构让 V8 不再担心属性被增删从而继续保持快速模式。const config Object.freeze({ host: localhost, port: 8080 });这里要注意freeze只是冻结了直接属性的增删改如果你冻结的对象里还有嵌套对象嵌套对象依然可变需要递归处理才能真正“深度冻结”。3.3 哪种创建方式在引擎层面更占便宜从引擎角度看把属性和方法一次性构造好的方式通常最快。比如// 推荐构造函数或对象字面量一次性声明 function User(name) { this.name name; this.login function () {}; } const u new User(test); // 不推荐动态添加属性 const obj {}; obj.name test; obj.login function () {};前者对象形状稳定V8 可以复用隐藏类后者每次动态添加都会触发形态变化。Object.assign和展开运算符在创建新对象时只要源对象形状一致性能也不错而JSON.parse(JSON.stringify())因为是先序列化再解析开销最高尽量只在确认需要深拷贝时才用。另外如果是频繁创建大量临时对象对象池比硬创建更划算那是另一层优化以后有机会再聊。4. 常见坑与调试实录最后聊点实战中经常翻车的部分。这些坑单看文档很难发现但我几乎每个都在真实项目里踩过。4.1 instanceof 为什么有时候不靠谱obj instanceof Fn判断的是Fn.prototype是否出现在obj的原型链上。这就有两个明显的坑一是如果构造函数返回了一个显式对象比如构造函数里return {}那这个实例与Fn.prototype就没有关系了二是多窗口、多 iframe 环境下页面 A 和页面 B 各有各的Object、Array数组从 A 传到 B 后arr instanceof B.Array会返回 false。// 跨窗口判断数组更安全的方式 function isArray(obj) { return Object.prototype.toString.call(obj) [object Array]; }所以复杂环境下用Object.prototype.toString、Symbol.toStringTag或者Array.isArray这类标准 API 更可靠少依赖instanceof。4.2 原型链污染与 Object.create(null)原型链污染是前端安全里一个很常见的隐患。简单说如果你把用户输入直接合并到对象上而用户提交了{__proto__: {isAdmin: true}}某些合并逻辑会顺着原型链把属性写进Object.prototype导致所有对象都“继承”到这个新属性。function merge(target, source) { for (const key in source) { if (typeof source[key] object source[key] ! null) { merge(target[key], source[key]); } else { target[key] source[key]; } } } // 如果 source 里带 __proto__很容易污染原型链防御手段也很简单合并时跳过__proto__、constructor、prototype这几个关键键或者使用Object.create(null)创建没有原型的“干净对象”作为配置项或临时存储对象的容器。这样做还有一个附带好处不会因为对象原型上被其他人挂载了什么方法导致for...in遍历时多出东西。4.3 hasOwnProperty 与 for...in 的取舍写for...in遍历对象时它会同时遍历继承来的可枚举属性这是新手最容易忽略的点。const base { inherited: true }; const obj Object.create(base); obj.own 1; for (const key in obj) { console.log(key); // owninherited 都会被打印 }所以遍历外部传入的普通对象时我一般直接Object.keys(obj)或Object.entries(obj)它们只返回自身可枚举属性如果需要判断属性归属再用Object.prototype.hasOwnProperty.call(obj, key)。千万不要直接写obj.hasOwnProperty(key)万一用户传入的对象把hasOwnProperty覆盖了就会报错或者被绕过。4.4 判断对象是否为空的小技巧还有一个很常见的需求判断一个对象有没有属性。直白的写法是Object.keys(obj).length 0但注意JSON.stringify({}) {}也可以判断不过一旦对象里有undefined、Symbol、函数JSON 序列化会直接忽略它们结果就不准了。稳妥做法还是用Object.getOwnPropertyNames(obj).length 0再加上Object.getOwnPropertySymbols(obj).length 0这样才不会漏掉不可枚举属性和 Symbol 属性。4.5 原型链调试技巧排查原型链问题时我常用浏览器控制台的console.dir它能把对象的原型层级完整展开。另一个实用技巧是给Object.prototype临时加一个调试方法但用完之后要立刻删除否则全站对象都会背上这个属性造成污染风险。还有个小习惯遇到undefined的xxx.map is not a function先不要怀疑是某个库坏了第一时间打印console.log(Object.prototype.toString.call(data))确认它到底是数组还是类数组对象往往几步就能定位问题。结尾我在实战中最推崇的组合是日常数据结构用对象字面量批量创建用构造函数或 class需要深拷贝才用 JSON 或手写递归需要精确控制原型就用Object.create(null)。十种方式没有绝对的好坏只有适不适合场景。最后再分享一个小习惯每学一种创建方式都顺手在浏览器里打印一下obj.__proto__和Object.getPrototypeOf(obj)亲眼看看它的原型到底指向谁时间久了你对原型链的感觉会非常准。希望这篇长文能帮你把对象创建和原型链这块彻底打通。