想象你出生在一个古老的大家族。每个新生儿都会收到两样东西:一本记载着全家族共同规矩的《传家手册》,以及族谱上一笔“父亲是谁”的暗记。手册让后代不用把家训抄写无数份,暗记让后代随时能顺着线索找到祖先。JavaScript 的对象世界就是这样一个家族——prototype 是传家手册,__proto__ 是认父暗记,constructor 是手册扉页上“编者签名”。这三样东西,构成了 JS 面向对象的灵魂:原型链。 当你访问一个对象的属性时,JS 会像翻抽屉一样去查找:先翻自己的抽屉,没有就打开爸爸的抽屉,再没有就打开爷爷的抽屉,一路翻到家族祠堂(Object.prototype),实在没有才摊手返回 undefined。本文就用这个家族故事,把 prototype、__proto__、constructor 的三角关系、四种继承方式的优劣、以及 ES6 class 的糖衣本质,一层层剥给你看。

岗位关联

吃透原型链,你才能真正读懂 JS 中九成的“奇怪行为”,这也是前端面试、框架源码阅读、大型应用架构设计的立足之本。

目录
  1. 可视化演示
  2. 原型三件套:手册、暗链与签名
  3. 属性查找:翻箱倒柜的巡查路线
  4. 构造函数继承:借钱式复制家产
  5. 原型链继承:血缘式共享家传
  6. 组合继承:拼盘方案的两难
  7. 寄生组合继承:复印手册的最优解
  8. class 语法糖:同一套族谱的新装
  9. 实战排查三招:查谱、自查、孤儿
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

原型链:属性查找与继承机制 prototype指向原型对象,原型对象的constructor指回构造函数,实例的proto指向原型对象,三者构成三角关系 属性查找沿原型链向上追溯:先在实例自身查找,未找到则沿proto向上查找直到Object.prototype 寄生组合继承是最优模式:用call继承实例属性,用Object.create继承原型方法,避免父类构造函数调用两次 原型链是JS实现继承的核心,ES6 class是其语法糖

原型三件套:手册、暗链与签名

家族里管事的老人会告诉你:每一代新生儿都要在三样东西上留下印记。第一样是《传家手册》,它会郑重地写到“本族子弟共享以下规矩”——在 JS 里这就是 prototype,一个专门挂公共属性和方法的对象。第二样是族谱上的认父暗记,新生儿出生时家族自动记一笔“此子之父为谁”——这就是 __proto__。第三样是手册扉页的编者签名,写着手册是谁编的——这就是 constructor。三者共同回答了“我是谁、我爸是谁、家里的规矩在哪”。

翻译成技术语言:每个函数(除了箭头函数)天生带一个 prototype 属性,它是一个普通对象。当用 new Foo() 创建实例时,引擎做了三件事:新建一个对象、把它的 __proto__ 指向 Foo.prototype、最后执行 Foo 函数体把 this 上的属性填进去。而 Foo.prototype 上默认有一个 constructor 属性,指回 Foo 自己,形成闭环。

这套三角关系是后面所有继承方式的基石。记住:prototype 是“传给后代的东西”,__proto__ 是“我爹是谁”,constructor 是“手册谁编的”。新手最容易把前两个弄混,但用家族故事记就永远不会:手册是规矩本身,暗记是血缘线索,两者一物一链,天然不同。

new Foo(x)  ⇒  f.__proto__=Foo.prototype,Foo.prototype.constructor=Foo\displaystyle \text{new Foo}(x) \;\Rightarrow\; f.\_\_proto\_\_ = \text{Foo}.\text{prototype},\qquad \text{Foo}.\text{prototype}.\text{constructor} = \text{Foo}
这个公式说明:实例通过 __proto__ 找到手册,手册通过 constructor 找到编者,三者互为印证。
  1. new Foo 诞生实例执行构造函数,得到一个全新对象 f,相当于家族里登记了一个新成员。
    new Foo(x)  ⇒  f\displaystyle \text{new Foo}(x) \;\Rightarrow\; f
  2. 实例的认父暗记新对象的 __proto__ 被自动指向构造函数的 prototype,也就是“我爸的传家手册”。
    f.__proto__=Foo.prototype\displaystyle f.\_\_proto\_\_ = \text{Foo}.\text{prototype}
  3. 手册的编者签名prototype 对象上的 constructor 属性默认指回构造函数本尊。
    Foo.prototype.constructor=Foo\displaystyle \text{Foo}.\text{prototype}.\text{constructor} = \text{Foo}

