news 2026/10/16 5:03:27

JS对象从创建到原理:原型链、属性描述符与深浅拷贝实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JS对象从创建到原理:原型链、属性描述符与深浅拷贝实战

作为一个整天跟 JavaScript 打交道的前端开发,我越来越觉得,对象这东西就是 JS 的命根子。你要是能把对象搞透,什么框架、源码、工程化,看着都跟玩拼图似的。但我也发现,很多写了两年多的开发者,对象还停留在“{ } 存东西”的层面,一问到原型链、属性描述符、深浅拷贝,就开始含糊其辞。这篇博文我不打算给你念文档,而是从一个实际项目里踩坑、爬坑的角度,把从创建一个对象到理解它底层原理的完整路径捋一遍。没有废话,全是实操里提炼出来的东西。

1. 对象是什么:先搞清楚它为什么是 JS 的“一等公民”

1.1 万物皆可对象:JS 世界的基本设定

很多刚入门的朋友会把对象理解成“键值对的集合”,这个说法没错,但特别容易误导人。准确来说,对象是一组无序属性的集合,属性名是字符串或 Symbol,属性值是任意类型的数据。这听起来简单,但你要明白一个底层逻辑:JS 这门语言本来是没有“类”的概念的,它的一切高级特性都是基于对象模拟出来的,函数是对象,数组是对象,正则也是对象。这就意味着,你对对象的理解深度,直接决定了你对整个语言的掌控程度。

我接手过好几个别人留下的老项目,里面大量代码是用var person = {}这种方式散装定义的,属性满天飞,方法到处挂。之所以这么乱,核心原因就是没搞懂对象在 JS 里的定位。当你明白“几乎所有数据形态的顶层都是对象”时,你就会下意识地思考:这个东西该不该合并,那个方法该不该挂到原型上,全局变量该怎么收敛。这个认知转变,是我觉得新手到进阶最关键的坎。

1.2 引用类型:变量里装的根本不是对象

这是我认为先要从底层刻进脑子里的认知。你写let obj = { name: '锋哥' }的时候,变量obj里存的并不是那个对象本身,而是对象在内存堆区里的一把“门钥匙”(引用地址)。常规的原始类型像字符串、数字,值就存在栈上,你复制一个给别的变量,是值拷贝,互不影响。但对象不一样,你let obj2 = obj,只是把这把钥匙复制了一份,两把钥匙开的还是同一扇门。

这个特性是后面所有坑的根源。我在给团队做 Code Review 时,最常见的 bug 就是有人以为const obj2 = obj就完成了一次复制,结果改obj2的时候把obj也改了。很多新手会惊讶,但如果你能理解引用类型的存储方式,你会觉得不惊讶才是怪事。内存模型不是高深的理论,它是你理解深浅拷贝、性能优化、意外修改等一切问题的基石。

2. 创建对象的几种姿势:别只会字面量和new Object(),更别乱用new

2.1 字面量:日常最常用,但有个细节多数人没注意

对象字面量{ }是日常中最推荐的创建方式,没有之一。它的优势是直观、简洁、性能好(V8 引擎对字面量创建的对象做了大量优化)。但这里有个细节,新手一般不会注意:属性名的引号问题。比如{ 'user-name': '锋哥' }这种带连字符的键必须加引号,否则就报错;而{ name: '锋哥' }这种合法的标识符,加不加引号都一样。

还有一个实战经验:当你需要根据条件动态添加属性时,别用拼字符串的方式搞出十万个if,ES6 的展开运算符和计算属性名computed property是特别优雅的解法。举个例子,比如你要根据用户权限决定对象里放哪个字段:

const baseInfo = { id: 1, name: '锋哥' }; const permission = isAdmin ? { role: 'admin' } : { role: 'guest' }; const finalUser = { ...baseInfo, ...permission };

这个看似简单的展开运算,实际就是一种浅合并。你后面会经常用到,但那时你会发现它也有坑——嵌套对象还是会被共享。这就是我们从创建对象过渡到原理层面的第一步:不是所有的复制都是深层的。

2.2 工厂函数、构造函数和Object.create:绕不开的历史包袱与进化

老代码里经常能看到这种写法:

function createPerson(name) { return { name: name, sayHi: function() { console.log('hi'); } }; }

这叫工厂函数,它的问题是每次调用都会在内存里生成一个全新的sayHi函数,如果创建一万个对象,就有一万个不同的函数实例。这在早期内存紧张的时代是浪费,于是就有了构造函数加原型链的解法:

function Person(name) { this.name = name; } Person.prototype.sayHi = function() { console.log('hi'); };

new关键字创建对象时,JS 引擎会悄悄做几件事。你以为new就是执行一下函数?根本不是。它会在函数内部隐式创建一个新对象,让这个对象的原型指向构造函数的prototype,然后再把构造函数里的this绑定到这个新对象上,最后如果你没显式返回对象,就返回这个刚创建的实例。

这个阶段,ES6 引入了class,它本质是构造函数的语法糖,写起来更清晰,但底层机制还是那套。所以我的建议是:日常业务代码直接用class没毛病,但你心里得清楚它底层还是在走原型链那一套。至于Object.create(null)这种创建“纯净对象”的方式,我在做字典映射、哈希表场景时会用,它能避免因为原型链上的属性导致逻辑误判,比如{}在控制台输入toString你是能访问到的,而Object.create(null)创建的对象是真正意义的“裸对象”。

2.3 不同方式的选型对比:一张表看懂什么时候用哪种

创建方式核心特点适用场景注意事项
对象字面量{ }最简洁、性能优化好普通数据存储、常量配置创建单例对象不要用new
工厂函数封装创建逻辑,返回对象需要复杂逻辑生成实例注意每实例副本,内存开销大
构造函数(new+prototype)方法共享在原型上,节省内存需要创建大量相同结构的实例必须用new调用,否则this指向全局
class语法糖结构清晰,适合现代业务面向对象编程还是原型那套,别误以为加了真正类
Object.create()自定义原型对象继承指定对象、创建裸对象参数为null时得到无原型对象

这张表是我每次给团队做培训时必讲的清单。你不一定全背下来,但心里要有张地图:在哪块业务用哪种姿势,踩了什么坑往哪方面排查。比如规格大的数据列表,就别用工厂函数批量建对象,内存一上去页面就卡,换成构造函数挂原型的方式能顺畅得多。这也是为什么我从不让团队在forEach里写{ name: xxx }去创建一万个对象,能复用结构的就应该复用。

3. 核心原理拆解:属性特征、原型链、this 绑定,三座大山一次翻越

3.1 属性描述符:对象属性的“隐藏设定”超乎你想象

很多人以为属性就是“名字加值”,用了好几年才发现 JS 的每个属性都是带着“特性”的。什么叫特性?比如这个属性能不能被重新赋值,能不能被删除,遍历的时候会不会出现。这就是属性描述符(Property Descriptor)。

你直接看Object.getOwnPropertyDescriptor就能调试出一堆细节:

const obj = { name: '锋哥' }; console.log(Object.getOwnPropertyDescriptor(obj, 'name')); // 输出: { value: '锋哥', writable: true, enumerable: true, configurable: true }

四个属性分别对应:值、可写性、可枚举性、可配置性。平时我们开发不会太注意,但一旦涉及框架源码、数据响应式,这玩意儿就无比关键。比如 Vue 2 的响应式就是通过Object.defineProperty去劫持属性的get和set。你在改框架配置时,遇到明明弹出一个属性不可修改,多半就是writable: false或configurable: false在作怪。

这里有个实用技巧:用Object.preventExtensions、Object.seal、Object.freeze可以控制对象的可扩展性、不可删除属性和不可修改属性。我一般对常量配置对象会做Object.freeze,防止别人在代码里偷偷改了全局公共配置,排查半天查不到。这三个方法的使用场景区分要清楚:防扩展是“不能加新属性”,密封是“不能加也不能删”,冻结是“彻底只读”。

3.2 原型链:为什么“对象能使用方法”不是理所当然的

先问一个最容易被当成废话的问题:你创建一个空对象{},然后用.toString()为什么能用?你并没有定义这个方法。答案就是原型链。每个对象内部都有一个隐藏属性(在规范里叫[[Prototype]]),指向另一个对象。当访问一个属性时,当前对象自己没有,引擎就会沿着这条链往上找,直到找到或者到达最顶层的null。

很多朋友学这里会跟“构造函数”的概念混淆。这里要理清一个关键点:所有实例对象的原型,指向的是构造函数的prototype对象,而不是构造函数本身。也就是说,person.__proto__ === Person.prototype,这是两条完全不同的线索。

