☰
JavaScript对象与原型链完全指南:从原理到面试实战
2026/10/8 20:37:23 网站建设 项目流程

JavaScript 的对象和原型,这两兄弟几乎承包了前端面试前半场的所有考点,也决定了你写的代码是“能用”还是“扛得住”。很多时候我们背熟了__proto__和prototype的指向关系,一到项目里排查问题又懵了——这个对象身上的方法到底从哪儿来的?为啥我改了一个构造函数的原型,所有实例都跟着变了?这背后其实就是 JavaScript 那套独特的“对象 + 原型”体系在起作用。

这篇内容我不打算给你念文档,而是从实际开发里总结的一套认知方法:对象怎么创建、原型链怎么走、面试题会怎么挖坑、生产环境里原型链污染和深度拷贝这些隐患又是怎么踩出来的。适合刚入门想理顺概念的新人,也适合工作了两三年想系统查漏补缺的同学。读完你能看懂原型链的来龙去脉,也能带着这几个核心思路去应对那些最刁钻的原型链面试题。

1. 对象与原型:JavaScript里最核心的“两套系统”

1.1 一切皆对象,但函数是“特殊的对象”

JavaScript 的世界观和 Java、C++ 这种经典面向对象语言不一样。它没有“类”的严格概念,虽然有class关键字,但本质还是基于原型的语法糖。所以你首先要接受一件事:在 JavaScript 里,函数也是对象。

为什么这么说?因为函数具备对象的全部特征——你可以给函数挂属性、传参、作为返回值。比如:

function greet() { console.log('hello'); } greet.customProp = '我是函数身上的属性'; greet.sayHi = function() { console.log('hi from function'); }; console.log(greet.customProp); // 我是函数身上的属性 greet.sayHi(); // hi from function

函数除了对象该有的东西,还有一个特殊属性prototype,只有普通函数才有(箭头函数就没有)。这个prototype正是实现“继承”和“共享方法”的核心机制。

理解函数是对象,是理解整个原型体系的第一个门槛。因为在原型链里,你是靠“函数也是对象”这个前提,才解释得通为什么Function.prototype和Object.prototype之间那条线会连成一个环形的微循环。

1.2 类与对象的本质:类是模板,对象是实例

抛开 ES6 的class语法,JavaScript 在底层做“类与对象”这件事,靠的是构造函数加原型。构造函数本身是个普通函数,你用new调用它时,就产生了一个新对象,这个新对象的原型自动指向构造函数的prototype对象。

function Person(name, age) { this.name = name; this.age = age; } Person.prototype.sayName = function() { console.log(`我是${this.name}`); }; const p1 = new Person('小明', 18); const p2 = new Person('小红', 20); p1.sayName(); // 我是小明 p2.sayName(); // 我是小红 console.log(p1.sayName === p2.sayName); // true,同一个函数,不是每个实例一份

这段代码里藏着两个关键信息:

  • name和age是每个实例自己的属性,存在对象自身上。
  • sayName定义在构造函数的prototype上,所以所有实例共享同一个方法,内存只有一份。

这样设计的好处很明显:如果有一万个实例,每个实例都复制一份方法的话,内存开销非常大。把方法放到原型上,实例通过原型链去查找,既省内存,又方便扩展——你想给所有实例加方法,只需要改原型一处就行。

这不难理解,你可以把它类比成“班级公用课件”:每个学生(实例)有自己的书包(自有属性),但老师发的课件(原型上的方法)全班共用。有人改了课件,全班看到的都变了。

1.3 那条经典的“三条链”:prototype、proto、constructor

很多初学者最头疼的就是这三者绕来绕去。我直接给你一张“关系拼图”:

  • 构造函数的 prototype指向它的原型对象。
  • 实例对象的 __proto__指向构造函数的原型对象,即实例.__proto__ === 构造函数.prototype。
  • 原型对象的 constructor指回构造函数,形成闭环。
function Person(name) { this.name = name; } const p = new Person('张三'); console.log(p.__proto__ === Person.prototype); // true console.log(Person.prototype.constructor === Person); // true console.log(Person.prototype.__proto__ === Object.prototype); // true,原型链向上走 console.log(Object.prototype.__proto__); // null,最终终点