属性查找:翻箱倒柜的巡查路线

新生儿想用一件传家宝,他并不知道东西放在哪。家族的规矩是:先翻自己房间的抽屉,没有就去爸爸房间翻,再没有就去爷爷房间翻,最后去祠堂的公共箱子里翻,翻到 null 这个“虚空”为止。JS 的属性访问 obj.x 走的正是这条巡查路线,术语叫“沿原型链查找”。路上如果某一层抽屉里有同名的物件,就立刻使用它并停下,不再往上翻——这叫遮蔽(shadowing),像你爸在自家抽屉放了一把和你爷爷一样的钥匙,你用的时候当然拿近的。

技术上的细节是:obj.x 先检查对象自身属性,找不到就沿 obj.__proto__ 逐级上溯。大部分普通对象的链都会汇合到 Object.prototype(家族祠堂),祠堂再往上就是 null,查找到此结束。特别地,obj.x = 1 这种赋值操作永远不会去改原型上的属性,它只会在自己身上新建或覆盖,所以遮蔽是“我自己的东西优先”。而 Object.create(null) 能造出一个连祠堂都不认的“孤儿对象”,彻底没有原型链。

理解这条巡查路线后,很多“怪现象”迎刃而解:为什么给实例加同名属性就能“覆盖”原型方法?因为遮蔽。为什么 hasOwnProperty 能区分“自己的”和“祖传的”?因为它不走这条路。属性查找是 JS 面向对象的地基,后面的继承方案全都是围绕“让这条链更长、更合理”做文章。

obj[x]  →未命中  obj.__proto__  →未命中  ⋯  → Object.prototype  →越界  null\displaystyle \text{obj}[x] \;\xrightarrow{\text{未命中}}\; \text{obj}.\_\_proto\_\_ \;\xrightarrow{\text{未命中}}\; \cdots \;\xrightarrow{}\ \text{Object}.\text{prototype} \;\xrightarrow{\text{越界}}\; \text{null}
一句话:属性查找是从自己出发、沿认父暗记一路向上的单向巡查,null 是链的终点。
  1. obj[x] 起点访问属性时永远先查自身,相当于先翻自己的抽屉。
    obj[x]\displaystyle \text{obj}[x]
  2. 未命中就沿暗链上溯自身没有,就顺着 __proto__ 去父级对象身上继续找。
    →未命中  obj.__proto__\displaystyle \xrightarrow{\text{未命中}}\; \text{obj}.\_\_proto\_\_
  3. 直到公共祖先逐层上溯,最终会到达所有普通对象共享的 Object.prototype。
    ⋯  → Object.prototype\displaystyle \cdots \;\xrightarrow{}\ \text{Object}.\text{prototype}
  4. null 终点祠堂里也没有,就触到 null,查找终止并返回 undefined。
    Object.prototype.__proto__=null\displaystyle \text{Object}.\text{prototype}.\_\_proto\_\_ = \text{null}

构造函数继承:借钱式复制家产

有一种继承思路很直接:孩子出生时,父母直接塞给他一份家产清单,把名下所有物件都复制一份给他,从此各过各的、互不相欠。在 JS 里,这叫构造函数继承(constructor stealing)。做法是在子构造函数里调用 Parent.call(this, ...)——call 的作用是“借”父构造函数来用,并强行把它的 this 改成子实例,于是父构造函数里所有 this.xxx = ... 的语句,属性全都落在了子实例自己身上。

这种方式的优点很实在:子实例的属性完全独立,改自己的不会影响别人;而且可以在调用时传入参数,每个孩子拿到不同的家产。但它有一个致命短板:父构造函数里 this.method = function(){} 这样定义的方法也被复制了一份到每个实例身上——家族明明有一本公共手册(Parent.prototype),却没人用,每个孩子都自己抄了一份家训,内存浪费严重。而且父原型上的方法,子实例通过这条链根本够不着。

用家族话说,构造函数继承是“分家产式继承”:孩子拿到一笔独立财产,却和家族祠堂断了血脉。所以它适合“只要属性、不要公共方法”的场景,更多时候它只是作为一个零件,被组合进更完善的方案里,就像拼盘里的一味主料。