这在内存共享上意义重大。你把方法放在Person.prototype.sayHi上,一万个实例访问的都是同一个函数地址,这就是代码优化的核心思想。继承本质上也是在操作这条链,比如让Student.prototype继承Person.prototype,那么学生实例查找方法时,会先找自己,找不到就去Person.prototype里找。理解到这一层,你去读框架里各种“继承”、“多态”的代码,就不会发怵。

值得注意的是,规范不推荐直接用__proto__,更推荐Object.getPrototypeOf和Object.setPrototypeOf。但__proto__在浏览器里依然被广泛支持,阅读老代码时会频繁见到。你要把它当“通往原型对象的快捷方式”理解,但写新代码最好规范些。

以下是一个极简原型链的示意:

function Animal(name) { this.name = name; } Animal.prototype.sayName = function() { console.log(this.name); }; function Dog(name) { Animal.call(this, name); // 核心:把父构造函数当普通函数调用,传 this 进去 } Dog.prototype = Object.create(Animal.prototype); // 核心:重新指定原型 Dog.prototype.constructor = Dog; // 修复 constructor 指向 const d = new Dog('旺财'); d.sayName(); // 旺财

这种手写继承的方式虽然老,但最能看清原型链是怎么拼接起来的。如果你用class的extends,看到编译产物觉得复杂,回头再看这段就全明白了。

3.3 this 绑定:调用位置决定一切,箭头函数是例外

很多初学者对 this 的恐惧,其实源于把静态作用域和动态绑定搞混了。普通函数的this是在调用时确定的,谁调用的它,它就指向谁。这句话值得用十个项目去验证。比如:

const user = { name: '锋哥', say() { console.log(this.name); } }; user.say(); // '锋哥' const fn = user.say; fn(); // undefined,这里 this 丢失了

第二个例子中,fn调用时没有对象前缀,this在非严格模式下会指向全局对象。这就是面试和实际开发里最常遇到的坑——方法提出来单独调用,this就错了。解决方案有很多:fn.call(user)、fn.bind(user)、箭头函数捕获,但核心是你得先意识到这里有坑。

箭头函数的特性是它本身没有this,它用的this是定义时外部作用域的那个。我在做定时器、事件回调时特别喜欢用箭头函数,因为不需要再临时const self = this了。但这也带来一个新的注意点:箭头函数不能作为构造函数使用,也不能依赖 arguments 对象(要用 rest 参数)。

最底层再看一眼new和 bind。new里构造函数被调用时,this指向新对象;bind返回一个新函数,这个函数的this被永久锁死。这四个规则(默认绑定、隐式绑定、显式绑定、new绑定)就是全部。优先级上,new最高,其次是显式绑定,再是隐式绑定,最后是默认绑定。

4. 运行机制实战:遍历、合并、拷贝、比较,每一个都是硬碰硬

4.1 对象的遍历与检测:for...in、Object.keys、Reflect.ownKeys到底用哪个

原型的知识如果只是“能理解”而不“会用”,那是没用的。这里正好有最典型的场景:遍历。新手用for...in遍历对象,一不小心会把原型上扩展的属性也遍历出来。永远记住,for...in是遍历所有“可枚举的自身属性加继承属性”的。如果你不清楚这一点,给Object.prototype挂过扩展,那么所有 for...in 都会中招。

Object.keys则安全且常用,它只返回自身可枚举属性。但也有局限,它无法识别 Symbol 类型的键。如果你需要处理 Symbol 键,用Reflect.ownKeys可以拿到全部自身属性的键(包括不可枚举和 Symbol)。这是我强烈推荐的方法。举一个实际场景:你想拷贝一个对象,但对象里可能有 Symbol 键作为内部标记,用Object.keys加Object.assign会把标记丢得一干二净。你自己写的代码还好,如果是解析第三方返回的数据结构,这个坑就特别隐蔽。

判断一个对象是否有某个属性,也很有讲究。obj.name的做法,如果属性值本身是undefined或者属性来自原型链,会干扰判断。最稳妥的是Object.prototype.hasOwnProperty.call(obj, 'name'),只查自身。现在也有更好的Object.hasOwn(obj, 'name'),这个 API 是标准提供的,语义更清晰。ES2022 之后的代码里我建议直接用Object.hasOwn。

关于空对象的判断,很多老代码写JSON.stringify(obj) === '{}',这种方式在属性值为undefined、函数、Symbol 时会失真。正确的姿势是组合Object.getOwnPropertyNames+Object.getOwnPropertySymbols,或者通过Reflect.ownKeys(obj).length === 0来判断。

4.2 深浅拷贝:为什么说“拷贝”这件事水很深,以及三个一次性解决的办法

深浅拷贝的问题,是每个 JS 开发者都要面对的。先说浅拷贝,Object.assign和展开运算符{...obj}都是浅拷贝。浅拷贝的意思是,对于对象里的基本类型属性会完整复制,但如果是嵌套的对象,那么新对象里那个属性还是指向同一个地址。举个典型的业务场景:你有一个用户对象,里面有个address是对象,你用{...user}复制了一份,然后修改新对象的address.city,老用户的address.city也跟着变了。这种问题在表单回填、取消编辑的场景特别常见。

深拷贝的解决主流有三招。第一招是JSON.parse(JSON.stringify(obj)),简单粗暴,但缺点是:undefined、函数、Symbol 值会丢失,Date 会变成字符串,RegExp 会变成空对象,循环引用会直接报错。所以它只适合纯数据结构的拷贝。第二招是structuredClone,这是现代浏览器原生 API,能处理循环引用、Date、Map、Set,比 JSON 方案强很多,是目前的首选,但要考虑兼容性。第三招是自己写递归函数,用 WeakMap 处理循环引用,这是最彻底、最可控的方案,适合特殊定制。

这里我必须强调一个经验:团队里如果有一个公共的深拷贝函数,不要自己实现第三招,否则大概率写出一个内存爆炸、循环引用崩掉的轮子。能使用原生能力就优先使用,框架里一般也自带了能调用的 deep clone 工具。自己写递归的唯一理由,是你需要处理特殊类型,比如保留原型链上的方法。

4.3 对象比较与解构:两个看起来一样的对象,为什么永远===不相等

对象比较是另一个高频困惑:这个对象和另一个对象内容长一样,但a === b却是false。因为比较的是引用地址。只有当你真的需要判断两个对象内容是否相等时,可以手写一个遍历比对函数,或者借助工具库的isEqual。日常开发中,判断引用是否相同用于优化性能是合理的,比如避免重复渲染时判断数据是否变更,但判断内容是否相同要注意性能开销。

解构赋值也是操作对象时离不开的。从嵌套结构中提取属性时,要留意变量名冲突和默认值写法。const { name, age = 18 } = user;这种写法能在属性缺失时给默认值,但有一个坑:如果name的值本身是null,默认值不会生效,它照样是null。这在解析接口数据时特别容易翻车。

另外一个符合实操习惯的经验是 rest 解构。比如你要排除对象中的某个敏感字段再去传给接口,可以这样:

const { password, ...rest } = user;

这是很干净的排除写法,不需要你再 delete 一个字段。这种技巧在框架状态管理里太常用了。

5. 常见问题排查与避坑速查:稳、准、狠地定位对象的真实身份

5.1 高频报错的根因分析:为什么“xxx is not a function”“Cannot read property of undefined”

很多初学者看到Cannot read property 'length' of undefined就慌了,其实这类问题 90% 是对象结构里少了某一层。比如接口数据返回来,你以为data.list是数组,结果这次是data直接是null。解决方案是写访问保护,比如data?.list?.length,现代 JS 对这个问题有很成熟的解法。这背后还涉及一个思考:“你到底应该信任哪个层级的结构,是接口返回的还是框架 store 里的?”我的经验是,尽量在数据入口处就做数据清洗与合规化,不要让undefined如幽灵般在代码里游走。

另一种高频问题就是“对象中的方法报错不是函数”。这种多半是原型链出了问题。比如你用Object.create(null)创建对象,然后又调用它上面的默认方法,直接报错。或者你在继承中忘了重新指定constructor,导致后续instanceof判断结果出错。这种排查没有捷径,就是靠对原型链的熟悉程度。

5.2 必坑速查表:问题、原因、解法一页全看懂

问题现象根本原因排查方向与解决
对象修改影响了其他地方浅拷贝共享了嵌套引用地址深拷贝或按需拆结构
for...in遍历出了意料之外的属性原型链上的可枚举属性混入用Object.keys或Reflect.ownKeys
JSON.stringify输出为空对象undefined、函数、Symbol、循环引用用structuredClone或自定义判断
方法里的this变成undefined丢失绑定,或严格模式下默认绑定为undefined用bind、箭头函数捕获、或显式call
Object.create(null)创建的对象toString不能用无原型对象,天然缺少Object.prototype方法需要使用对象方法时不要用裸对象
Object.keys拿不到 Symbol 键keys只返回字符串键用Reflect.ownKeys
深拷贝处理循环引用报错递归互相引用导致栈溢出使用WeakMap记录已拷贝对象
Object.freeze之后还是被改了冻结是浅层的,嵌套对象依然可变需要递归冻结或使用代理深冻结
Object.assign合并后结果不符合预期后面的属性覆盖了前面的,或者浅拷贝导致嵌套属性冲突明确合并规则,或使用深合并工具

5.3 优化心法:面向对象的性能提速与内存释放,实际项目中如何用起来

聊性能会首先想到 V8 下的隐藏类和对象内联缓存。不必死记,但要有意识:不要频繁增删对象属性,因为这会破坏隐藏类的优化。举个实际案例,团队之前有个循环频繁给对象动态添加一个status状态位,结果在数据量大时明显比别人写法慢了 20% 以上。改成构造时就把所有属性字段初始化完整,性能立刻回来了。

内存释放也很重要。在大型单页应用中,一个长期存在的全局对象会保留大量引用,导致 old space 无法回收。我的建议是离开页面时,手动将大对象置为null,断开引用链。此外,尽量不要用Object.prototype上挂方法,一旦污染,整个应用所有的对象循环遍历都会变慢。

我可以分享一个我自己一直在用的习惯:创建一个“数据清洗层”,专门把接口返回的数据整理成规范的对象模型,该默认值给默认值,该嵌套的嵌套好,之后再进入业务代码。这比让每一个组件都自己去判断字段是否存在要高效太多。对象模型的稳定,是整个项目稳定的一半。

个人回顾来讲,我对对象理解最深的时刻,不是看文档,而是离线排查了一个因原型链污染导致全线组件报错的凌晨。那次之后,我特别能把原型链、属性描述符、拷贝这些知识串到一起。对象其实是一块地基,你越早把这里夯实,之后不管写业务还是读框架,都会顺得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 5:02:48

EndNote中文文献双语引用:重建字段与样式模板,解决乱码混排

简介:资源包为EndNote用户提供中文文献管理与中英双语引用的一站式解决方案,主要面向经常撰写学术论文、需要同时处理中文期刊、书籍和学位论文引用格式的研究生、科研人员及期刊投稿作者。包内共4个文件,包含3个xml样式模板与1个ens输出格式…

作者头像 李华
网站建设 2026/10/16 5:02:07

七类爬虫采集工具实用指南:从数据源判断到三分钟跑通

上周一个朋友让我帮她整理一份行业名单,输入框一行行粘过去,弄到晚上十点还剩一半。我过去看了一眼,那份数据其实就躺在同一个网页列表里,结构规规整整,十几分钟就能全部下载完。她听完愣了几秒。这不是她手慢&#xf…

作者头像 李华
网站建设 2026/10/16 5:01:12

Spark Standalone与Yarn运行模式深度对比与生产选型指南

1. 项目概述:为什么必须搞懂 Standalone 和 Yarn 这两种 Spark 运行模式在某高校大数据实验室带学生做实时日志分析项目时,我遇到过一个典型场景:团队用同一份 Spark Streaming 代码,在本地开发环境跑得飞快,一上生产集…

作者头像 李华
网站建设 2026/10/16 5:01:03

时空轨迹分析利器:GEO源码编译与功能定制实战指南

做时空轨迹分析这几年,我越来越强烈地意识到,工具只有攥在自己手里才算数。2026 年这轮 GEO 源码搭建,是我和几个搞空间计算的朋友反复讨论后,决定认真做透的一件事。所谓 GEO,是一个面向时空轨迹与地理要素处理的开源…

作者头像 李华
网站建设 2026/10/16 5:00:55

MLRun实战指南:从训练到部署的MLOps工程化全流程

1. 为什么需要MLRun:MLOps的痛点与破局思路1.1 MLOps到底难在哪先聊点实话。深度学习、机器学习项目,很多团队最后不是死在模型精度上,而是死在“把模型弄上线”这件事上。我在多个团队里见过同一种剧本:算法工程师在本地笔记本上…

作者头像 李华
网站建设 2026/10/16 5:00:09

Django模型完全指南:从ORM概念到迁移与查询性能优化

1. 模型设计与核心概念拆解1.1 先理解Django模型到底解决什么问题我最早接触Django模型时,第一反应是这东西不就是个数据库表映射工具吗?后来被前辈点醒,才意识到模型层的价值远不止“把表变成类”这么简单。它本质上是Django整套框架的基石—…

作者头像 李华