
在 JavaScript 生态里待得越久你会越发觉一个事实很多人写业务代码极其熟练组件、状态管理、性能优化都能侃侃而谈但一碰到“构造函数、原型、原型链”这三个词就支支吾吾。我自己也是这么过来的早年在电商项目里写公共弹窗组件同事让我把 show、hide、destroy 这几个方法挂到 prototype 上我二话不说写进了构造函数里每个弹窗实例都复制了一份方法。那时候我只知道“原型链”这个名词很高级但根本不清楚它到底是什么、怎么运作的更别提 new 背后到底发生了哪些事情。这篇文章就想把这条主线彻底捋一捋。从构造函数到底是什么到 new 的执行细节再到原型链的查找规则、继承的几种经典写法最后补几个冷门但面试和工作都可能撞上的知识点。适合两类人一类是刚学完 HTML、CSS、JS 基础准备进阶的新手另一类是写了一两年业务想系统补一补底层机制的同学。看完你会发现所谓继承、class 语法糖、各种设计模式底层翻来覆去讲的就是这一条链。1. 先搞清楚三样东西到底是什么1.1 构造函数本质上就是一个普通函数先破除一个最大的误解JavaScript 里没有一个叫作“构造函数”的特殊类型。函数就是函数一个函数既能像普通函数那样直接调用也能用 new 关键字触发“构造”行为。区别不在函数本身而在调用它的方式——前面加了 new引擎就会走另一套流程创建新对象、绑定 this、执行函数体、返回对象。我们通常把用作构造函数的函数名首字母大写那只是社区约定用来提醒阅读者“别忘记加 new”。如果你用new person()这种写法代码也能跑只是不符合规范。真正强制“必须用 new”的是 ES6 的 class 语法这在后面会单独讲。function Person(name, age) { this.name name; this.age age; } const p1 new Person(张三, 28);请注意Person这个函数里没有return也没有显式创建对象是 new 帮我们完成了这些工作。这也是很多人第一次看到构造函数时最想不通的地方明明函数体里只写了一行this.name name怎么 new 完之后就拿到一个包含 name 和 age 的对象答案在 new 的流程里。1.2 原型挂载在构造函数身上的“公共模板”每个函数在创建时都会自动获得一个prototype属性箭头函数除外这个属性指向一个对象。这个对象默认带一个constructor字段指回函数本身。说到这很多人容易绕晕因为prototype这个名字太有迷惑性了。请记住这句话函数的prototype是“用它 new 出来的实例”的原型而不是函数自己的原型。函数自己的原型是另外一个东西叫__proto__内部是 [[Prototype]]后面会展开。原型的主要作用是共享。把方法挂到prototype上之后所有 new 出来的实例都能顺着关系找到这个方法而且每个实例不各自持有一份拷贝大家共用同一个函数。这就解决了两个问题一是内存浪费二是后续扩展能力。1.3 原型链由proto串起来的一条查找路径有了prototype再看__proto__。每个对象包括函数对象都有一个内部指针指向“创造它的函数”的prototype。这是原书里最核心的那个公式实例的__proto__ 构造它的函数的prototype举个例子。p1.__proto__ Person.prototype成立。那Person.prototype也是一个对象它的__proto__又指向谁指向Object.prototype。而Object.prototype.__proto__是null链到这里就断了。原型链上的每一步都是一个“我这边没有就去上面找”的过程。你访问一个对象的某个属性时引擎先查对象自身没有就沿__proto__指针往上翻一级再没有就继续翻直到整条链走完。这有点像家族遗产儿子没有的财产去问老子老子没有的去问爷爷爷爷也没有那就只能返回 undefined 了。2. new 到底做了什么手写一个 new2.1 引擎执行 new 的完整流程不少人能背出“new 会创建一个新对象”这句话但完整流程往往说不全。一个标准的 new 调用引擎会依次做这些事创建一个全新的空对象 obj把 obj 的内部原型指向构造函数的prototype也就是让obj.__proto__ Constructor.prototype执行构造函数并把 this 绑定到这个 obj 上如果构造函数显式返回了一个对象包含函数那么 new 的结果是那个对象否则返回第一步创建的这个 obj。很多旧教程只写前三步把第四步吞了。可一旦忽略了第四步后人写代码时就容易踩到“构造函数里返回对象”的坑。为了把整个流程刻进脑子里建议自己手写一个 fakeNew一遍就懂。function fakeNew(Constructor, ...args) { // 第一步 第二步创建新对象并把它连接到构造函数的 prototype 上 const obj Object.create(Constructor.prototype); // 第三步执行构造函数把 this 指向新手对象 const result Constructor.apply(obj, args); // 第四步处理返回值 const isReturnObject result ! null (typeof result object || typeof result function); return isReturnObject ? result : obj; }这里面Object.create(Constructor.prototype)是 ES5 提供的原生方法作用就是创建一个新对象并把它的__proto__指向参数里的对象。如果你不想用 Object.create也可以手动完成function fakeNew2(Constructor, ...args) { const obj {}; Object.setPrototypeOf(obj, Constructor.prototype); const result Constructor.apply(obj, args); const isReturnObject result ! null (typeof result object || typeof result function); return isReturnObject ? result : obj; }第二版更适合理解本质造一个空对象然后把它的“父级”设为构造函数的 prototype再执行构造函数。最终拿到手的是什么取决于构造函数有没有 return 引用类型。2.2 构造函数里的 return一个容易被忽略的坑很多新手以为构造函数里不能写 return其实写了也不报错但结果会不同。通常来说如果构造函数里 return 的是一个原始类型的值比如数字、字符串new 的结果不会被影响引擎还是把第一步创建的 obj 还给你。代码里写return只是为了提前结束函数体。但如果 return 的是对象或函数事情就变了new 的结果直接变成你 return 的那个对象。你 this 上绑了多少属性都白搭最终拿到的对象和你构造函数里写的 this 没关系了。我在真实项目里见过一次这样的 bug。某个基础库的作者在构造函数里写了个return { custom: true }本意是为了覆盖默认返回结果所有通过new Base()创建的实例instanceof Base都是 false。因为 instance 判断的是原型链上有没有Base.prototype而返回的那个对象压根没连接到Base.prototype。function User() { this.name 张三; return { fake: true }; } const u new User(); console.log(u.name); // undefined console.log(u.fake); // true console.log(u instanceof User); // false所以我的建议是除非你对语言特性极其熟悉并且有明确的设计意图否则不要在构造函数里返回对象。这会让代码的语义变得混乱。2.3 忘了 new 会怎样构造函数最忌讳的就是忘记 new。非严格模式下普通调用Person()时函数内部的 this 指向全局对象浏览器里是 window于是this.name name等于给全局对象挂了个属性代码不会报错但你会莫名涂污染全局变量。严格模式下情况更严酷。ES module 和use strict下普通调用时 this 是 undefined等函数体执行到this.name name这句时浏览器直接抛出一个TypeError: Cannot set properties of undefined。因为 undefined 上不能挂属性。use strict; function Person(name) { this.name name; // TypeError: Cannot set properties of undefined } Person(张三);如果你写的是工具库希望强制调用者使用 new可以借助new.target做一道检查。new.target 在普通函数被 new 调用时指向函数本身没加 new 调用时是 undefinedfunction Person(name) { if (new.target undefined) { throw new Error(Person 必须使用 new 调用); } this.name name; }class 内部其实就内置了类似的检查所以用 class 定义的构造函数无论如何都不能当成普通函数调用它会直接抛错。3. 原型链到底怎么走查找规则与遮蔽3.1 记忆锚点实例.proto等于构造函数.prototype整条原型链最关键的公式前面已经出现过一次这里再强调一遍p.__proto__ Person.prototype只要记住这一条其他都能推导出来。比如Person.prototype.constructor Person又因为Person.prototype本身也是对象它的__proto__指向Object.prototype所以顺着链往上走最终到达null。表达式值说明p.__proto__Person.prototype实例连向构造它的函数的 prototypePerson.prototype.constructorPerson原型默认带 constructor 指回函数Person.prototype.__proto__Object.prototype函数的 prototype 也是一个普通对象Object.prototype.__proto__null原型链终点这一串关系一定要亲手在浏览器控制台里敲一遍。打开 DevTools 敲一个const p new Person()然后依次展开p.__proto__、Person.prototype、Person.prototype.__proto__看到它们是同一个对象的不同引用时你会瞬间通透。原型链带来最直接的价值是属性共享。假设你要创建 1000 个弹窗实例每个弹窗都有 show 方法。这个方法如果写在构造函数内部1000 个实例就会各自生成一个函数内存里躺着 1000 份功能完全相同的代码。挂在 prototype 上1000 个实例共享一个 show内存开销小得多而且以后想给所有实例打补丁只需要改一处。function Modal(title) { this.title title; this.element document.createElement(div); } Modal.prototype.show function () { this.element.style.display block; };对实例而言show 不是它自己的属性它是沿__proto__找到的。这也解释了为什么修改Modal.prototype.show之后所有新老实例的行为会立刻同步变化——它们又不持有副本访问时永远走的是这条链。3.2 属性遮蔽找到了就不继续往上走原型链的查找规则是“就近原则”。实例自身存在同名属性引擎就直接返回实例上的值根本不会再去原型上找。这个过程叫 shadowing中文一般翻译成遮蔽。function Person() {} Person.prototype.getType function () { return person; }; const p new Person(); p.getType function () { return custom; }; console.log(p.getType()); // custom如果删掉 p 自己的 getTypedelete p.getType; console.log(p.getType()); // person一旦自身属性被删除引擎又找不到它了于是下降一层去原型里找。这个机制在调试时非常有用看到某个实例的方法行为诡异可以先把实例自身属性 delete 掉看它是不是回到了原型方法的默认行为。判断属性是实例自身还是来自原型用hasOwnProperty。这个方法本身也在 Object.prototype 上但它已经不是重点重点是你知道链上所有对象都有hasOwnProperty可用。console.log(p.hasOwnProperty(getType)); // false还有一个相关的点for...in会把原型链上可枚举的属性一起遍历出来而Object.keys只列出自身属性。ES5 以前写遍历经常要在循环里套一层hasOwnProperty过滤现在有了Object.keys和数组遍历方法很多场景都不需要手动过滤了。但这个差异在面试或代码审查时依然高频出现。3.3 链的尽头为什么是 null原型链走到Object.prototype就差不多完了——Object.prototype是所有普通对象默认的祖先它的__proto__是null。既然吐 null说明这个对象没有“创造它的函数”或者说它本身就是 Language 最底层的存在。JavaScript 里绝大部分对象都能调用toString、hasOwnProperty、valueOf这些方法就是因为它们在 Object.prototype 上。你创建了一个对象哪怕是个空对象字面量它的原型链里必然挂着 Object.prototype 这一环于是这些方法天然可用。如果不想让对象继承 Object.prototype可以显式指定原型为 nullconst bareObj Object.create(null); console.log(bareObj.toString); // undefined这种“裸对象”通常用来做纯字典不会有任何原型副作用也正因为没有toString等方法用起来时要注意判断方式。这一点后面第 5 章有专门一节展开。4. 继承的几种经典写法与 class 语法糖4.1 原型链继承最直接坑也最多ES5 时代继承主要靠操作原型链。最粗暴的写法是这样function Parent() { this.list [1, 2]; } Parent.prototype.getList function () { return this.list; }; function Child(name) { this.name name; } Child.prototype new Parent(); const c1 new Child(a); const c2 new Child(b); c1.list.push(3); console.log(c2.list); // [1, 2, 3]c2 的 list 被污染了这是原型链继承最大的硬伤。原因很简单Child.prototype被整体替换成了new Parent()这个实例于是 c1 和 c2 的__proto__都指向这个同一个 Parent 实例对象。它们在自身找不到 list就到公共原型上找到了同一个数组改一个等于改所有。除此之外这种写法还有一个问题无法给 Parent 传参。Parent 的构造函数在new Parent()那一刻已经执行子类实例真正创建时你没有任何机会把参数传给 Parent。所以原型链继承往往只作为概念演示实际项目里很少直接单用它。4.2 借用构造函数与组合继承为了解决属性独立的问题可以把 Parent 当作普通函数在 Child 里 call 一下function Parent(name) { this.name name; this.list []; } Parent.prototype.getName function () { return this.name; }; function Child(name, age) { Parent.call(this, name); // 每个子实例都初始化一份自己的 list this.age age; } // 方法还是要走原型链 Child.prototype new Parent();这种模式叫组合继承用 call 借用构造函数解决属性共享问题用原型链继承解决方法复用问题。但它有个缺陷——Parent构造函数被调用了两次。一次是Parent.call(this, name)一次是new Parent()生成 Child.prototype。第二次调用产生的 name、list 属性放在 Child.prototype 上但子类实例根本不会访问到它们属于白干。更优的解法是寄生组合继承function inherit(Child, Parent) { Child.prototype Object.create(Parent.prototype); Child.prototype.constructor Child; } inherit(Child, Parent);Object.create(Parent.prototype)创建了一个新对象它顺利连接到了 Parent.prototype但不会去执行 Parent 构造函数所以不会产生多余的实例属性。之后再把 constructor 指回 Child。这就是 ES5 时代公认最优的继承方式后来 ES6 的 class 底层也跑不出来这个套路。如果你想看见 Object.create 的真面目它其实内部就是借了个临时构造函数function objectCreate(proto) { function F() {} F.prototype proto; return new F(); }一个空函数 F把 F.prototype 指向目标原型然后 new 一下。依赖的无非还是那条老朋友实例.__proto__ 构造函数.prototype。4.3 class 与 extends语法糖但也是底层同样的链ES6 的 class 让写法好看许多但底层仍然是原型链。一个 class 里的普通方法会被挂到类.prototype上静态方法会被挂到类构造函数自己身上。class Parent { constructor(name) { this.name name; } getName() { return this.name; } static make() { return new Parent(默认); } } class Child extends Parent { constructor(name, age) { super(name); // 本质上是 Parent.call(this, name) this.age age; } }注意 class 比 ES5 多了一个机制静态方法也是可以继承的也就是说Child.make()能正常工作。原因是引擎在创建子类构造函数时会把子类构造函数的__proto__指向父类构造函数。这就等于函数对象之间也存在一条原型链子类构造函数自己没有静态方法就沿这条链去父类构造函数上找。super 的机制略微复杂在子类构造函数里调用 super 之前还不能使用 this因为引擎此刻还没确定 this 的真实原型归属。这也是为什么你写 class extends 时必须先调用 super更何况 JavaScript 引擎会严格检查这一点漏了就直接抛错。整个 extends 的转译结果就是寄生组合继承。对比一下三种写法的继承效果方式属性共享问题能否传参是否多余执行父构造推荐度原型链继承有否是不推荐组合继承无是是可用寄生组合继承无是否推荐class extends无是否日常首选5. Function、Object 和原型链的微妙关系5.1 Function.prototype 的自指现象这一节是很多 JS 学习者最容易懵的地方。脚本一旦有人盯着Function.__proto__ Function.prototype发呆说明他正在被“鸡生蛋蛋生鸡”问题困住。先拉一组关系表表达式值Object.__proto__Function.prototypeFunction.__proto__Function.prototypeFunction.prototype.__proto__Object.prototypeObject.prototype.__proto__nullFunction是一个函数对象函数对象也是“由 Function 构造”的所以它的__proto__指向Function.prototype。Object也是一个函数所以Object.__proto__同样指向Function.prototype。而Function.prototype本身是一个函数它就是那个“空函数”函数的内部原型又指向Object.prototype于是整张网就串了起来。至于“谁先出现”这类问题不需要在 JS 层面钻牛角尖Object 和 Function 是引擎启动时就内置的原生对象它们的内部实现处于更底层JS 语言规范只说好了它们之间的关系让 JS 代码可以一致性访问。你只要把那张表记下来面试基本题和手写题都能应付。5.2 instanceof 到底在查什么obj instanceof Constructor的语义是Constructor.prototype 是否出现在 obj 的原型链上。它并不关心 obj 是不是真的由 Constructor new 出来的。哪怕你是用 Object.create 手工串联出来的对象只要链上带上了这个 prototypeinstanceof 就会返回 true。手写一遍就清楚了function instanceOf(left, right) { const proto right.prototype; let current Object.getPrototypeOf(left); while (current ! null) { if (current proto) return true; current Object.getPrototypeOf(current); } return false; }等待循环退出的条件就是当前原型为 null又一次印证了原型链的终点。instanceof 也不是坚不可摧的。ES6 之后构造函数的Symbol.hasInstance这个静态方法定义了instanceof的默认行为而且允许自定义。比如可以手动改写class MyArray { static [Symbol.hasInstance](instance) { return Array.isArray(instance); } } console.log([] instanceof MyArray); // true这只是一个有趣的冷知识生产环境基本不鼓励这么玩。但了解它你就知道 instanceof 不是单纯的“类型判断”它背后是一次原型链遍历加上一个可插拔的钩子。5.3 没有原型的对象Object.create(null)上一节讲过Object.create(null)会得到一个裸对象。这种对象连__proto__都没有原型链的第一级就是 null。它在项目里最常见的用途是做纯字典或 Map 的替代品。实际踩坑的地方在于裸对象没有hasOwnProperty、没有toString。你如果习惯写map.hasOwnProperty(key)就会直接报错。安全写法是把 Object.prototype 上的方法拿出来借用const map Object.create(null); map.title 文章; // 正确 Object.prototype.hasOwnProperty.call(map, title); // 不要这样写会直接抛错 // map.hasOwnProperty(title);为什么有些公共库特别爱用裸对象因为普通对象字面量继承自 Object.prototype会让它的原型链上带着__proto__、constructor等一堆属性。如果拿客户端传来的 JSON 数据当非正则遍历对象使用某些恶意 key 可能触发原型链属性覆盖带来安全隐患。裸对象从根上切断了这些风险。6. 实战中容易翻车的地方6.1 整体重写 prototype 丢掉了 constructor我自己刚接触原型那两年干过一件错事为了给某个旧组件扩展方法直接整个替换了它的 prototype。function Modal(title) { this.title title; } Modal.prototype.show function () { /* ... */ }; Modal.prototype.destroy function () { /* ... */ }; // 手滑了直接整体重写 Modal.prototype { show: function () { console.log(show); }, destroy: function () { console.log(destroy); } };问题有两处。第一旧实例的__proto__还指向旧的 Modal.prototype新实例则指向新对象新旧实例行为不一致第二新 prototype 对象没有 constructor 字段new Modal().constructor会沿着链找到Object而不是 Modal。团队里如果有人拿constructor做类型判断就会得到一堆 undefined 或错误结果。正确姿势是只改单个方法别整对象替换Modal.prototype.show function () { console.log(show with log); };如果真有必要整体重写补上 constructorModal.prototype { constructor: Modal, show: function () { /* ... */ } };6.2 扩展内置原型前想想后果给Array.prototype或Object.prototype加方法是新手阶段特别爱干的事因为真的很方便。比如给数组加一个containsArray.prototype.contains function (item) { return this.indexOf(item) ! -1; };这个方法本身没什么问题但如果某个依赖库内部用for...in遍历数组它会把数组所有的可枚举属性连方法一起遍历出来导致出现完全无头绪的 bug。即使不依赖 for...in将来团队里另一个同事也往 Array.prototype 加同名但语义不同的方法你们的代码会神仙打架。在这方面我踩过的坑是某个老旧的第三方库遍历配置数组时多处理了几个“多余”的方法表现出的现象是表格多渲染出几行空数据。排查了半天才发现是原型扩展的锅。如果实在要扩展尽量用Object.defineProperty把属性设为不可枚举或者直接用 Symbol 作为方法名Object.defineProperty(Array.prototype, contains, { value: function (item) { return this.indexOf(item) ! -1; }, enumerable: false, configurable: true, writable: true });但说到底我的建议是不要改内置原型。如果只是你自己项目内部用写成一个工具函数放在模块里收益和风险不成比例。6.3 顺着原型链排查一个线上 bug有一次线上详情页报item.show is not a function。我第一反应是找 item 是通过哪个工厂函数创建的再去翻那个工厂函数所属类的方法定义。结果发现 show 明明定义在基类的 prototype 上。之所以拿不到是因为某个后期维护者在扩展子类时把子类的 prototype 整体替换了一份新的对象。新对象的原型链没有接上基类的 prototype基类提供的 show 自然就不在 item 的查找链路上了。整个排查过程靠的就是三个动作先console.dir(item)看它原型链的展开形态再Object.getPrototypeOf(item)手动拿到它的上一级原型最后对比item.__proto__ 某构造函数.prototype这个公式是否成立。如果两三层找不到你期待的方法大概就是某个环节断了链。这时候要去看是不是有代码整体重写过 prototype或者中间某个原型的__proto__被篡改过。这段经验后来被我总结成一个小技巧遇到任何“对象方法丢失”的报错第一步不是打开源码而是把对象打出来看原型链。很多时候问题根源根本不在业务代码里而在某段看似无关的继承改写里。最后分享一个对我自己帮助很大的心得——理解原型链最好的方法不是死背关系图而是把 fakeNew、Object.create、寄生组合继承这三段代码各手写三遍。第一遍照葫芦画瓢第二遍边写边说每行在做什么第三遍合上资料直接默写。写完你会发现自己对 JavaScript 对象模型的直觉上了一个台阶。遇到再刁钻的原型链问题临时创建两个对象、几行代码试一下答案往往自己就浮出来了。