Child(a)  :  Parent.call(this,a)  ⇒  this.a=a,Child.prototype⊄链\displaystyle \text{Child}(a) \;:\; \text{Parent}.\text{call}(\text{this}, a) \;\Rightarrow\; \text{this}.a = a,\quad \text{Child.prototype} \not\subset \text{链}
公式的含义:call 只负责把父构造函数里的属性语句“平移”到子实例上,原型链这条血脉并没有接通。
  1. Parent.call 借用父构造把父构造函数当作普通函数“借”过来调用,它内部的 this 被替换。
    Parent.call(this,a)\displaystyle \text{Parent}.\text{call}(\text{this}, a)
  2. 属性落到子实例父函数体里 this.xxx = ... 全部写进子实例自己的属性表。
    this.a=a\displaystyle \text{this}.a = a
  3. 原型链未接通子构造函数的 prototype 仍是默认对象,没有连到父原型上。
    Child.prototype⇏Parent.prototype\displaystyle \text{Child.prototype} \not\Rightarrow \text{Parent.prototype}

原型链继承:血缘式共享家传

另一条路是血缘式继承:不复制任何家产,孩子通过认祖归宗自动获得家族血脉。做法很粗暴:Child.prototype = new Parent()——把子构造函数的传家手册整个换成“一个父实例”。这样一来,子实例沿链走两步就能摸到 Parent.prototype 上的方法:实例 → Child.prototype(父实例)→ Parent.prototype。公共方法天然共享,不用复制,内存省了。

但血缘共享有个大坑:如果家产清单(原型)上放了引用类型的传家宝,比如一个数组 this.hobbies = [],所有子实例共用的是同一份数组。一个孩子往数组里加东西,其他孩子全看到了——这不是继承,这是“连坐”。更麻烦的是,创建 Child.prototype 时 new Parent() 的参数是写死的,子实例出生时没办法通过 new Child(args) 把参数传给父构造函数,每个孩子拿到的祖传属性都一样。

回看家族比喻:血缘继承打通了血脉、共享了祠堂,但祖产(引用属性)公有化导致一损俱损,且新生儿的个人参数传不进父辈。它适合“方法共享、无引用属性”的简单场景,但作为完整继承方案,它和构造继承一样只是半成品,所以才有了下面的拼盘。

Child.prototype=new Parent()  ⇒  ∀c∈Child:  c.__proto__.__proto__=Parent.prototype\displaystyle \text{Child.prototype} = \text{new Parent}() \;\Rightarrow\; \forall c \in \text{Child}:\; c.\_\_proto\_\_.\_\_proto\_\_ = \text{Parent.prototype}
意思是:子原型被一个父实例顶替,实例到父原型之间隔了两跳,公共引用被所有后代共享。
  1. 子原型替换把 Child 的手册换成父实例,相当于让“孩子的爸爸”直接当手册。
    Child.prototype=new Parent()\displaystyle \text{Child.prototype} = \text{new Parent}()
  2. 两跳抵达父原型子实例沿链先到父实例,再到父原型。
    c.__proto__=Child.prototype,c.__proto__.__proto__=Parent.prototype\displaystyle c.\_\_proto\_\_ = \text{Child.prototype},\quad c.\_\_proto\_\_.\_\_proto\_\_ = \text{Parent.prototype}
  3. 方法共享父原型上的方法被子实例和子原型同时读到,不需要复制。
    ∀c:c.method=Parent.prototype.method\displaystyle \forall c: c.\text{method} = \text{Parent.prototype}.\text{method}

组合继承:拼盘方案的两难

聪明的工匠把前两种方案拼在一起:构造函数继承负责“分家产”——属性各自独立;原型链继承负责“通血脉”——方法公共共享。于是就有了组合继承(combination inheritance):在子构造函数里 Parent.call(this, args) 复制属性,再在外面 Child.prototype = new Parent() 接通血脉。它属性独立、方法共享、还能传参,看起来非常完美,一度是 JS 教科书上的“标准答案”。

但拼盘有个隐蔽的浪费:父构造函数被执行了两次。第一次在 Parent.call(this) 里,把属性复制到子实例上;第二次在 new Parent() 里,又把同样的属性做进了 Child.prototype。结果每个子实例身上有两份父属性:自己身上一份(用得上),原型上还有一份(永远被自身的遮蔽,白占内存)。名字里的“组合”二字,既是优点也是代价——它什么都好,就是不够干净。