画出来其实是一张从实例出发,逐级向上到Object.prototype,最后以null结束的查找链。访问属性时,JavaScript 会先查自有属性,查不到就沿__proto__链条逐级向上找。这就是所谓的“原型链”。

很多人把prototype和__proto__搞混,我用一句话给你拆开:prototype是函数身上的属性,__proto__是对象身上的属性,两者在“实例”和“构造函数”之间建立起链接。函数因为也是对象,所以函数身上也有__proto__,它指向前一层,最终所有函数的__proto__都指向Function.prototype。

2. 原型与原型链的核心机制深挖

2.1 属性查找的完整过程:自有属性优先,沿链向上

做一个很实际的例子:你有一个对象obj,它自己身上没有toString,但你调用obj.toString()却成功了。这背后就是原型链查找:

const obj = { a: 1 }; // obj自身没有toString console.log(obj.hasOwnProperty('toString')); // false // 但调用成功 console.log(obj.toString()); // "[object Object]" // 实际查找路径:obj -> Object.prototype,找到了Object.prototype.toString console.log(Object.prototype.toString.call(obj)); // "[object Object]"

我们再往上深挖一层:那如果自定义原型呢?

const animal = { kind: '动物' }; const dog = Object.create(animal); dog.name = '旺财'; console.log(dog.name); // 旺财,来自自身 console.log(dog.kind); // 动物,来自原型链上的animal console.log(dog.toString()); // 来自Object.prototype

整个查找顺序是:dog自身 →animal→Object.prototype→null。找不到时返回undefined。这也是为什么有些面试题喜欢问“如何安全判断一个对象上有没有某个属性”——因为in操作符会查原型链,hasOwnProperty只查自身。

2.2 原型链的顶端:Object.prototype 与微循环

继续说原型链的顶端。Object.prototype是所有对象原型链的终点,Object.prototype.__proto__是null,到这里就断了。但有意思的是,这条链上还有一个反向的闭合:

function Foo() {} const f = new Foo(); // Foo这个函数对象本身,它的__proto__指向Function.prototype console.log(Foo.__proto__ === Function.prototype); // true // 而Function.prototype,它又是个对象,它的__proto__指向Object.prototype console.log(Function.prototype.__proto__ === Object.prototype); // true // 于是形成一种"微循环" console.log(Function.__proto__ === Function.prototype); // true console.log(Function.prototype.constructor === Function); // true

这个“微循环”是 JavaScript 原型体系里比较绕的一个点。我平时带新人时,会让他们把“函数”也当成“一种对象”来理解,这样就不乱了:Function.prototype本身是个对象,所以它也得有__proto__,而它作为所有函数原型的代表,最终沿链指到Object.prototype,这很合理。

面试时如果考到Object instanceof Function或者Function instanceof Object这类判断题,根源都在这个微循环上。理解了上面的关系,你就能推出:Object是个函数,所以Object.__proto__ === Function.prototype,那Object instanceof Function就是true。

2.3 原型链污染:一个必须警惕的安全隐患

原型链污染不是一道普通的面试题,它在生产环境里可能导致严重的安全问题。原理特别简单:如果你能修改某个对象的原型,那么所有继承自这个原型的对象都会受到影响。

常见的污染方式是往Object.prototype里塞东西:

Object.prototype.isAdmin = true; const user = { name: 'tom' }; console.log(user.isAdmin); // true,几乎每个对象都“继承”了这个属性

一个delete user.isAdmin删不掉它,因为那是继承来的属性,不是自有属性;必须delete Object.prototype.isAdmin才能清理干净。

真实场景里,合并配置对象时如果不做防护,就容易被污染:

function mergeConfig(target, source) { for (const key in source) { if (source.hasOwnProperty(key)) { target[key] = source[key]; } } return target; } // 攻击者传入的payload const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}'); const config = mergeConfig({}, malicious); // 如果merge实现不够严谨,Object.prototype被篡改 console.log({}.isAdmin); // true

