☰
JavaScript构造函数、原型与原型链:从原理到实践
2026/10/5 7:56:22 网站建设 项目流程

构造函数、原型、原型链这三个词,几乎每个学 JavaScript 的人都会在某个阶段被它们绕晕。我记得自己当年第一次看到prototype和__proto__的时候,脑子里只有一个想法:这俩到底是啥关系?为啥构造函数能“继承”方法?凭啥new出来的对象可以调用别处的函数?这些困惑不是因为你不聪明,而是因为 JavaScript 的这套机制和绝大多数面向对象语言都不一样——它压根没有真正的“类”,而是走了一条基于原型(prototype-based)的路子。这篇文章我就把这条路上最关键的几个站点全部讲透,从构造函数怎么用,到原型链怎么串,再到平时写代码时容易踩的坑,全都过一遍。

我不敢说看完这篇你就能精通原型链,但至少再遇到跟原型、继承、instanceof、new相关的问题,你心里会有一个清晰的逻辑图,而不是靠猜。无论你是刚接触 JS 的新手,还是已经在写业务代码但一直对底层机制含糊其辞的开发者,这篇文章都值得你花二十分钟认真过一趟。

1. 构造函数:JavaScript 里的“造物主”

1.1 构造函数到底是什么

很多人一开始接触构造函数,都是从“构造函数的首字母要大写”这种约定俗成的规范开始的。但真正要理解构造函数,得先明白一件事:在 JavaScript 里,构造函数本身就是一个普通函数,它外观上和普通函数没有本质区别,唯一的区别在于“调用方式”。

当你用new关键字去调用一个函数时,这个函数就变成了构造函数,它会经历一个完整的“制造流程”。我先直接给一个最简单的例子,咱们从代码里感受:

function Person(name, age) { this.name = name; this.age = age; } const p1 = new Person('小明', 18); console.log(p1.name); // 小明 console.log(p1.age); // 18

这个Person函数,单独拎出来就是普通函数,但你用new调用它之后,它内部就多了一套动作。这套动作具体是什么,我下面细说。

1.2 new 关键字到底做了什么

new是一个特别有意思的操作符,它做了四件事,而且顺序是固定的:

  1. 在内存里创建一个全新的对象;
  2. 将这个对象的[[Prototype]](也就是内部原型指针)绑定到构造函数的prototype属性上;
  3. 把构造函数内的this指向这个新对象,并执行构造函数体内的代码;
  4. 如果构造函数显式返回了一个对象,则返回那个对象;否则返回新创建的这对象。

这四步里,第二和第四步是特别容易被忽略的。第二步就是“原型链”的起点,没有这一步,p1就无法访问Person.prototype上的方法和属性。第四步则解释了为什么构造函数里通常不写return——如果你返回了一个基本类型的值,new会直接忽略它;如果你返回了一个对象,那新创建的this会被丢弃,取而代之的是你返回的对象。

我用一个生活化的类比来帮你记:new就像一个“加工流水线”。构造函数的prototype是这条流水线的“标准附件包”,每个从线上走下来的产品(新对象)都会自动装配上这个附件包。这样你不需要在每个产品身上重复刻上相同的方法,只要查附件包就行。

1.3 手写一个 new,彻底搞懂内部逻辑

光看理论容易忘,最好的办法是自己实现一遍。我建议你可以在浏览器控制台或 Node 环境里跑一下这个函数,它几乎就是new的迷你版:

function myNew(constructor, ...args) { // 1. 创建一个新对象,并让它继承构造函数的 prototype const obj = Object.create(constructor.prototype); // 2. 将构造函数内部的 this 绑定到新对象并执行 const result = constructor.apply(obj, args); // 3. 根据构造函数返回值决定最终结果 return (result !== null && typeof result === 'object') || typeof result === 'function' ? result : obj; } function Person(name, age) { this.name = name; this.age = age; } const p = myNew(Person, '小红', 20); console.log(p.name); // 小红 console.log(p instanceof Person); // true

这个手写实现最核心的一行是Object.create(constructor.prototype)。它可以精确地帮你理解“创建对象并将其原型指向构造函数的prototype”这个步骤。如果你在面试或者项目里被问到new的原理,直接把这个手写版本丢出来,比空口描述有说服力得多。

1.4 一个必须记住的坑:箭头函数不能做构造函数

在 ES6 普及之后,很多人习惯用箭头函数写一切。但在构造函数的场景里,箭头函数是“传统禁区”。

箭头函数的特点是没有自己的this,它的this是词法作用域决定的,也就是它在定义时所处的上下文。而构造函数的核心依赖于this被动态绑定到新对象上,这两者是冲突的。所以,JS 引擎直接禁止你用new调用箭头函数,会抛出类型错误。

反过来说,普通函数(或者说非箭头函数)都有prototype属性吗?答案是:是的,普通函数在定义时都会自动获得一个prototype对象。这个问题很多人面试时会答错,记住这一点,后面讲原型对象就顺畅了。

2. 原型与原型对象:函数自带的一张“图纸”

2.1 prototype 到底是什么

我先说一个反直觉的事实:在 JavaScript 里,只有函数才有prototype属性(更严谨地说是普通函数和类都有,箭头函数没有)。这个prototype属性指向的是一个普通对象,这个对象将来会被所有通过该构造函数创建出来的实例所共享。

你完全可以把它理解成一张“图纸”或“模板”。比如:

function Person(name) { this.name = name; } Person.prototype.sayHello = function () { console.log(`你好,我是 ${this.name}`); }; const p1 = new Person('小明'); const p2 = new Person('小红'); p1.sayHello(); // 你好,我是 小明 p2.sayHello(); // 你好,我是 小红 console.log(p1.sayHello === p2.sayHello); // true

注意最后一行输出的是true,这说明什么?说明p1和p2并没有各自拥有一份sayHello的副本,它们用的是同一份函数。这就是prototype存在的意义:节省内存,共享方法。

如果你不用原型,而是直接在构造函数里写this.sayHello = function(){},那每new一个实例,就会重新生成一个函数对象,非常浪费。

2.2 构造函数、实例、原型对象三者的关系

这三者的关系特别经典,也是最容易搞混的地方。我直接说结论,然后用代码验证:

  • 构造函数通过prototype属性访问原型对象;
  • 实例通过__proto__(非标准,但浏览器普遍实现)访问原型对象;
  • 原型对象通过constructor属性指回构造函数。

用代码表达就是这样:

function Person(name) { this.name = name; } const p = new Person('小明'); console.log(Person.prototype === p.__proto__); // true console.log(Person.prototype.constructor === Person); // true

这三行代码把整个关系网串起来了:Person.prototype就是p的原型,同时它有一个constructor属性,又被指回了Person。所以,你从任何一个实例出发,都能通过__proto__找到它的原型对象,再通过原型对象的constructor找到构造函数。

这里要强调一个容易出错的地方:如果你直接给Person.prototype重新赋值了一个对象字面量,那新对象上是没有constructor属性的,你需要手动补上它:

Person.prototype = { constructor: Person, // 手动补回 sayHello() { /* ... */ } };

2.3 用一张“想象图”理解原型查找

你不用真的画图,跟着这段话在脑子里建模就行:

当你写p.sayHello()时,JS 引擎先查p自己身上有没有sayHello,没有的话,就顺着p.__proto__找到Person.prototype,看这个对象身上有没有,有就拿来用。如果Person.prototype身上也没有,就接着往上找Person.prototype.__proto__,也就是Object.prototype。如果Object.prototype也没有,再往上就是null,引擎就会报错说这个方法找不到。

这套一级一级向上查找的路径,就是原型链。它和搜索引擎的“就近匹配”规则很像:先看自己家有没有,没有再往上一级找,找到就停止;找不到就继续向上,直到最高层结束。

2.4 实例上的同名属性会“遮蔽”原型属性

这是一个非常日常的坑。假设原型上有个name属性,实例上也有一个name属性,会发生什么?

function Person(name) { this.name = name || '默认'; } Person.prototype.name = '原型上的name'; const p = new Person('小明'); console.log(p.name); // '小明' delete p.name; console.log(p.name); // '原型上的name'

实例属性比原型属性拥有更高的优先级,这个机制叫属性遮蔽。如果你删除了实例上的属性,原型上的同名属性就会重新显露出来。开发中常见的一个误区是试图清空一个对象的所有属性,结果发现某些属性“删都删不掉”,那八成就是删掉了实例自身的属性,原型上的属性还挂在那儿。

所以你要判断一个属性到底是实例自己的,还是从原型链上摘下来的,就不能只看obj.name的值,要配合hasOwnProperty方法:

console.log(p.hasOwnProperty('name')); // true(实例自己的) console.log(p.hasOwnProperty('sayHello')); // false(来自原型)

3. 原型链的完整版图:从起点到终点的一路追踪

3.1 原型链的尽头是 null

前面提到了查找顺序,这里我把整条链拉出来,给你看一条最典型、最完整的原型链:

function Person(name) { this.name = name; } const p = new Person('小明');

此时,p的原型链是这样的:

p -> Person.prototype -> Object.prototype -> null

再多想一步:Person是一个函数,函数也是对象,所以Person也有原型链。函数的原型链是:

Person -> Function.prototype -> Object.prototype -> null

能看到,无论哪条原型链,最终都会汇聚到Object.prototype,然后终止于null。这是因为Object.prototype里面已经没有__proto__指向更上层的对象了。

这个“终点是 null”的设计其实非常优雅。它保证了你沿着原型链去查找任何属性,最终都会有一个明确的结果而不会无限循环。查找属性是一个线性过程,不是循环过程,所以理论上最多走过的层级数就是原型链的长度,性能上是有边界保障的。

3.2 原型链到底让你“继承”了什么

很多人会问:原型链的意义就是让实例能调用祖先对象上的方法,对吗?对,但不全对。原型链还负责另一件事:类型判定。

举个例子:

function Animal() {} function Dog() {} Dog.prototype = new Animal(); const dog = new Dog(); console.log(dog instanceof Dog); // true console.log(dog instanceof Animal); // true

为什么dog既属于Dog又属于Animal?因为instanceof的判定规则是:沿着dog的原型链往上找,看能不能找到Dog.prototype或者Animal.prototype。能找到就返回true。

这也是为什么所有对象 instanceof Object几乎总是true——因为所有对象都顺着原型链连到了Object.prototype。所以说原型链是一张“继承网络”,它让对象可以“冒充”链上任何一位祖先的类型。

3.3 原型链、类与 extends 的底层等价关系

ES6 的class语法让很多新同学误以为 JavaScript 终于有“标准类”了。但如果你把class编译成旧版语法(或者直接看运行时行为),你会发现class就是一个语法糖,底层还是构造函数加原型链:

class Animal { constructor(name) { this.name = name; } eat() { console.log(`${this.name} is eating`); } } class Dog extends Animal { bark() { console.log('汪汪汪'); } } const dog = new Dog('旺财'); dog.eat(); // 旺财 is eating dog.bark(); // 汪汪汪

extends实现的继承,底层做的事情可以用一个口诀记忆:“子类的prototype的__proto__指向父类的prototype;子类的__proto__指向父类本身”。第一句负责方法继承,第二句负责静态方法继承。这个稍微有点绕,但你可以暂时记住结论:class的继承依然建立在原型链上。

3.4 Object.create 与原型链的灵活操作

如果你不想依赖new来创建继承关系,可以直接用手动指定原型的方式来构建对象。Object.create就是为这个场景设计的:

const base = { greet() { return '你好'; } }; const obj = Object.create(base); obj.name = '小明'; console.log(obj.greet()); // 你好 console.log(obj.__proto__ === base); // true

Object.create(base)创建的新对象,其原型直接指向base。这种方式在实现“纯对象继承”和原型式继承时非常常用。在new的手写实现里,我们也是用它来把新对象的原型指向构造函数的prototype的。

需要注意,Object.create可以传第二个参数,用于定义属性描述符。这个在日常开发中用得少,但在写框架或做底层封装时会用到,知道有这回事就行。

4. 继承与组合:把这些机制用起来的日常姿势

4.1 经典原型链继承:简单但不完美

我们继续往下聊实际应用。如果只看继承,最经典的做法就是让子类的prototype成为父类的一个实例。刚才的例子就是这种:

function Animal(name) { this.name = name; } Animal.prototype.eat = function () { console.log(`${this.name} is eating`); }; function Dog(name) { Animal.call(this, name); // 借用父类构造函数,目的是继承实例属性 } Dog.prototype = new Animal(); Dog.prototype.constructor = Dog; // 修复 constructor 指向

这种方式的问题在于:Dog.prototype = new Animal()会把父类实例的所有属性(比如name)都放到子类的原型上。如果某个子类实例改了this.name,不会影响原型;但如果一个属性是引用类型(比如数组),并且你没有在当前实例上重新赋值,而是直接修改值,那么所有子类实例都能看到这个修改。这是原型链继承最经典的坑:共享引用类型的属性会被意外污染。

解决这个问题的常规方案是“构造函数借用 + 原型继承”的组合模式。Animal.call(this, name)保证每个实例都有自己的属性副本,而Dog.prototype = new Animal()负责把方法挂到原型链上供所有实例共享。

4.2 寄生组合式继承:更推荐的方案

有了上面的经验,老练的开发者一般不会直接用Dog.prototype = new Animal(),因为这样做会多调用一次父类构造函数,还会带来共享属性的隐患。更好的方案是“寄生组合式继承”:

function inheritPrototype(Child, Parent) { const proto = Object.create(Parent.prototype); proto.constructor = Child; Child.prototype = proto; } function Animal(name) { this.name = name; } Animal.prototype.eat = function () {}; function Dog(name) { Animal.call(this, name); } inheritPrototype(Dog, Animal); Dog.prototype.bark = function () {};

这里最精妙的一步是Object.create(Parent.prototype)而不是直接new Parent()。这么做有几个好处:

  1. 不会执行父类构造函数体,避免多余开销;
  2. 子类原型不会包含父类实例本身的属性,只有父类原型上的方法;
  3. 子类实例通过原型链依然能找到父类原型的方法,继承关系清晰。

现在多数框架和库的内部实现,包括class的转译代码,使用的都是这种模式或它的变体。

4.3 多继承与混入(Mixin):JS 里的折中方案

有些传统面向对象语言支持多继承,但 JavaScript 的原型链是一条单链,做不到真正的多继承。不过我们可以用“混入”的方式把多个对象的方法复制到一个类或实例中去:

const canWalk = { walk() { console.log('我可以走路'); } }; const canSwim = { swim() { console.log('我可以游泳'); } }; function Person(name) { this.name = name; } Object.assign(Person.prototype, canWalk, canSwim); const p = new Person('小明'); p.walk(); p.swim();

Object.assign(Person.prototype, canWalk, canSwim)会把canWalk和canSwim里的方法复制到Person.prototype上。这种方式牺牲了原型链的“动态关联”,但换来了灵活的组合能力。在很多实际项目中,用 Mixin 组合比强行拉一条超深的原型链要聪明得多。

4.4 ES6 class 的继承细节:super 和方法重写

class语法让继承的代码写起来舒服了很多,但你依然要清楚它内部的语义。特别是super关键字。在子类构造函数里调用super(...)之前,你不能访问this。为什么?因为 ES6 的class继承机制跟老式构造函数的语义不完全相同,子类的this是在父类构造函数执行完之后才被创建的。

更直白一点说:老式写法里,Animal.call(this, name)是在现有this上执行函数;而class的super(...)更像“先由父类创建出一个全新的this,然后子类在它的基础上继续初始化”。所以如果你在子类构造函数里写this.name = ...之前忘记调用super(),就会直接报错。

还有方法重写的问题。子类可以定义和父类同名的方法,这本身没问题。但如果你想在子类方法里同时调用父类的同名方法,就得用super.method():

class Dog extends Animal { eat() { super.eat(); console.log('还要啃骨头'); } }

这里super.eat()的调用目标由 JavaScript 引擎根据“对象实例的原型链”和“当前方法定义的上下文”共同决定,这就是class底层绑定这些逻辑的地方。

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

5.1 为什么给 prototype 重新赋值后,方法全失效了

这是我自己第一次玩原型时就踩过的坑。你写:

function Person() {} Person.prototype = { sayHello() { console.log('hello'); } }; const p = new Person(); p.sayHello(); // 正常工作

但如果在这之前,已经有p1 = new Person()创建了一个实例,然后你再把Person.prototype换成新对象,那p1.__proto__指向的依然是旧的原型对象。结果就是:

const p1 = new Person(); Person.prototype = { sayHello() {} }; console.log(p1.sayHello); // undefined,因为 p1 的原型还是旧对象

这不是什么神秘问题,本质是“引用指向没变”。__proto__引用是在new的那一瞬间被固定的,之后你改写Person.prototype,只影响新实例,不影响老实例。如果遇到这种问题,第一反应应该是去检查创建实例和修改原型的时间顺序。

5.2 为什么 for...in 会遍历到多余属性

如果你用for...in遍历一个实例对象,可能会发现遍历结果里多出很多不在“自己身上”的属性。这是因为for...in的特性就是:它会遍历对象自身可枚举属性,也会沿原型链遍历可枚举属性。

function Person(name) { this.name = name; } Person.prototype.sayHello = function() {}; const p = new Person('小明'); for (const key in p) { console.log(key); // name、sayHello 都会被打印 }

要解决这个问题,最标准的做法是配合hasOwnProperty过滤:

for (const key in p) { if (p.hasOwnProperty(key)) { console.log(key); // 只输出 name } }

或者直接改用Object.keys(p)获取自身可枚举属性。如果你在项目里发现某个循环操作产生了意料之外的副作用,建议立刻检查是不是遍历到了原型链上的可枚举属性。

5.3 为什么 instanceOf 时灵时不灵

instanceof的原理是检索原型链,但这有一个前提:你比较的对象和构造函数都必须来自同一个“全局上下文”。如果你在多个 iframe 或 window 之间传对象,每个框架都有自己的Array、Object等构造函数,就很可能出现arr instanceof Array返回false的诡异情况。

这不是原型链逻辑出了问题,而是不同全局对象之间的构造函数不是同一个引用。实际项目中遇到跨窗口、跨 iframe 的类型判断需求时,建议用更稳妥的方式,比如Object.prototype.toString.call(value)来判断内置类型。

要理解instanceof的本质,我建议你手写一个它的简易版本:

function myInstanceof(obj, constructor) { let proto = Object.getPrototypeOf(obj); while (proto !== null) { if (proto === constructor.prototype) return true; proto = Object.getPrototypeOf(proto); } return false; }

这个方法实现的逻辑,就是沿着对象的__proto__一层层往上走,直到找到全等引用。理解了这一点,以后遇到instanceof的问题就不慌了。

5.4 原型链上引用属性的“共享污染”

这是我在项目中真实遇到过的:某段代码在父类原型上放了一个数组,然后所有子类实例都去操作这个数组,结果数据乱成一片。

function Bag() {} Bag.prototype.items = []; const b1 = new Bag(); b1.items.push('水杯'); const b2 = new Bag(); console.log(b2.items); // ['水杯'],b2 看到了 b1 的改动

这种共享行为在只读场景下没问题,但只要涉及写操作,就很容易变成 bug。原则很简单:放在原型上的属性一般只建议放方法,如果确实需要共享数据且要各自独立操作,就在构造函数里用this.items = []为每个实例创建新数组。

如果你非要在原型上放引用类型,就必须清楚这是所有实例共同持有的,修改前请三思。这个坑我用一句话总结:原型上的引用属性是“公共财产”,不要在实例里随意改写它。

5.5 调试工具中看到的__proto__和prototype

浏览器开发者工具里打印对象时,你会看到展开的[[Prototype]]或__proto__。很多人看蒙了,不知道这个跟构造函数的prototype有什么区别。

简单说,prototype是函数身上的属性,它描述的是“用这个函数构建出来的对象会继承什么”;__proto__是每个对象身上的属性,它描述的是“这个对象实际连到了谁”。一个是类级别的定义,一个是实例级别的引用。

如果你在调试时想快速拿到某人对象的原型,建议使用标准的Object.getPrototypeOf(obj),而不是直接读obj.__proto__。虽然两者在大多数浏览器里结果一样,但前者是标准 API,后者是非标准的,虽然实现普遍,但在严谨的代码或面试场合,用标准 API 更稳妥。

6. 写在最后的个人体会

我把这些年理解构造函数和原型链的路径浓缩成一句话:不要试图背结论,要把自己逼到“手写实现”那一步。每当你觉得自己理解了new,就手写一遍myNew;每当你觉得理解了继承,就用寄生组合式继承去实现一次;每当你以为自己懂了instanceof,就手写一遍它的逻辑。这些练习做上三遍,你的 “JS 内功”会有质的提升。

至于学习顺序,我的建议是跟着这条线走:普通函数 ->new调用流程 ->prototype和constructor的互指关系 ->__proto__的查找规则 -> 原型链终点null->class与extends的语法糖视角。这条线理清楚了,以后再看任何框架源码里的原型操作,都会觉得顺眼很多。

最后再分享一个小技巧:你在调试别人写的代码时,如果看到一个对象具备你“没想到”的方法,别急着翻文档,先在控制台敲一遍对象名.__proto__看看,再往上打一层对象名.__proto__.__proto__,一路追踪下去,你很快就能看清这个对象的完整“家谱”。原型链这东西,一旦你习惯用“顺着绳子摸终点”的视角去看,就不再是玄学了。

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

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

立即咨询