用家族的话说,父母既塞了一份家产清单给孩子,又在祠堂里多存了一份一模一样的清单——孩子永远用自己那份,祠堂那份成了文物。组合继承是“能用但有点浪费”的方案,理解了它的冗余,你才能真正体会到寄生组合继承的巧妙。

Parent.call(this)  ⊕  Child.prototype=new Parent()  ⇒  #{Parent}=2\displaystyle \text{Parent}.\text{call}(\text{this}) \;\oplus\; \text{Child.prototype} = \text{new Parent}() \;\Rightarrow\; \#\{\text{Parent}\} = 2
这个式子揭示了组合继承的核心矛盾:call 和 new 各执行一次父构造函数,总共两次,产生了冗余属性。
  1. call 复制属性子构造函数内借父构造执行一次,实例获得自己的属性。
    Parent.call(this,args)\displaystyle \text{Parent}.\text{call}(\text{this}, \text{args})
  2. new 再造父实例外面又 new 一个父实例当子原型,属性再次被创建。
    Child.prototype=new Parent(args)\displaystyle \text{Child.prototype} = \text{new Parent}(\text{args})
  3. 父构造共两次同一份属性逻辑被执行两遍,实例和原型各存一份。
    #=2\displaystyle \# = 2

寄生组合继承:复印手册的最优解

能不能既接通血脉,又不在祠堂里多存一份冗余家产?工匠想出一个妙招:不直接 new 一个父实例当子原型,只用一台“复印机”把父原型的空壳复印一份,让子原型以父原型为模板诞生,再在上面写子类自己的方法。这台复印机就是 Object.create:它以参数为原型,返回一个全新对象,全程不调用任何构造函数。

于是寄生组合继承(parasitic combination inheritance)诞生了:

Object.create(Parent.prototype) 造出一个以父手册为模板的干净副本,子手册换上去后血脉通了;再把副本上的 constructor 修正回 Child(因为复印机会把 constructor 也继承成 Parent,得手动改签)。父构造函数只执行一次(在子构造函数里 call 的那次),没有冗余属性,属性独立、方法共享、能传参——四全其美。ES5 时代,这就是公认的最优继承方案。

回到家族故事:寄生组合继承等于只复印了《传家手册》的空白封面,封面签上“本册属 Child 支系”,里面的公共家训仍指向 Parent 祠堂,却不占用任何多余人手、不执行多余仪式。这个方案把原型链的灵活发挥到了极致,也是后面 ES6 class 内部悄悄做的事——先懂它,你才算真正看穿了继承的底牌。

Child.prototype=Object.create(Parent.prototype),Child.prototype.constructor=Child\displaystyle \text{Child.prototype} = \text{Object.create}(\text{Parent.prototype}),\qquad \text{Child.prototype.constructor} = \text{Child}
核心思想:只复印手册的“版式”,不复印家产本身;血脉照通,冗余为零。
  1. Object.create 复印模板以父原型为原型,创建一个空的新对象,不执行父构造函数。
    Object.create(Parent.prototype)\displaystyle \text{Object.create}(\text{Parent.prototype})
  2. 换上新手册子构造函数的 prototype 换成这个干净副本,血脉接通。
    Child.prototype=⋯\displaystyle \text{Child.prototype} = \cdots
  3. 修正 constructor 签名副本上原有的 constructor 要手动指回 Child。
    Child.prototype.constructor=Child\displaystyle \text{Child.prototype.constructor} = \text{Child}
js4 行
function inherit(Child, Parent) {  Child.prototype = Object.create(Parent.prototype);  Child.prototype.constructor = Child;}

class 语法糖:同一套族谱的新装

很多从 Java 转过来的同学看到 class 关键字欣喜若狂,以为 JS 终于有了“标准面向对象”。其实 class 只是给原型链家族穿上的一件西装:内里还是那套手册、暗链、签名。class Foo {} 的本质是一个构造函数,typeof Foo === 'function';class Child extends Parent 的本质就是寄生组合继承那两行代码;子类里的 super() 本质就是 Parent.call(this),而且因为 this 必须等父构造初始化完毕才能使用,所以 super() 必须写在子构造函数第一行——这是“先认祖,再自立”的仪式感。