避免这种问题的常规做法:

  • 使用安全的合并函数,比如Object.assign时也要注意 key 可能为__proto__。
  • JSON.parse后先过滤__proto__、constructor、prototype这几个关键键。
  • 或者在合并前对源对象做一次Object.create(null)处理,创建无原型对象。

这里重点提示一下:做对象合并、深拷贝这类操作时,务必校验 key。我用 Node.js 写后端接口时,凡是入参要进对象合并逻辑的,都会先跑一层过滤,确保__proto__、constructor这些键一律拒绝。这是底线操作,不能省。

3. 对象的创建与应用实操:不只 new 一种路子

3.1 五种常见的对象创建方式对比

大家常说的“对象的创建”,在 JavaScript 里至少有五种常规写法。我列一下,顺便给出各自的使用场景。

创建方式代码示例特点使用场景
字面量const obj = { name: 'x' }简单直接,日常最常用普通数据对象
构造函数new Person('x')可以批量创建同构对象,靠原型共享方法类实体对象
Object.createObject.create(proto)明确指定原型,实现“继承”更原生需要精准控制原型链
class 语法new Person('x')ES6 语法糖,面向对象风格更清晰团队协作、工程化项目
工厂函数createPerson('x')返回新对象,内部可封装逻辑复杂初始化场景

每种方式背后的区别,核心就在“原型怎么连”。我拿Object.create举个例子,这是很多人平时用得不多的方法,但在原型操作里非常灵活:

const prototypeObj = { greet: function() { console.log(`你好,${this.name}`); } }; const obj = Object.create(prototypeObj); obj.name = '李四'; obj.greet(); // 你好,李四 // obj自身的原型是prototypeObj,而不是Object.prototype console.log(obj.__proto__ === prototypeObj); // true console.log(obj.__proto__.__proto__ === Object.prototype); // true

如果你想创建一个真正“干净”的对象,不继承任何原型,就用Object.create(null)。这在写字典类工具、存在不可信 key 的场景里特别好用,因为不怕原型链污染,也不怕hasOwnProperty被覆盖。

3.2 判断数据类型与判断对象为空的实用技巧

判断数据类型几乎每个前端项目每天都在用。这里我总结一套日常能直接用的经验:

typeof只能区别原始类型和函数:

console.log(typeof 'hello'); // string console.log(typeof 42); // number console.log(typeof true); // boolean console.log(typeof undefined); // undefined console.log(typeof Symbol('x')); // symbol console.log(typeof null); // object,这是历史遗留坑 console.log(typeof {}); // object console.log(typeof []); // object console.log(typeof function(){}); // function

想要准确区分数组、对象、日期、正则等,用Object.prototype.toString:

function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } console.log(getType([])); // Array console.log(getType({})); // Object console.log(getType(new Date())); // Date console.log(getType(new Map())); // Map console.log(getType(null)); // Null

这个方法很稳,比instanceof更不容易误判,尤其在跨 iframe 场景下instanceof会失效,而Object.prototype.toString基本不受影响。

判断对象为空也是高频场景。注意,一个“空对象”并不等于“没有任何属性”:

const obj1 = {}; const obj2 = { a: 1 }; const obj3 = Object.create(null); // 方法一:Object.keys function isEmpty(obj) { return Object.keys(obj).length === 0; } console.log(isEmpty(obj1)); // true console.log(isEmpty(obj2)); // false // 但Object.keys对Object.create(null)也有效 console.log(isEmpty(obj3)); // true // 方法二:for in 循环,但会遍历原型链,需要配合hasOwnProperty function isEmptySafe(obj) { for (const key in obj) { if (obj.hasOwnProperty(key)) return false; } return true; }

实际开发中我更推荐Object.keys,因为只读自有属性,不涉及原型链。但如果对象是用Object.create(null)创建的,也没有问题,因为Object.keys是静态方法,不依赖对象原型上有没有这个方法。

3.3 对象数组去重和深拷贝:前端绕不过去的高频操作

对象数组去重

非对象数组去重用Set就够了,对象数组去重要考虑“按哪个字段判断重复”。最常见的场景是接口返回列表,你需要按id去重:

