ARTICLE DETAIL

资讯详情

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

JS对象核心机制全解:从原型链到深拷贝的进阶指南

JS对象核心机制全解:从原型链到深拷贝的进阶指南 做前端时间长了你会发现真正让你像过山车一样的往往不是React、Vue这些框架而是最基础的JS对象。框架写多了对对象的理解反而容易停留在能用就行——直到某天被一个问题卡住翻文档才发现自己连属性描述符和原型链都没完全吃透。这篇就想把JS对象这个主题一次性讲透涵盖对象的本质、创建方式、属性控制、原型链、常见操作以及真实业务里的应用场景适合刚学完基础语法想进阶的初学者也适合写了两三年代码但总觉得对象这块有模糊感的开发者。1. JS里的对象到底是什么先纠正几个根深蒂固的误解1.1 函数也是对象这句话该怎么理解很多教程喜欢说JS中一切皆对象这句话方便入门但严格讲并不准确。原始值string、number、boolean、null、undefined、symbol、bigint并不是对象只是在使用属性或方法时JS会临时把它们包装成对应的对象类型。真正需要你刻进脑子的是函数实际上是对象的一种准确说是可调用对象。你可以在函数上挂属性而不报错这足以说明它的对象身份function sayHi() { console.log(hi); } sayHi.author zhangsan; sayHi.desc { level: A }; console.log(typeof sayHi); // function console.log(typeof sayHi.author); // string但它的typeof依旧是function这是JS为了区分普通对象与可调用对象的特殊设计。知道这点有什么用当你看到构造函数、类、箭头函数这些概念时就不会纠结函数到底是对象还是函数——它既可以有属性和方法也可以被new调用这两件事互不排斥。1.2 对象、数组、函数在内存里的关系对象本质上是一组键值对的集合但它的存储方式值得注意。基本类型存的是值对象存的是引用。这个差别引发的现象是两个对象哪怕内容一模一样用或比较也是不相等的因为它们指向的内存地址不同。const a { name: xm }; const b { name: xm }; console.log(a b); // false const c a; console.log(a c); // true因为它们引用同一个地址数组和函数同样是引用类型。理解引用之后很多改了一个变量的值另一个变量也跟着变的诡异问题都能一眼看穿。1.3 为什么null和object的历史包袱到现在还有人踩typeof null object是JS从第一版就带出来的bug由于历史兼容性问题一直没修。判断一个值是否为普通对象时不能只依赖typeof通常需要组合判断function isPlainObject(value) { return Object.prototype.toString.call(value) [object Object]; }这里用Object.prototype.toString.call而不是value instanceof Object是因为它能把null、数组、函数都排除掉准确识别纯对象。实际项目里判断对象为空的需求经常和这一块搅在一起后面我会专门讲。2. 创建对象的姿势五花八门背后其实是三种不同的设计思路2.1 字面量、构造函数、Object.create选谁不是看心情最常用的对象字面量简洁直观适合一次性创建固定结构的对象。构造函数和类适合需要复用、带初始化逻辑的场景。而Object.create的作用是创建一个以指定对象为原型的对象它最大的价值在于手动控制原型链。const parent { greet() { return hello; } }; const child Object.create(parent); child.name lisi; console.log(child.greet()); // hello console.log(child.__proto__ parent); // trueObject.create(null)会创建一个没有任何原型的方法对象既没有toString也没有hasOwnProperty适合做真正的字典不易受到原型污染。2.2 new关键字背后到底发生了什么手写一个new的过程能帮你彻底搞懂构造函数的逻辑function myNew(Constructor, ...args) { const obj Object.create(Constructor.prototype); const result Constructor.apply(obj, args); return (result ! null typeof result object) ? result : obj; }关键两步一是用Object.create(Constructor.prototype)把新对象的原型指向构造函数的prototype二是把构造函数的this绑定到新对象上执行。如果构造函数内显式返回了一个对象那么new的结果就是这个返回对象如果没返回对象则返回刚创建的新对象。2.3 属性描述符给对象属性上锁对象属性并不只是简单的键值对每个属性还带着一套描述符包括value、writable、enumerable、configurable。直接赋值时这些描述符的默认值通常是true用Object.defineProperty定义时默认值全是false。这个差别经常让人踩坑。const obj {}; Object.defineProperty(obj, secret, { value: 42, writable: false, enumerable: false, configurable: false }); obj.secret 100; console.log(obj.secret); // 还是42非严格模式下静默失败严格模式下直接抛错 console.log(Object.keys(obj)); // 空数组因为不可枚举想遍历对象时Object.keys只能拿到可枚举属性for...in还会把原型链上的可枚举属性也带出来。搞清这几个描述符你才能安全地设计第三方库、做数据隔离避免属性被意外改写或遍历泄露。3. 原型链不是玄学prototype、__proto__和继承的真相3.1 prototype和__proto__各管什么这两个词让无数人头疼但只需要记住一个核心区别prototype是函数才有的属性它指向当这个函数作为构造函数时创建出来的实例对象的原型__proto__是每个对象都有的属性指向这个对象自身的原型。function Person() {} const p new Person(); console.log(p.__proto__ Person.prototype); // true原型链的本质可以理解成对象在查找属性时先找自己身上的属性找不到就往__proto__指向的原型上找再找不到就继续沿原型链往上直到Object.prototype还没有就是undefined。这也是JS实现继承的基础。3.2 用class写继承时底层发生了什么ES6的class语法给了我们extends和super但底层仍然是基于原型链的。子类的prototype会通过Object.setPrototypeOf建立与父类prototype的关联同时子类还要继承父类自身属性。class Animal { constructor(name) { this.name name; } speak() { console.log(this.name makes a sound.); } } class Dog extends Animal { speak() { super.speak(); console.log(this.name barks.); } }注意super.speak()中的super并不是一个对象它只是语法层面提供的一个指向原型方法的引用。如果尝试console.log(super)会报错这提醒我们class中的super本质上是一种基于原型的语法糖并不是把父类对象传进来。3.3 原型链上常见的坑与排查方法一个经典坑是遍历对象时用for...in会把原型上可枚举的扩展属性一起遍历出来。另一个坑是instanceof的判定依赖原型链如果你手动改了对象的__proto__instanceof结果也会跟着变。排查原型链问题时我习惯在浏览器DevTools里直接打印对象展开__proto__逐层看或者用Object.getPrototypeOf(obj)获取对象原型避免直接触碰__proto__这个历史遗留属性。Object.hasOwn(obj, prop)是较新的方法判断是不是对象自身的属性比obj.hasOwnProperty更安全因为obj.hasOwnProperty有可能被对象自身的同名属性覆盖。4. 天天在写的对象操作判空、去重、拷贝、提取4.1 判断对象是否为空不只是Object.keys().length 0很多初学者用JSON.stringify(obj) {}判断空对象但这个写法遇到不可枚举属性时会误判而且性能不好。更稳妥的方法是function isEmptyObject(obj) { if (obj null || typeof obj ! object) return false; return Object.keys(obj).length 0 Object.getOwnPropertySymbols(obj).length 0; }之所以还要检查getOwnPropertySymbols是因为对象可以有symbol类型的属性这些属性不会被Object.keys统计到。如果你的对象还需要考虑非枚举属性就需要用Object.getOwnPropertyNames它能把不可枚举的普通属性也拉出来。实际业务里接口返回的数据对象即使看起来为空也经常带一些隐藏属性这时候判空逻辑要格外严谨。4.2 对象数组去重从JSON序列化到Map的取舍对[{id: 1, name: a}, {id: 1, name: a}]去重最容易想到的是JSON.stringify后放到Set里。这种方法简单但有两个问题属性顺序不同会被误判为不同对象对象里有函数、undefined、Date等类型时序列化结果会失真。更可控的做法是基于某个唯一键去重const arr [{ id: 1, name: a }, { id: 2, name: b }, { id: 1, name: a }]; const map new Map(); for (const item of arr) { if (!map.has(item.id)) { map.set(item.id, item); } } const uniqueArr [...map.values()];如果真的要全字段去重可以写递归比较或先对键排序再序列化。在数据量不大时排序序列化最直观数据量大时用Map加唯一键的方案性能更高。4.3 浅拷贝、深拷贝structuredClone也许比你想象的更靠谱浅拷贝方法很多Object.assign、展开运算符{...obj}都只能拷贝一层。深拷贝常用JSON.parse(JSON.stringify(obj))但它会丢掉undefined、函数、Symbol、循环引用Date也会变成字符串。真正的完全深拷贝很难用几行代码实现。好消息是现代浏览器和Node.js 17已经支持structuredClone它能正确处理大部分内置类型和循环引用const original { list: [1, 2], date: new Date(), nested: { x: 1 } }; original.self original; const cloned structuredClone(original); console.log(cloned.self cloned); // true不过structuredClone不复制函数、不保留原型链上的自定义方法所以如果对象里有函数或class实例还是要手写递归拷贝或借助成熟的库。选型时不必盲目能不用库就不用但要清楚每种方式的边界。4.4 ES6提取数组对象的一部分解构与数组方法配合热搜里es6提取数组对象一部分是一个很常见的需求。比如从接口返回的大对象数组中只取id和name字段可以用解构加mapconst users [{ id: 1, name: a, age: 20 }, { id: 2, name: b, age: 22 }]; const picked users.map(({ id, name }) ({ id, name }));还可以用Object.fromEntries快速过滤字段const picked users.map(user { const { age, ...rest } user; return rest; });第二种方式的好处是当你不确定要保留多少字段时用排除法比列举法更省事。需要注意的是解构出的...rest永远是一个新的普通对象原对象不会被修改。5. 对象在真实业务场景里的现身说法5.1 事件对象eventtarget、currentTarget别再搞混了前端事件处理中event本身就是一个对象。最常见的坑是事件冒泡时以为event.target是当前绑定事件的元素其实currentTarget才是。document.querySelector(#parent).addEventListener(click, function (event) { console.log(event.target); // 实际点击的元素 console.log(event.currentTarget); // 绑定事件的#parent });在事件委托里我们需要用event.target去判断真正触发事件的子元素再结合closest等API处理动态列表的点击。把这个弄熟很多为什么我绑定了父元素但拿不到子元素的数据的问题都会迎刃而解。5.2 对象转查询参数别把嵌套对象搞成[object Object]热搜里出现了对象转querywrapper虽然那是Java后端的叫法但前端常常要做类似的事情把筛选条件对象转换成URL查询串。比如const params { page: 1, size: 10, keyword: js 对象, tags: [a, b] }; const queryString Object.entries(params) .map(([key, value]) { if (Array.isArray(value)) { return value.map(v ${encodeURIComponent(key)}${encodeURIComponent(v)}).join(); } return ${encodeURIComponent(key)}${encodeURIComponent(value)}; }) .join();这里最容易出问题的是嵌套对象或数组没有单独处理最后拼出[object Object]。如果后端接口接受JSON字符串参数也可以直接用JSON.stringify放进query但要注意URL长度限制。做这种转换时最推荐用URLSearchParams它能自动处理大部分编码问题。5.3 浏览器端的对象变形Base64转File以及PDF缩略图思路js base 转file是一个高频需求。从后端拿到Base64字符串想要上传或展示时需要转成File或Blobfunction base64ToFile(base64, filename, mimeType application/octet-stream) { const byteCharacters atob(base64.split(,)[1] || base64); const byteArrays []; for (let offset 0; offset byteCharacters.length; offset 512) { const slice byteCharacters.slice(offset, offset 512); const byteNumbers new Array(slice.length); for (let i 0; i slice.length; i) { byteNumbers[i] slice.charCodeAt(i); } byteArrays.push(new Uint8Array(byteNumbers)); } return new File(byteArrays, filename, { type: mimeType }); }关键点是atob得到的是二进制字符串需要逐字节转成Uint8Array再放进File。如果直接new File([base64], ...)生成的文件是坏的因为File不会自动帮你解码Base64。至于js中pdf缩略图通常思路是先把PDF渲染成Canvas再toDataURL生成缩略图。这个过程绕不开Blob和对象URLconst blob base64ToFile(pdfBase64, file.pdf); const url URL.createObjectURL(blob);在用完createObjectURL后记得URL.revokeObjectURL释放内存否则页面会越来越卡。这也是对象生命周期管理在浏览器端的典型体现——不只是{}才叫对象File、Blob、URL这些都脱离不了对象这个概念。回头再看最开始那个问题——函数是不是对象对象在内存里怎么流动原型链是怎么串起来的。你会发现这些问题其实都是一条线JS里的对象是属性集合 原型引用的组合。平时写代码不需要时刻想着这些底层概念但一旦遇到改了这里那里变了深拷贝还是浅拷贝事件对象拿错了元素这类问题能快速从根上定位才是真正把对象吃透了。我自己最深的体会是多花半小时拆解一个对象的内部结构比刷一百道面试题都管用。
返回列表