class 语法解决了老写法“容易手滑”的问题:方法自动挂在 prototype 上、继承关系一行声明、super 语法清晰,但它没有引入任何新的对象模型。new Child() 之后的对象,child.__proto__.constructor 依然是 Child,child.__proto__.__proto__ 依然是 Parent.prototype,一切照旧。换句话说,class 改的是笔迹,没改族谱。

用家族故事收个尾:class 是把散落在各处的认祖仪式、签到流程装订成一本《家规手册》,新人照着念就行,不用懂背后的族谱逻辑。但如果你想排查线上问题、读框架源码、或者手写 polyfill,就必须把糖衣剥开,看见里面那条朴素的 __proto__ 链——这正是本节的用意。

class Child extends Parent  ≡  寄生组合继承的语法糖\displaystyle \text{class Child extends Parent} \;\equiv\; \text{寄生组合继承的语法糖}
公式提醒你:extends 背后就是 Object.create 那条链,class 只是更好看的写法。
  1. class 声明本质上是定义了一个带 prototype 的函数,typeof 仍是 function。
    class Foo  ≡  function Foo\displaystyle \text{class Foo} \;\equiv\; \text{function Foo}
  2. extends 自动接链引擎自动完成 Child.prototype 与 Parent.prototype 的连接。
    extends  ⇒  Object.create(Parent.prototype)\displaystyle \text{extends} \;\Rightarrow\; \text{Object.create}(\text{Parent.prototype})
  3. super 先调用子构造函数里必须先 super(),相当于先执行父构造初始化 this。
    super(args)  ≡  Parent.call(this,args)\displaystyle \text{super}(\text{args}) \;\equiv\; \text{Parent.call}(\text{this}, \text{args})

实战排查三招:查谱、自查、孤儿

掌握了原理,还要会“破案”。前端排查继承相关问题,有三件趁手的工具。第一件是查族谱:a instanceof B 会从 a 出发沿原型链走,途中只要遇到 B.prototype 就返回 true,它回答的是“a 家谱上有没有 B 这一支”。第二件是自查:obj.hasOwnProperty('x') 只问“这个属性是不是 obj 自己身上的”,完全不看族谱,专门用来区分“我自己的”和“祖传的”。第三件是造孤儿:Object.create(null) 创建一个没有原型的对象,适合做纯字典,防止 __proto__ 被恶意污染,也不会被 for...in 意外翻出祖先的属性。

这三招常配合使用。比如遍历对象时,for...in 会把原型链上可枚举的属性也捞出来,所以标准写法是 for (const k in obj) { if (obj.hasOwnProperty(k)) ... };判断数组、日期等内置类型时用 arr instanceof Array 会受跨 iframe 影响,更稳妥的是 Object.prototype.toString.call(arr)。这些技巧的本质,都是对“属性查找路径”的精确控制——你知道链怎么走,就知道在哪一步设卡。

侦探三件套的家族版本:查出身看族谱(instanceof)、查私产看房本(hasOwnProperty)、要绝对干净就收养一个不认任何祖先的孤儿(Object.create(null))。这三招覆盖了日常九成的原型链排查需求,配合前面几节对链的理解,你已经能像一个“懂族谱的老族长”一样,从容解释任何属性查找的来龙去脉。

obj instanceof F  ⟺  ∃ k≥0:  obj.__proto__⋯__proto__⏟k 层=F.prototype\displaystyle \text{obj instanceof F} \iff \exists\, k \geq 0:\; \text{obj}.\underbrace{\_\_proto\_\_ \cdots \_\_proto\_\_}_{k\ \text{层}} = \text{F}.\text{prototype}
instanceof 是沿着链走 k 步找目标手印的过程;hasOwnProperty 则一步都不走,只看脚下。
  1. obj 出发从对象自身开始,沿认父暗链逐层上跳。
    obj.__proto__⋯__proto__\displaystyle \text{obj}.\_\_proto\_\_ \cdots \_\_proto\_\_
  2. 寻找 F.prototype每跳一层,都比较当前层的原型手印是否等于构造函数的 prototype。
    =F.prototype\displaystyle = \text{F}.\text{prototype}
  3. 存在即真只要某一步命中,表达式为 true;走到 null 也没命中则为 false。
    ∃k:⋯  ⟺  true\displaystyle \exists k: \cdots \iff \text{true}

AI 应用场景

浏览器端 AI 模型封装与基类扩展

阶段:模型部署与前端集成

