☰
JavaScript对象与原型链进阶:从引用类型到深拷贝的完整指南
2026/10/6 4:42:17 网站建设 项目流程

先说个我自己的感受。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); // true

ES6的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.prototype:Object.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.stringify:

function 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 string:

const 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(); // keyword=js&page=1&size=20&tags=es6&tags=object

注意事项:

  • 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 遍历对象的所有姿势

想遍历对象,方式太多了,但各有各的适用场景:

方式返回内容是否包含原型链是否遍历Symbol
Object.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]); // 'internal'

Symbol键不会被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剩下的门一扇扇都打开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询