ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript对象与原型链进阶:从引用类型到深拷贝的完整指南

JavaScript对象与原型链进阶:从引用类型到深拷贝的完整指南 先说个我自己的感受。JavaScript里几乎所有东西都能归到对象这个概念上——数组是对象函数是对象正则也是对象就连你在浏览器里操作的每个DOM元素本质上也是一组对象包装出来的。我见过不少写了三五年JS的人日常用对象用得飞起可一旦被问到“对象和原型链到底什么关系”“为什么判断空对象不能用obj {}”“delete一个属性为什么删不干净”这类问题就直接卡壳。这篇东西就是把我这些年折腾对象时攒下的理解和踩坑记录整理一遍覆盖从创建、继承到实战操作的完整链路适合那些已经会写JS、想往深处走一步的开发者也适合准备面试前系统过一遍对象知识的人。1. 先从“对象”的本质说起1.1 对象是什么引用类型的分水岭很多教程喜欢把对象说成“键值对的集合”这句话没错但只说了表面。真正决定对象行为特点的是它在JavaScript类型系统里的位置——引用类型。基本类型string、number、boolean、null、undefined、symbol、bigint存的是值本身你复制一个变量相当于复制了一份独立的副本两个变量之后怎么改都互不影响。对象存的是引用也就是内存地址。你写const a { name: 张三 }; const b a;然后把b.name改成李四你会发现a.name也跟着变了。这个现象新手最容易困惑老手偶尔也会栽跟头——比如在函数参数里直接修改对象属性以为传进来的是副本其实改的是同一个对象。function updateName(user) { user.name 李四; } const u { name: 张三 }; updateName(u); console.log(u.name); // 李四因为传进去的是引用不是副本这个引用语义带来的连锁反应很多比较两个对象不能直接用因为比较的是引用地址而不是内容函数传参时对象的修改会“穿透”到外部作用域不可变数据要自己手动实现没人帮你自动拷贝。理解了“对象是引用类型”这一天后面看原型链、深拷贝、响应式原理都会顺很多。React里为什么强调不可变数据Vue的响应式为什么能拦截对象本质上都是因为对象是引用类型改一个地方会影响所有引用它的地方。1.2 函数也是对象最容易被误解的概念“JS中函数是对象吗”这个问题经常被搜说明很多人都在这里纠结过。答案是函数就是对象而且是Function类型的实例。你可以给函数挂属性function greet() { console.log(Hello); } greet.author 张三; greet.version 1.0.0; console.log(greet.author); // 张三这看起来很奇怪但其实是合法的只是平时代码里未必有人这么写。函数对象和普通对象的区别在于它内部有一个[[Call]]内部方法所以可以被调用。数组也是一个对象区别在于它内部有专门的length处理逻辑和一堆数组方法同时会自动维护索引与length的对应关系。这就是JS“万物皆对象”说法的来源——也不是真的万物皆对象而是在对象之上叠加了不同的内部行为Internal Slot。明白这一点对理解call、apply、bind很有帮助。既然函数是对象那它就有自己的方法Function.prototype上挂了call、apply、bind本质上就是操作这个函数对象内部this指向的方式。我在面试里问到原型链的时候经常以此开场让候选人解释“为什么数组能用map、对象不能用map”能答出来的人说明他真懂原型链。2. 创建对象从字面量到工程化方案2.1 字面量与构造函数日常开发的基础创建对象最常用的方式就是对象字面量const user { name: 张三, age: 28, sayHi() { console.log(Hi, I am ${this.name}); } };字面量写法简单直观性能也好日常开发99%的场景用它就够了。new Object()也能创建但显得多余而且语义上不够清晰——你写new Object()的时候读者还要想一下你要干嘛字面量就直接表达“我要新建一个对象”。这里有个细节对象的键名如果是合法的标识符可以直接写如果包含连字符、空格或者数字开头就必须加引号const obj { user-name: 张三, 2nd: second };访问的时候也不能用点号必须用括号语法obj[user-name]。这一点在写表单数据映射、处理后端返回的驼峰与下划线字段时特别常见动不动就是data[user_profile]这种写法别觉得丑这是语法规定。ES6之后对象的写法进化了不少比如属性简写const name 张三; const age 28; const user { name, age }; // 等价于 { name: name, age: age }还有计算属性名const key age; const user { [key]: 28 };计算属性名在做动态表单、状态管理库的reducer时很实用不需要先创建空对象再赋值。2.2 工厂模式与构造函数模式当需要批量创建多个结构相似的对象时直接手写字面量就显得啰嗦。最早期的方案是工厂模式function createUser(name, age) { return { name, age, sayHi() { console.log(Hi, I am ${this.name}); } }; } const u1 createUser(李四, 30);工厂模式的缺点是你没法判断u1到底是用哪个函数创建的因为它自身没有类型标识instanceof只能返回跟Object相关的true。而且每个对象都各自保存了一份sayHi方法内存浪费虽说不严重但总归不够优雅。后来就有了构造函数模式function User(name, age) { this.name name; this.age age; this.sayHi function() { console.log(Hi, I am ${this.name}); }; } const u2 new User(王五, 25);用new调用时函数内部会发生四件事创建一个新对象、把新对象的原型指向构造函数的prototype、把这个新对象绑定为this、如果函数没有返回对象就返回这个新对象。这一步非常关键理解它之后你才能理解class本质上是怎么工作的。这一步也是面试高频题“new操作符做了什么”的标准答案。构造函数模式的问题在于方法写在函数内部每new一个实例就多一份方法副本。虽然不影响功能但如果在构造函数里创建了十个方法十万个实例就会有一百万个函数对象这个浪费就不小了。2.3 原型模式与class语法糖构造函数模式的方法浪费问题用原型模式就能解决。思路是把方法放到构造函数的prototype上所有实例共享一份function User(name, age) { this.name name; this.age age; } User.prototype.sayHi function() { console.log(Hi, I am ${this.name}); }; const u1 new User(张三, 28); const u2 new User(李四, 30); console.log(u1.sayHi u2.sayHi); // trueES6的class只是语法糖底层还是构造函数加原型class User { constructor(name, age) { this.name name; this.age age; } sayHi() { console.log(Hi, I am ${this.name}); } }class的方法实际上是定义在User.prototype上的静态方法定义在User本身。很多人误以为class和Java的类一样这是不对的——它的继承机制本质还是原型链class只是让你写得更像个正经类可读性和可维护性提升了但底层模型没变。我个人的观点是日常写业务直接上class没问题它是目前最清晰的表达方式。但如果你要调试一个继承链复杂的库或者想搞明白为什么子类实例能调用父类方法还是得回到原型链去理解。3. 原型与原型链对象继承的底层逻辑3.1 原型链是如何串联的每个对象除了Object.create(null)创建的都有一个隐藏的[[Prototype]]属性在浏览器里可以通过__proto__访问但正规的读取方式是Object.getPrototypeOf。当访问一个对象的属性或者方法时JS会先在对象自身找找不到就去它的原型上找还找不到就继续去原型的原型上找直到Object.prototype都没有才返回undefined。这就是原型链。拿一个普通数组举例const arr [1, 2, 3];arr自身有length和索引arr的隐式原型指向Array.prototype里面有map、filter、push这些方法Array.prototype的隐式原型又指向Object.prototype里面有toString、hasOwnProperty这些方法。所以数组能调用Object.prototype上的方法就是因为这层链。这条链的终点是null。走到null还没找到就返回undefined。这个“找不到就向上找”的机制就是JS继承的底层逻辑——子类实例能调用父类方法是因为子类实例的原型链上串着父类的prototype。我建议你在浏览器控制台里敲一下console.dir(arr)展开看它的[[Prototype]]再展开一次[[Prototype]]真实感受一下这条链比看十篇文章都管用。3.2 instanceof与constructor的真实行为instanceof判断的是对象与构造函数的prototype对象之间是否存在原型关系function User() {} const u new User(); console.log(u instanceof User); // true console.log(u instanceof Object); // true注意instanceof不要求对象的直接原型匹配只要原型链上能找到对应prototype就返回true。所以u既是User的实例也是Object的实例。这个“多义性”是JS面向对象的特色别的语言里不太常见。有一个常见坑修改prototype后旧实例的instanceof关系会发生变化function User() {} const u new User(); User.prototype {}; console.log(u instanceof User); // false因为u的原型还指向老的prototype对象而User.prototype已经换成了新的空对象。这一点在写库、做插件扩展的时候要特别小心——如果你动态替换了某个构造函数的prototype所有旧实例会变成“失去身份”的对象。另外constructor属性其实不保证指向正确。默认情况下User.prototype.constructor指向User但如果你整体覆盖了prototype又不手动把constructor指回去constructor就变成Object了。很多面试题会考这个实际开发中也会因为类继承的时候没处理constructor导致一些诡异问题。标准做法是User.prototype { constructor: User, sayHi() { console.log(Hi, I am ${this.name}); } };3.3 原型链污染隐蔽的安全风险这个知识点平时没人讲但真的很重要。所谓原型链污染就是通过修改Object.prototype或者Array.prototype往原型上塞属性导致所有对象都“凭空”多出这个属性。最经典的攻击场景是递归合并对象时攻击者传入__proto__或constructor.prototype作为键名function merge(target, source) { for (const key in source) { if (typeof source[key] object source[key] ! null) { if (!target[key]) target[key] {}; merge(target[key], source[key]); } else { target[key] source[key]; } } } merge({}, JSON.parse({__proto__: {polluted: true}}));这段代码跑完所有对象的原型都被改了然后几乎所有对象访问polluted都会得到true。这就是真实世界的漏洞利用原理一些知名工具库历史上也出过类似CVE。防御手段也不复杂使用Object.create(null)创建无原型对象来保存不可信数据在递归合并时跳过__proto__、prototype、constructor这几个键用Map代替普通对象存键值对冻结Object.prototypeObject.freeze(Object.prototype)。我自己的习惯是只要解析外部JSON一律先校验键名包含__proto__直接拒绝。别觉得这是小题大做你永远不知道用户提交的数据里藏着什么。4. 对象高频操作实战每个都值得收藏4.1 判断对象是否为空四种方案对比“判断对象为空”能搜出来一大堆方案说明这是日常开发里逃不掉的需求。所谓空对象一般指的是没有任何自有可枚举属性的对象。最常用的方案是Object.keys()function isEmpty(obj) { return Object.keys(obj).length 0; } console.log(isEmpty({})); // true但Object.keys只统计可枚举的自有属性如果对象里只有不可枚举属性它也会被算作空。另一种常见方案是JSON.stringifyfunction isEmpty(obj) { return JSON.stringify(obj) {}; }这个方案有个问题如果对象里有undefined、函数、Symbol值这些属性会被JSON.stringify忽略结果依然返回{}误判。比如{ a: undefined }在JSON.stringify后是{}但它显然不是一个空对象。最严谨的方案是用Object.getOwnPropertyNames能拿到所有自有属性包括不可枚举的function isEmpty(obj) { return Object.getOwnPropertyNames(obj).length 0 Object.getOwnPropertySymbols(obj).length 0; }如果只想判断“可枚举自有属性为空”用Object.keys即可如果想知道“这个对象除了原型链上的东西自己什么都没挂”用getOwnPropertyNames更准确。我的建议是先想清楚业务上“空”的定义再选API。两种“空”表达的是不同语义混着用会出bug。4.2 对象数组去重从暴力到优雅对象数组去重和基本类型数组去重完全是两码事因为两个对象内容相同也不代表引用相同const arr [ { id: 1, name: a }, { id: 2, name: b }, { id: 1, name: a } ];直接new Set(arr)是没用的因为每个对象引用都不同。常见做法是取某个唯一键来判断const map new Map(); arr.forEach(item { map.set(item.id, item); }); const result [...map.values()];按id去重重复项保留后者。想保留前者的话加个判断arr.forEach(item { if (!map.has(item.id)) { map.set(item.id, item); } });如果按多个字段组合去重可以把字段拼成一个字符串作为键const key ${item.id}_${item.name};有时候还要注意深浅拷贝的问题如果希望在去重后互不影响需要把对象拷一份再塞进Map不然结果数组里多个元素会指向同一个对象。我在表格数据处理里经常用这种方式尤其是从后端接口拿到重复的关联数据时用id去重是最稳的。4.3 深度拷贝structuredClone与手写deepClone深拷贝是对象操作里的高频话题。以前最省事的是JSON.parse(JSON.stringify(obj))但这个方案有致命缺点undefined、函数、Symbol、循环引用会出问题Date会变成字符串Map和Set会变成空对象。现在浏览器和Node.js都原生支持structuredClone了const cloned structuredClone(original);它支持循环引用、Date、Map、Set、ArrayBuffer、TypedArray这些比JSON方案强很多。但注意它不能拷贝函数、DOM节点、Error对象而且拷贝的不是“纯JS对象”时可能会抛DataCloneError。所以它也不是万能药业务场景里如果你有一个包含函数引用的配置对象还是得另想办法。如果需要更多的控制比如拷贝函数、忽略某些字段就得手写递归function deepClone(value, hash new WeakMap()) { if (value null || typeof value ! object) return value; if (value instanceof Date) return new Date(value); if (value instanceof RegExp) return new RegExp(value); if (hash.has(value)) return hash.get(value); const cloned Array.isArray(value) ? [] : {}; hash.set(value, cloned); for (const key of Reflect.ownKeys(value)) { cloned[key] deepClone(value[key], hash); } return cloned; }这里用WeakMap解决循环引用否则克隆一个有环的对象会无限递归、直接爆栈。这也是我强烈建议手写深拷贝时一定要加WeakMap的原因——你以为对象没有环等到线上爆栈才发现某个数据里藏着一个父子互相引用的结构那时候再去排查就麻烦了。手写深拷贝的日常价值在于你可以定制规则比如遇到函数就跳过、遇到VNode就浅拷贝、遇到某个业务类型就特殊处理。这是structuredClone做不到的灵活度。4.4 对象转QueryWrapper前后端参数映射热词里有“对象转QueryWrapper”这是Java后端MyBatis-Plus里的概念但JS侧对应的是把对象转换成查询参数。比如前端要发GET请求把筛选对象构造成query stringconst filters { keyword: js, page: 1, size: 20, tags: [es6, object] }; const params new URLSearchParams(); Object.entries(filters).forEach(([key, value]) { if (Array.isArray(value)) { value.forEach(v params.append(key, v)); } else { params.append(key, value); } }); const queryString params.toString(); // keywordjspage1size20tagses6tagsobject注意事项undefined和null值应该跳过不然URL里会出现undefined字符串空数组一般不需要传否则会产生一个没有意义的空参数布尔值要注意后端期望的格式是true还是1。后端JPA里也有Specification和QueryWrapper的概念本质就是把对象字段映射成查询条件。关键是约定好哪些字段参与相等匹配、哪些字段走模糊查询、哪些字段是范围查询。前端构造参数时也要对应约定别把模糊查询的字段和精确查询的字段混在一起。这个“约定”往往写在接口文档里但实际操作中经常要靠联调才能发现双方都容易踩坑。4.5 ES6按需提取对象字段“ES6提取数组对象一部分”这个需求也很常见。一种是提取一个对象的部分字段const { name, age } user;想重命名const { name: userName, age: userAge } user;数组对象批量提取const users [ { id: 1, name: 张三, age: 28 }, { id: 2, name: 李四, age: 30 } ]; const names users.map(({ name }) name); const pickList users.map(({ id, name }) ({ id, name }));还可以借助解构忽略某些字段来“剔除”属性const { password, ...safeUser } user;这里的rest解构是个非常实用的技巧删除敏感字段的时候比delete更干净还不会修改原对象。注意delete操作符虽然能删属性但会影响原对象而且在V8引擎下性能也比较差能用rest解构就别用delete。我处理用户信息返回给前端时经常用这招把password、token这些敏感字段剥掉。5. 属性描述符与对象遍历的进阶细节5.1 defineProperty与属性描述符对象属性的背后不只是“键值对”那么简单每个属性还有一套描述符分两种数据属性描述符value、writable、enumerable、configurable和访问器属性描述符get、set、enumerable、configurable。Object.defineProperty可以精确控制属性行为const user {}; Object.defineProperty(user, name, { value: 张三, writable: false, enumerable: true, configurable: false }); user.name 李四; console.log(user.name); // 仍然是张三writable为false时赋值会被忽略严格模式下直接抛TypeError。enumerable为false时Object.keys、for...in都看不到它但可以用Object.getOwnPropertyNames找到。configurable为false时这个属性不能再被删除也不能再修改描述符。Vue 2的响应式原理就是靠defineProperty对每个属性做getter/setter劫持所以它才有“新增属性不响应、必须用Vue.set”这个经典坑。Vue 3换成Proxy之后才从根上解决了属性新增监听不了的问题。理解了属性描述符你对响应式框架的认知也会更深一层——为什么Vue 2要遍历对象、为什么数组的索引监听那么费劲根因都在这里。5.2 遍历对象的所有姿势想遍历对象方式太多了但各有各的适用场景方式返回内容是否包含原型链是否遍历SymbolObject.keys()可枚举自有字符串键否否Object.getOwnPropertyNames()所有自有字符串键含不可枚举否否Object.getOwnPropertySymbols()所有自有Symbol键否是for...in可枚举字符串键是否Reflect.ownKeys()所有自有键含不可枚举Symbol否是实际项目中我优先用Object.keys配合forEach或者用Object.entries配for...of既安全又可控。for...in会连原型链上的可枚举属性一起遍历性能也不好还要用hasOwnProperty过滤尽量少用。如果你在用对象模拟HashMap直接建议换成Map遍历语义清晰得多。5.3 Symbol在对象中的作用Symbol作为对象的键主要用在“不想被别人轻易覆盖或遍历到的元数据”场景。比如你写一个库给对象内部打个标记const TYPE Symbol(type); const obj { [TYPE]: internal, visible: true }; console.log(Object.keys(obj)); // [visible] console.log(obj[TYPE]); // internalSymbol键不会被Object.keys遍历到但Reflect.ownKeys能看到。这使得Symbol很适合做“隐藏字段”。不过要注意Symbol并不是私有的Object.getOwnPropertySymbols和Reflect.ownKeys照样能取出来它更像是一种约定上的隔离防不了真正想要攻击你的代码。真正要私有化现在可以用ES2022的私有字段#name这是在语言层面保证无法从外部访问的。但私有字段只能用于class内部普通对象字面量用不了。所以如果你只是想在普通对象上藏点元数据Symbol是最合适的选择。6. 常见问题与排查实录6.1 判断对象为空怎么总踩坑我见过不少新人用obj {}判断空对象这个永远返回false因为两个对象字面量永远是不相等的引用。还有人用!isEmpty(obj)这是Lodash的写法Lodash的isEmpty对Map、Set、数组也有判断但它的定义和原生空对象不完全等价引入依赖前先查清楚到底想判断什么。还有一种情况后端返回的对象嵌套了多层判断空的时候用Object.keys只查了外层忘了检查里面的字段结果出现“没报错但页面空白”的怪问题。排查的思路是打印完整的对象结构确认“空”到底指的是哪一层。我在实际项目里封装了一个工具函数专门用来判断一个对象是否“业务上是空的”也就是递归检查所有叶子节点是否为空值function isDeepEmpty(obj) { if (obj null || obj undefined) return true; if (typeof obj ! object) return false; return Object.keys(obj).every(key isDeepEmpty(obj[key])); }这类工具函数看着简单但在处理表单数据筛选、接口参数清理时非常实用能省掉很多重复代码。6.2 动态创建表格的单元格合并热词里有“js动态创建的表格合并怎么弄”。动态生成表格后要合并单元格最容易出问题的是数据还没渲染完就去合并或者行列坐标计算错了。我的做法是数据渲染完再统一调一次合并函数。合并逻辑核心是根据字段值判断相邻行是否相同function mergeCells(table, rowIndex, colIndex, rowspan) { const cell table.rows[rowIndex].cells[colIndex]; cell.rowSpan rowspan; for (let i rowIndex 1; i rowIndex rowspan; i) { table.rows[i].cells[colIndex].style.display none; } }更通用的是先按字段做分组计算再决定rowspan。这个过程中常见的问题是合并同一列后其它列的对齐错乱。解决办法是同时计算同一行内所有需要合并的列不能只处理一列。我一般会先构建一个二维的合并矩阵再一次性应用避免边计算边修改DOM导致坐标错位。6.3 对象存储与序列化的小坑热词里还有“对象存储服务”。前端遇到对象存储最常见的是直传文件到云存储然后拿到URL。这里有个隐藏坑上传对象时如果文件名里带中文、空格、特殊符号URL编码不一致会导致访问失败。我用的时候总是统一用encodeURIComponent处理文件名。还有一个坑是元数据的处理对象存储服务通常支持自定义元数据但元数据键名往往会被服务端规范成小写所以不要依赖大小写来区分业务字段。而且有些自定义元数据的值可能带特殊字符序列化的时候要考虑转义。如果你在Node.js里自己实现对象存储接口要格外注意大文件分片的上传顺序以及断点续传时如何保存状态。这些操作本质上都是对对象数据的读写牵扯到缓冲区和流的概念比纯前端场景复杂不少。6.4 字符串、数组与对象的边界“js判断字符串是否包含”这个热搜其实和对象知识也有关系。字符串本身是一个包装对象你在字符串上调用方法比如hello world.includes(hello)这个includes方法就是从String.prototype上来的。这又回到了前面说的原型链字符串临时被包装成对象调用完方法再拆回基本类型。instanceof String的判断要小心abc instanceof String是为false的因为基本类型不是对象。但new String(abc) instanceof String是true。这也是基本类型和包装对象容易混淆的地方实际开发中永远不要用new String这种方式创建字符串直接字面量就好。数组和对象的边界也值得说一句数组不是“用来替代对象的集合”当你需要按key查找时用对象或Map当你需要有序遍历时用数组。很多性能问题都出在“用数组模拟对象”——比如几十万条数据里用find反复查找复杂度直接O(n^2)换成Map瞬间降下来。7. 写在实操之后折腾对象这么多年最大的体会是JS对象的知识不是背出来的而是用出来的。原型链看着抽象但你只要写过一个组件、封装过一个公共方法就会自然明白共享方法为什么要放在原型上深拷贝看着简单真正克隆一个带循环引用的复杂对象时你才会知道递归和WeakMap有多重要。建议你把上面这些代码都自己跑一遍尤其是原型链污染那段代码理解它为什么能侵入所有对象比记住十篇总结文章都管用。还有一个我个人的习惯每个项目里维护一个小工具文件把判断空对象、对象数组去重、深拷贝、按需提取字段这些函数统一封装起来按项目实际需求调整。因为不同项目的“空对象”定义和“去重”键都不一样封装出来才能随时改。这套东西用顺手了你会发现对象真的不神秘也不难——它就像一把钥匙把JavaScript剩下的门一扇扇都打开了。
返回列表