解决的问题:在浏览器里跑 TensorFlow.js、transformers.js 等 AI 推理库时,直接修改第三方库源码会在升级时被覆盖;传统做法是复制粘贴改代码,维护成本极高。利用 JS 的原型继承,可以优雅地继承官方基类,只覆写预处理、后处理等局部方法。

具体案例:某 AI 对话产品需要把用户输入统一加上 prompt 模板再交给模型。工程团队定义 class MyPipeline extends TextClassificationPipeline,在子类里重写 tokenizer 预处理逻辑,公共的模型加载、推理调度全部复用父原型上的实现。后续升级依赖库,只需重新测试子类,父类行为零改动。

统一 AI 错误处理与降级体系

阶段:应用工程与容错

解决的问题:AI 应用中错误类型繁杂(模型加载失败、推理超时、流式中断、鉴权过期),传统做法是到处 try/catch 再用字符串匹配错误码,极易写错且难以维护。借助原型链继承,可以建立统一的错误家族,再用 instanceof 沿链精准分流。

具体案例:一个流式对话服务定义了 class AIError extends Error {},再派生出 AITimeoutError、AIModelError、AIStreamError。全局错误处理器里用 err instanceof AITimeoutError 走重试、err instanceof AIModelError 走降级模型切换、其余走兜底提示。因为 instanceof 沿原型链查找,捕获子类错误时父类判断也能兜住,容错体系既灵活又严谨。

AIGC 低代码平台的组件继承

阶段:AI 应用构建与平台化

解决的问题:AIGC 搭建平台有大量画布组件(文本生成、图像生成、视频生成),若各自为政,公共的拖拽、缩放、状态管理逻辑会重复实现。把公共方法挂到组件基类的原型上,子组件按需继承和覆写,是典型的原型链应用。

具体案例:某 AI 绘图平台定义 class BaseNode { render() {...} bindEvents() {...} },ImageNode、TextNode、VideoNode 分别 extends 它并覆写 render。所有节点的公共生命周期方法都放在基类原型上,实例对象通过原型链共享,内存占用小、扩展新节点只需写差异代码。

Python 演示

python54 行
"""Use Python to simulate JS prototype chain: lookup, shadowing, inheritance"""  class JSObject:    """A JS-like object: __dict__ stores own props, proto points to parent"""     def __init__(self, proto=None):        self.__dict__['proto'] = proto     def set(self, name, value):        self.__dict__[name] = value     def get(self, name):        node = self        while node is not None:            if name in node.__dict__:                return node.__dict__[name]            node = node.__dict__.get('proto')        raise AttributeError('not found: ' + name)  def make_person(name, age):    p = JSObject()    p.set('name', name)    p.set('age', age)    return p  def make_student(name, age, school):    """Combination inheritance: copy props first, then link parent on chain"""    parent = make_person(name, age)    s = JSObject()    for key, value in parent.__dict__.items():        if key == 'proto':            continue        s.__dict__[key] = value    s.__dict__['proto'] = parent    s.set('school', school)    return s  if __name__ == '__main__':    person_proto = JSObject()    person_proto.set('species', 'human')     alice = make_student('Alice', 10, 'No.1 School')    print(alice.get('name'))    print(alice.get('school'))    print(alice.get('species'))     bob = JSObject(person_proto)    bob.set('species', 'robot')    print(bob.get('species'))    print(alice.get('species'))
目的:用 Python 模拟 JS 原型链的属性查找、遮蔽效应和组合继承,直观演示“沿链上溯”的过程。
  1. while node is not None:从实例出发沿链走,直到 null 终点才停,对应 JS 原型链的终止条件。
  2. if name in node.__dict__:先看当前对象的自有属性表,相当于先翻自己的抽屉。
  3. node = node.__dict__.get('proto')没找到就取出认父暗记,跳到父级继续查。
  4. s.__dict__[key] = value模拟构造函数继承:把父实例的属性复制到子对象身上。
  5. s.__dict__['proto'] = parent模拟原型链继承:把父实例挂到子对象的链上。
  6. alice.get('species')实例自身没有 species,顺着链在父级身上找到,输出 human。
  7. bob.set('species', 'robot')遮蔽效应:bob 在自己身上新建同名属性,只影响自己。
  8. print(alice.get('species'))依然输出 human,证明遮蔽不影响链上的其他兄弟。

常见误区