const list = [ { id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 1, name: 'a2' }, ]; const seen = new Set(); const uniqueList = list.filter(item => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); console.log(uniqueList); // [{ id: 1, name: 'a' }, { id: 2, name: 'b' }]

如果需要按“多个字段”判断,可以先把字段拼成字符串作为 key:

const uniqueByFields = list.filter((() => { const seen = new Set(); return (item) => { const key = `${item.id}_${item.name}`; if (seen.has(key)) return false; seen.add(key); return true; }; })());
对象深拷贝的几种方案

深拷贝是个老话题,但踩坑率极高。浅拷贝用展开运算符{ ...obj }只能拷贝第一层,嵌套对象还是引用。如果嵌套两层,修改内层对象的属性会影响原对象。

常见的深拷贝方案:

// 方案1:JSON 序列化(最快但有限制) const copy1 = JSON.parse(JSON.stringify(original)); // 缺点:函数、undefined、Symbol、Date、RegExp、Map、Set 都会丢失或变形 // 方案2:structuredClone(现代浏览器和Node 17+ 原生支持) const copy2 = structuredClone(original); // 支持 Date、Map、Set、ArrayBuffer 等,不能拷贝函数和 Symbol // 方案3:lodash 的 cloneDeep // _.cloneDeep(original) // 最全面,兼容函数、日期、正则等,性能也不错

按项目实际来选:数据是纯 JSON 结构,用JSON.parse(JSON.stringify())最省事;数据包含Date、Map这类复杂类型,能用structuredClone就是最优解;如果要兼容老浏览器且数据结构复杂,直接上lodash.cloneDeep,别手写递归——手写递归要考虑循环引用(比如cloneObj.self = cloneObj),一个WeakMap没处理,栈直接爆掉。

我写过一版递归深拷贝,算是踩过循环引用的坑之后留下的教训:

function deepClone(obj, hash = new WeakMap()) { if (obj === null || typeof obj !== 'object') return obj; if (hash.has(obj)) return hash.get(obj); const isArray = Array.isArray(obj); const result = isArray ? [] : {}; hash.set(obj, result); for (const key in obj) { if (obj.hasOwnProperty(key)) { result[key] = deepClone(obj[key], hash); } } return result; }

第二参数hash是关键,遇到循环引用时能直接命中缓存返回,避免无限递归。虽然业务里很少手写,但这个思路面试经常问到,值得记牢。

4. 实战中对象原型相关的三个高阶场景

4.1 函数是对象又如何:JS函数式编程操作

前面说了函数是对象,那我们再往前迈一步:既然函数是对象,函数就可以被存到数组里、作为参数传递、作为属性挂到对象上。这给了我们很多函数式编程的操作空间。

比如实现一个简单的“事件监听器”:

function createEmitter() { const listeners = {}; return { on(event, handler) { if (!listeners[event]) listeners[event] = []; listeners[event].push(handler); }, emit(event, data) { (listeners[event] || []).forEach(handler => handler(data)); }, off(event, handler) { const idx = (listeners[event] || []).indexOf(handler); if (idx > -1) listeners[event].splice(idx, 1); } }; } const emitter = createEmitter(); const logHandler = (data) => console.log('收到数据:', data); emitter.on('message', logHandler); emitter.emit('message', { title: '测试' }); emitter.off('message', logHandler);

这就是函数作为对象在实际开发中的用法——事件处理器的本质,就是把函数当数据存起来,再按需取出执行。

4.2 数组原型方法扩展思路(但不建议改内置原型)

有人会在项目里把一些常用方法挂到Array.prototype上,比如数组去重、数组分组。我劝你别这么做,除非是内部工具库且你能保证团队所有人都知道。原因很简单:改内置原型是“全局副作用”,一旦未来 ES 规范里加了同名方法,或者第三方库有同名的 polyfill,行为互相污染,排查起来非常痛苦。

更稳的做法是封装成工具函数:

function groupBy(arr, keyFn) { return arr.reduce((result, item) => { const key = keyFn(item); (result[key] = result[key] || []).push(item); return result; }, {}); } // 或者挂在一个自定义工具对象上 const arrayUtils = { groupBy, uniqueBy(arr, keyFn) { const seen = new Set(); return arr.filter(item => { const key = keyFn(item); if (seen.has(key)) return false; seen.add(key); return true; }); } };

这样既不影响全局环境,功能上也完全够用。如果实在要给原型挂方法,至少满足这几个条件:方法名带非常明确的前缀(比如$或__),方法体里对this做严格校验,并且在文档里显著标注。

4.3 面向对象改造:当class遇上原型

ES6 的class本质上还是构造函数加原型。你用class写出来的方法,全部落在构造函数的prototype上,static方法则直接落在构造函数自身上。

class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} 发出声音`); } static create(name) { return new Animal(name); } } const dog = new Animal('旺财'); dog.speak(); // 旺财 发出声音 console.log(typeof Animal); // function,class本质是函数 console.log(dog.__proto__ === Animal.prototype); // true console.log(Animal.create('凯蒂').name); // 凯蒂,静态方法在用构造函数本身

手写一个模拟 class 继承的 ES5 用法,能让你对原型链的理解彻底通透:

function Parent(name) { this.name = name; } Parent.prototype.sayName = function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); // 继承属性 this.age = age; } // 关键步骤:让Child.prototype指向一个以Parent.prototype为原型的对象 Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child; // 修正constructor指向 Child.prototype.sayAge = function() { console.log(this.age); }; const kid = new Child('小美', 8); kid.sayName(); // 小美,来自Parent.prototype kid.sayAge(); // 8,来自Child.prototype

Child.prototype = Object.create(Parent.prototype)这行的作用就是切断对Parent.prototype的直接引用,而是以它为原型创建新对象。为啥不能直接Child.prototype = Parent.prototype?因为那样两个原型是同一个对象,往Child.prototype上挂方法会污染Parent.prototype,继承关系就乱套了。

5. 常见问题与排查技巧实录

5.1 面试必问:原型链题目解题四步法

原型链面试题,不管怎么变,基本都能用四步法拆解:

第一步,明确对象自身有没有这个属性,有就直接返回。 第二步,沿__proto__找上一层原型,找到就返回。 第三步,一直找到Object.prototype为止,都找不到就返回undefined。 第四步,遇到函数时,记住“函数也是对象”,函数自身也有__proto__,所以函数的__proto__是Function.prototype,而不是Object.prototype。

举个例子:

function Foo() {} const foo = new Foo(); // 问题1:foo.__proto__ 是什么? // 答:Foo.prototype // 问题2:Foo.__proto__ 是什么? // 答:Function.prototype // 问题3:Foo.prototype.__proto__ 是什么? // 答:Object.prototype // 问题4:Object.__proto__ 是什么? // 答:Function.prototype // 问题5:Object instanceof Function // 答:true,因为Object是函数,函数.__proto__指向Function.prototype

这类题只要你不晕“函数也是对象”这个前提,基本不会错。遇到typeof null === 'object'、null instanceof Object === false这种历史遗留题,记住结论即可,不必深究成因。

5.2 运行时报错定位:对象的属性为什么不合法

实际项目里关于对象和原型的运行时报错,我遇到最多的是TypeError: Cannot read properties of undefined (reading 'xxx')。根因几乎都是“链条上某个环节断掉了”——你要访问的对象的某个层级是undefined,后边的属性就无从谈起。

排查思路:

// 报错代码 const userName = user.profile.name; // 排查步骤 // 1. 先确认user是否存在 console.log(user); // 2. 再确认profile是否存在 console.log(user && user.profile); // 3. 取不到就做兜底 const userName = user?.profile?.name || '未知';

可选链?.和空值合并||是解决这类问题最常用的工具。但要注意||会把0、''也替换掉,如果业务里这些值是合法数据,就要改用??:

const count = data?.count ?? 0; // data.count为0时保留0 const count2 = data?.count || 0; // data.count为0时也会变成0

对象深层取值时报错,90% 是后端返回的数据结构比预期少了一层,或者某个字段为null。给接口数据做一层防御式解析,能省下大量调试时间。

5.3 判断对象为空与深拷贝的连环坑

我把“判断对象为空”和“深拷贝”放在一起聊,因为这两个操作经常会同时出现在一个功能里,比如写表单回显前要判断数据是否为空,数据复杂时要克隆一份再编辑。

判断空对象的第一个坑是JSON.stringify({}) === '{}'会返回true,但它同时会把{ a: undefined }也序列化成{},所以不能用JSON.stringify判断空对象。

判断空对象的第二个坑是Object.keys只能数出可枚举的自有属性,如果用户用了Object.defineProperty声明一个不可枚举属性,Object.keys数不到它,但对象确实有属性:

const obj = {}; Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false }); console.log(Object.keys(obj).length); // 0,但obj并非空 console.log(Object.getOwnPropertyNames(obj)); // ['hidden']

严格意义上,空对象应该指“没有任何自有属性”,用Object.getOwnPropertyNames或Reflect.ownKeys才更准确。日常开发按业务取舍就行,但要清楚边界在哪里。

深拷贝的坑我前面提过:JSON.parse(JSON.stringify())会把undefined、函数、Symbol直接丢掉;Date会被转成 ISO 字符串;RegExp会变成{};NaN和Infinity会变成null。如果你的数据可能在任意一层包含这些类型,就别偷懒用 JSON 方案。

5.4 调试原型链问题的两个实用命令

排查原型链问题时,控制台里以下两个方法很值钱:

// 1. 查看对象自身属性 console.log(Object.getOwnPropertyNames(obj)); // 2. 查看对象的完整原型链 function getProtoChain(obj) { const chain = []; let current = obj; while (current !== null) { chain.push(current); current = Object.getPrototypeOf(current); } return chain; }

Object.getPrototypeOf是标准方法,比直接读__proto__更正规。你把一个对象丢进去,马上就能看到它整个原型链上到底挂了几层、每层有什么方法,定位“方法找不到了”或者“方法被覆盖了”非常高效。

还有个小技巧:想知道一个方法挂在链上哪一层,可以逐层用hasOwnProperty试探:

function findMethodOwner(obj, methodName) { let current = obj; let depth = 0; while (current !== null) { if (Object.prototype.hasOwnProperty.call(current, methodName)) { return { depth, owner: current }; } current = Object.getPrototypeOf(current); depth++; } return null; }

面试里被问到“这个对象的 methods 是怎么来的”,用这两招就能当场拆解,比空口背概念有说服力得多。

6. 写在最后的个人心得

我在实际写业务代码的过程中,最大的体会是:对象和原型这类基础概念,不能靠死记硬背,一定要结合踩过的坑去反向理解。比如我最早写深拷贝时,就是因为没考虑循环引用,一执行就栈溢出;后来才知道要用WeakMap做缓存。最早用JSON.parse(JSON.stringify())去克隆带Date的配置对象,改完发现时间全变成字符串了,才意识到这方案不是万能的。

原型链这条线把 JavaScript 的对象体系串得很完整:你想让实例共享方法,把方法放原型上;你想精准地控制继承关系,用Object.create;你想防止全局污染,别乱改内置原型,该用安全合并函数就用安全合并函数。整套逻辑熟悉之后,再看框架源码里各种“继承、重写、扩展原型的骚操作”,基本一眼就能看穿它原理是什么。

最后再分享一个实用习惯:每次写完对象相关代码,我都会顺手自查三件事——这个方法是不是有更简单的原生方案?这个对象会不会被外部意外修改原型?这段代码在原型链上会不会因为继承产生额外的性能开销?养成这个习惯之后,你能在写代码的阶段就避开很多运行时的坑,比事后排查要高效得多。

JavaScript 的对象与原型,说到底就是一套“委托机制”——对象之间不是严格的父子关系,而是“我找不到的东西,就去问我的原型要”。把这个思维转换过来,那些曾经让你头疼的__proto__、prototype、constructor就不再是背诵材料,而是工具箱里随时取用的工具了。

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

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

立即咨询