ARTICLE DETAIL

资讯详情

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

JavaScript构造函数、原型与原型链:从原理到实践

JavaScript构造函数、原型与原型链:从原理到实践 构造函数、原型、原型链这三个词几乎每个学 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是一个特别有意思的操作符它做了四件事而且顺序是固定的在内存里创建一个全新的对象将这个对象的[[Prototype]]也就是内部原型指针绑定到构造函数的prototype属性上把构造函数内的this指向这个新对象并执行构造函数体内的代码如果构造函数显式返回了一个对象则返回那个对象否则返回新创建的这对象。这四步里第二和第四步是特别容易被忽略的。第二步就是“原型链”的起点没有这一步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); // trueObject.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()。这么做有几个好处不会执行父类构造函数体避免多余开销子类原型不会包含父类实例本身的属性只有父类原型上的方法子类实例通过原型链依然能找到父类原型的方法继承关系清晰。现在多数框架和库的内部实现包括class的转译代码使用的都是这种模式或它的变体。4.3 多继承与混入MixinJS 里的折中方案有些传统面向对象语言支持多继承但 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__一路追踪下去你很快就能看清这个对象的完整“家谱”。原型链这东西一旦你习惯用“顺着绳子摸终点”的视角去看就不再是玄学了。
返回列表