误区我一直以为 `prototype` 和 `__proto__` 是一回事,都是“原型”。比如 `fn.prototype` 和 `fn.__proto__` 应该都指向同一个对象吧?写代码时经常混着用。

正解两者完全不同。prototype 是函数身上“传给后代的手册”,只有函数才有,new 出来的实例没有 prototype;__proto__ 是每个对象都有的“认父暗记”,指向构造它的函数的 prototype。用家族比喻:手册是家族公共规矩,暗记是你爸爸是谁,一物一链,方向相反。

为何常见:两个词都带“原型”字样,中文都翻译成“原型”,加上控制台打印时经常出现 [[Prototype]] 隐藏属性,初学者极易混淆。

误区既然函数都能 new 或者调用,那箭头函数应该也有 `prototype` 属性吧?我试过箭头函数 `() => {}`,感觉它也能当构造函数用。

正解普通函数(function 声明和表达式)都有 prototype,但箭头函数没有。箭头函数继承了外层作用域的 this,不能作为构造函数,不能 new,因此引擎不给它配 prototype。可以试试 typeof (() => {}).prototype,结果是 "undefined"。这就像家族里“入赘女婿”不传家族姓氏一样——没有后代手册,自然没有传家手册。

为何常见:大多数教程只讲“函数有 prototype”,没强调“普通函数”这个限定语;实际开发中箭头函数用得又多,踩坑后才反应过来。

误区JS 有了 class 关键字,应该和 Java 一样是编译期静态类型、类定义不可变、`this` 绑定和 Java 完全相同吧?以后可以完全按 Java 那套思维写 JS 了。

正解class 只是寄生组合继承的语法糖,底层依然是函数加原型链。typeof class Foo {} 是 "function",child.__proto__ 依然存在,实例方法还是挂在 prototype 上。它既没有引入新的内存模型,也没有静态类型检查,this 依旧由调用方式决定。糖衣再甜,拨开还是那条族谱链。

为何常见:class 关键字与 Java/C++ 同名同形,极易触发“语言相似性错觉”,加上 ES6 普及后很少有人去读转译产物。

误区我觉得组合继承调用了 `Parent.call(this)`,属性都复制到实例上了,所以组合继承不存在引用类型被共享的问题,只有原型链继承才有这个坑。

正解组合继承照样有共享问题。因为 Child.prototype = new Parent() 创建出来的原型对象上,本身就有一份父构造函数的引用属性(比如数组)。虽然实例自身的同名属性会遮蔽它,但如果子类代码通过 Child.prototype 去操作这个数组,或者在某些边界场景下,这隐藏的共享引用仍然存在。所以真正的根治方案是寄生组合继承:用 Object.create 造干净原型,完全不执行父构造函数,从源头消灭冗余和共享。

为何常见:学习者记住了“组合继承属性独立”这个结论,却忽略了它内部还藏着一个 new Parent(),那一次调用留下的原型属性正是隐患。

面试题

Q1. const f = new Foo(),请问 f 的 __proto__ 指向谁?

单选题 · easy

  • A.Foo 的原型对象← 正确答案
  • B.Foo 的实例对象
  • C.f 自身的原型表
  • D.Object 的原型对象

答案:A。new 的过程中,引擎会把实例的 __proto__ 指向构造函数的 prototype,所以 f.__proto__ === Foo.prototype。用家族话说,实例的“认父暗记”指向父亲手上的“传家手册”,而不是指向爸爸本人,更不是指向 Object 原型。

Q2. 要判断对象 obj 上是否有“自有属性 name”,最可靠的方法是?

单选题 · mid

  • A.直接判断 name in obj
  • B.调用 hasOwnProperty 检测← 正确答案
  • C.判断 obj.name 不为空值
  • D.遍历原型链上的所有属性

答案:B。name in obj 会沿原型链查找,把祖传属性也算进去;obj.name !== undefined 在属性值为 undefined 时误判;遍历全链效率低且把非自有也算入。hasOwnProperty 只看自身属性表,就像查房本只认户主名下的资产,不认祖上传下来的。

Q3. 关于组合继承(构造函数继承 + 原型链继承),下列说法正确的有?

多选题 · mid

  • A.父构造函数会被调用两次← 正确答案
  • B.子实例上存在冗余属性← 正确答案
  • C.子类无法继承父类方法
  • D.原型链上找不到父属性

答案:AB。组合继承里 Parent.call(this) 一次、Child.prototype = new Parent() 又一次,父构造函数总共执行两次,第二次在原型上留下冗余属性(被实例自身遮蔽但白占内存)。方法沿原型链正常继承,父属性也在链上,所以 C、D 错误。

Q4. 想让 Child 继承 Parent,且不产生冗余属性,最佳做法是?

单选题 · mid

  • A.复制一份 Parent 的所有方法
  • B.用 new Parent 当子类原型
  • C.用 Object.create 建新原型← 正确答案
  • D.把 Child 原型指针改写

答案:C。Object.create(Parent.prototype) 只以父原型为模板创建干净副本,不执行父构造函数,属性不冗余。new Parent() 会执行构造并留下冗余;复制方法是构造继承的写法,方法无法复用;“改写指针”指向不明,且直接 Child.prototype = Parent.prototype 会让子父原型共享,修改互相污染。

Q5. hasOwnProperty 方法的作用是?

单选题 · easy

  • A.检查属性是否在原型链上
  • B.检查属性是否为自有属性← 正确答案
  • C.检查属性能否被枚举出来
  • D.检查属性是否可以被删除

答案:B。hasOwnProperty 只检查属性是否存在于对象自身的属性表中,完全不沿原型链查找。它就像查房本——只认自己名下的资产,不认祖传财产。是否可枚举是 propertyIsEnumerable 管的事,能否删除是 delete 的事,都不属于它。

Q6. 下列哪些方式能判断 obj 是 Foo 的实例?

多选题 · mid

  • A.使用 obj instanceof Foo← 正确答案
  • B.用 Foo 原型 isPrototypeOf← 正确答案
  • C.判断 obj 的 constructor 指向
  • D.判断 obj 自身存在 prototype

答案:AB。instanceof 沿 obj 的原型链查找是否出现 Foo.prototype,是标准判断;Foo.prototype.isPrototypeOf(obj) 语义相同。obj.constructor 可以被修改或继承,不可靠;实例对象自身没有 prototype 属性,D 本身就不成立。

Q7. 关于箭头函数与 prototype,下列说法正确的是?

单选题 · hard

  • A.箭头函数自带 prototype 表
  • B.只有普通函数有 prototype← 正确答案
  • C.箭头函数 prototype 为 null
  • D.两者都没有 prototype 属性

答案:B。箭头函数没有 prototype 属性,(() => {}).prototype 是 undefined。因为它不能作为构造函数使用,没有“传家手册”的必要。但普通函数(function 声明和表达式)都有 prototype。注意“普通函数”也包括 class 的构造函数,它是带 prototype 的特殊函数。

Q8. 采用原型链继承(Child.prototype = new Parent()),会带来哪些问题?

多选题 · hard

  • A.引用类型的属性被实例共享← 正确答案
  • B.创建子实例时无法传参← 正确答案
  • C.子实例完全无法继承方法
  • D.父构造函数被调用两次

答案:AB。原型上的引用类型属性被所有实例共用,一个实例改动全部实例可见;new Parent() 的参数写死,实例创建时无法向父构造传参。方法是可以沿链继承的,所以 C 错;“父构造调用两次”是组合继承的缺点,原型链继承只调用一次,D 错。

Q9. ES6 的 class 继承本质是什么?

单选题 · easy

  • A.与 Java 类完全相同的机制
  • B.语法糖,底层仍是原型链← 正确答案
  • C.全新的一套内存存储模型
  • D.与普通函数没有任何关联

答案:B。class 是寄生组合继承的语法糖,extends 底层仍是 Object.create 连接原型链,super 底层是 Parent.call(this)。它没有引入新内存模型,typeof class {} 是 "function",和 Java 的类机制有本质区别。

Q10. Function.prototype 的 __proto__ 指向什么?

单选题 · hard

  • A.Function 构造函数的原型
  • B.Object 构造函数的原型← 正确答案
  • C.指向 null 表示链的终点
  • D.指向自身成环的闭环

答案:B。Function.prototype 虽然是个函数对象,但它的 __proto__ 指向 Object.prototype,因为一切对象(包括函数对象)最终都要汇入 Object 这条“家族主线”。Function.prototype.__proto__ === Object.prototype 为 true。它并不指向自己,也不直接指向 null——链的终点在 Object.prototype 之上才是 null。