JavaScript构造函数与Class底层机制全解析:从new到原型链

发布时间:2026/9/24 22:40:41
JavaScript构造函数与Class底层机制全解析:从new到原型链
先说个我观察到的现象很多写了两年以上JavaScript的人被问到“Class和构造函数到底什么关系”时也只能说出“Class是语法糖”这一句话。再追问一句“糖在哪儿、编译产物是什么、super和原型链怎么串起来的”基本就卡住了。这其实很正常因为日常业务里很少有人关心new和class的底层实现但只要涉及组件封装、框架源码阅读、复杂继承设计这块知识的含金量立刻就会体现出来。这篇文章我打算把JavaScript构造函数和Class从底层机制到实战场景完整拆一遍涵盖new的执行过程、Class的编译产物、extends与super的运行链路、私有字段和静态成员的设计意图以及项目里最常见的this绑定坑。不管你写原生JS还是React/Vue组件哪怕只是准备面试都能在里面找到能用上的东西。1. 为什么还要重新认识构造函数从new的机械动作说起1.1 new关键字在执行时到底做了什么构造函数的核心触发点是new但new这个关键字在JavaScript里做的事情远比表面复杂。它不是一个简单的“调用函数”而是执行了一套完整的对象装配流程。以function Person(name)为例当你写const p new Person(张三)时引擎实际做了四件事创建一个全新的空对象把这个空对象的内部原型[[Prototype]]指向构造函数的prototype属性将构造函数内部的this绑定到该新对象上并执行函数体如果构造函数没有显式返回对象则返回这个新对象。第四步有个非常容易忽略的细节如果构造函数内部返回了原始值比如数字、字符串、布尔值这个返回值会被直接忽略最终返回的仍然是新创建的this对象。但如果返回的是一个对象那么new表达式的结果就是那个对象而不是this。这意味着一个构造函数可以通过返回对象来彻底覆盖常规的实例化行为这种模式可以用在对象池、单例等场景但也容易在不知情的情况下引发诡异问题。为了加深理解可以用一个手动实现的_new函数模拟整个过程。这不是面试题专用技巧它确实能帮你明确“new到底接管了哪些事情”function _new(Constructor, ...args) { // 第一步创建空对象并链接原型 const obj Object.create(Constructor.prototype); // 第二步绑定this并执行构造函数 const result Constructor.apply(obj, args); // 第三步根据返回值类型决定返回什么 return (typeof result object result ! null) || typeof result function ? result : obj; }这个实现里最关键的是Object.create(Constructor.prototype)。它建立了实例和构造函数之间的原型关联这种关联就是后续所有“方法共享”“属性查找”的基础。new对函数还有一个额外要求函数内部的this必须指向新对象而普通调用Person()时this指向全局对象严格模式下是undefined构造函数就会失去意义。1.2 prototype、__proto__和constructor的三角关系很多人在原型链这里绕不清楚根因是没分清两个长相相似但职责完全不同的属性。prototype是构造函数这个“函数对象”上的显式属性它只有在函数被当作构造函数调用时才有意义承载的是将来所有实例共享的方法和默认属性。__proto__则是每个对象都有的内部属性它表示“我这个对象是从谁那里继承来的”指向创建我的那个构造函数的prototype。比如function Person() {}Person.prototype是一个对象而p.__proto__指向的也是这个对象。因此p.__proto__ Person.prototype成立。实例可以直接调用Person.prototype上的方法是因为当访问p.sayHello时引擎先在p自身属性里找找不到就顺着p.__proto__往上找再找不到就继续沿着__proto__.__proto__查找直到null为止。这个查找链就是原型链。constructor属性是被误解最多的角色。Person.prototype.constructor默认指向Person本身但这个属性并不是“某个对象由哪个构造函数创建”的可靠标记因为它可以被改写。很多库在给prototype批量挂方法时会直接覆盖整个prototype对象导致constructor丢失此时p.constructor就会顺着原型链跑到Object上。写扩展代码时如果依赖constructor做类型判断这类问题会直接引爆所以更稳定的做法是用instanceof它的原理是检查构造函数.prototype是否出现在实例的原型链上而Object.getPrototypeOf(p) Person.prototype是更底层的直接验证。理解这三者的关系后你再看任何关于“构造函数和Class”的讨论会发现它们的底层模型完全一致Class只是换了一层更规整的皮。2. Class语法糖拆解它被编译后长什么样2.1 一个最简单的class被翻译成什么ES6引入class后JavaScript的面向对象写法从“函数原型手工拼接”变成了“类声明方法声明”简洁度提升了一个量级。但无论语法多优雅底层仍然是那个原型模型。来看一个标准例子class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name} 发出声音); } static create() { return new Animal(默认动物); } }这段代码用ES5风格手写大致等价于function Animal(name) { this.name name; } Animal.prototype.speak function () { console.log(this.name 发出声音); }; Animal.create function () { return new Animal(默认动物); };从这段映射关系能提炼出一个核心规律class里定义的普通方法编译后都是挂到ClassName.prototype上的不是挂到实例本身的。这意味着所有实例共享同一份方法内存占用小也符合“方法是公共行为”的语义。而constructor里的赋值语句编译后依然是构造函数体内给this挂属性的逻辑这些属性才是每个实例独有的。但class不是简单的包装它增加了几个ES5手写方式难以模仿的硬性约束。第一class声明具有暂时性死区不能在声明之前使用即便用typeof也不行而function声明是会提升的。第二class内部默认启用严格模式未声明变量直接赋值会抛ReferenceError。第三class只有通过new调用才会成功直接Animal()会抛TypeError“Class constructor Animal cannot be invoked without new”这是ES5构造函数做不到的。2.2 原型上的方法为什么不可枚举有一个实验很有趣在ES5手写版本里Object.keys(Animal.prototype)会得到[speak]因为speak是通过赋值语句添加的默认可枚举。而在Class版本里Object.keys返回空数组Object.getOwnPropertyNames(Animal.prototype)才能看到[constructor, speak]。这是因为class语法把所有方法都定义成不可枚举了。这个设计决策很务实。for...in会遍历实例的枚举属性以及原型链上的枚举属性如果class里的方法默认可枚举那么for (const key in obj)就会不断把方法名带出来增加无意义的干扰。SDK设计、组件封装中尤其需要这种干净可预测的属性枚举结果所以语言层面强制了不可枚举。配合这个方法挂载位置的问题可以做一个三路对照帮助记忆声明位置/方式归属对象是否可共享典型用途constructor中的this.xxx赋值实例自身否实例独有状态class普通方法ClassName.prototype是实例公共行为static方法ClassName本身是通过类访问工厂方法、工具方法这个表格看起来简单却是后面理解继承和super的重要基础。如果你能把任意一个class快速翻译成functionprototype的操作再复杂的类结构在你眼里都会变得透明。3. 继承的底层逻辑extends和super的完整执行链路3.1 extends在原型链上做了一次双线挂接class的继承不是只在实例这一层做文章它在两个维度同时建立了关联。以class Dog extends Animal为例JavaScript引擎实际上是做了两个setPrototypeOf操作Object.setPrototypeOf(Dog.prototype, Animal.prototype); Object.setPrototypeOf(Dog, Animal);第一个操作解决的是“Dog实例能共享Animal.prototype上的方法”。dog.speak()查找时没在Dog.prototype上找到就会继续沿着原型链找到Animal.prototype。第二个操作解决的是“Dog类能访问Animal类的静态方法”因为Dog本身作为一个函数对象其内部原型也被指向了Animal。这两条链接缺一不可也是class继承和ES5时代Child.prototype new Parent()那种继承方式的本质差异——ES5的做法会通过创建一个父类实例来搭桥导致父类构造函数被先执行一次而class的写法不实例化父类直接做原型挂接语义更纯净。实际写继承代码时大多数情况只需要关心extends关键字它会自动完成上面两条链的搭建。真正需要手动处理的是super的调用顺序问题。3.2 super为什么必须在使用this之前调用这条规则每个写React class组件的老手都背过但理解其底层原因的人不多。当一个构造函数声明为派生类构造函数时this的初始化权被移交给了父类。子类构造函数体内的this一开始处于“未初始化”状态你不能读也不能写直到执行super(...args)父类构造函数才基于参数完成对this的初始化之后子类才能继续给this挂自己的属性。换一种更容易理解的表达派生类并没有创建过一个全新的对象来给自己用实例对象在父类构造过程中就已经存在了super调用本质上是“借用父类的初始化逻辑来设置这个this”。所以super()必须放在子类构造函数最前面不是风格要求而是引擎的硬性检查。如果你的子类构造函数里完全没有调用super那么在使用this或return时引擎都会抛错。还有一层更隐蔽的细节在派生类中即便你不写constructor引擎也会自动生成一个默认的constructor(...args) { super(...args); }。所以一个没有任何constructor的派生类仍然可以正确初始化父类状态。而基类没有继承时默认constructor是constructor() {}这也解释了为什么基类可以不显式定义constructor。3.3 super.method()与new.target的行为异同super除了作为函数调用初始化this之外还可以作为对象使用例如调用super.speak()或super.getName()。这里的super指向父类的prototype但有一个关键点当通过super.speak()调用父类方法时父类方法内部的this并不指向父类实例而是指向当前的子类实例。这一点特别重要它保证了多态性。如果父类方法内部还调用了另一个父类方法而这个父类方法被子类重写了那么这次调用会走到子类的重写版本而不是父类版本。这正是事件冒泡、生命周期钩子等机制能在框架中生效的根本原因。new.target的机制在继承中同样不可忽视。new.target在普通函数调用中为undefined在new调用中指向当前实际执行构造的构造函数。在继承场景下即便你在父类构造函数里判断new.target它也可能是子类构造函数。这个特性让父类可以检测到自己被哪个类实例化常用于实现抽象类约束在基类构造函数中判断new.target 基类则抛错从而保证基类不能被直接实例化。这比ES5里的约定式处理要可靠得多因为new.target是语言层面的能力。4. 高级特性背后的设计意图static、私有字段与访问器4.1 静态成员和实例成员的分工逻辑static方法挂在构造函数上不属于任何实例所以它不能直接访问实例属性自然也不能依赖this来共享状态。它的定位是“与类相关的工具逻辑”或“与实例无关的创建逻辑”。一个很常见的例子就是配合继承实现工厂模式父类定义static create()内部用new this()创建实例子类继承后调用子类的create()时new this()中的this指向子类从而实现“同一个工厂方法产出不同类型的实例”。不过有一个经常被忽略的坑static方法里的this跟随调用者走如果你把静态方法从类上解构出来再单独调用this就变了。这与普通函数完全一致。静态属性在ES2022之前没有通用写法只能在class外部通过ClassName.prop value添加现在class体内可以直接写static count 0语义更清晰且定义在类构造器上。我把“实例属性、实例方法、静态属性、静态方法”这四类成员整理成一个快速判断表这样写代码时对照着甄别明显清晰很多成员类型写法归属访问方式何时使用实例属性constructor内this.x 值或类字段实例实例属性实例.x每个对象独立状态实例方法class中的普通方法原型实例.method()共享行为逻辑静态属性static x 值构造函数Class.x类级常量/缓存静态方法static method() {}构造函数Class.method()工厂方法/工具方法4.2 #私有字段为什么不是语法糖传统JavaScript实现“私有”依赖两条约定一是属性名前缀下划线二是文档约定不要碰。这两条都不具备强制性任何运行时代码都能读公共API很容易被误用。ES2022的#私有字段改变了这件事。它的访问限制是引擎级硬约束外部直接obj.#secret会抛SyntaxError甚至obj[#secret]也读不到。这个约束不能通过Object.keys、反射等方式绕过因为私有字段根本不存放在常规属性列表里。从实现角度看私有字段有点类似于WeakMap方案但语言层面做了内存和可预测性优化。它在声明时就固定了实例的字段布局实例创建后不能增删这带来两个优势V8等引擎可以为这类对象做形状优化访问速度接近普通属性开发者在设计类时被迫把状态清单想清楚而不是随手往this上挂东西。私有字段的继承规则也值得注意。父类的私有字段子类不仅访问不到甚至同名私有字段定义也是允许的。因为每个类都有自己独立的私有命名空间子类里写#x和父类里的#x是两个完全不同的变量互不干扰。这种设计避免了很多“父类改了一行代码导致子类私有状态损坏”的问题但也要求开发者在基类中通过受保护的方法暴露必要的私有状态操作入口。4.3 访问器要当成“行为”而不是“属性”getter和setter在class中常被当作“代理属性”使用但它本质上是行为。getter在读取属性时拦截setter在赋值时验证或转换数据。比如一个组件类内部维护#volume通过get volume()对外暴露set volume(v)里做范围裁剪防止音量超出合理区间。这种封装能避免在业务层到处写if判断。但访问器有个容易被忽视的特性它和普通数据属性不能共存。如果一个类的原型上定义了gettersize同时实例上想直接赋值this.size 100如果没有定义setter严格模式下会直接抛错非严格模式下静默失败。这是很多class组件动态增加字段时踩坑的地方。对于纯数据容器类例如DTO、表单模型引入getter/setter其实提高不了太多安全性反而增加样板代码但如果你写的是约束严格的实体模型或组件封装访问器能显著降低非法状态出现的概率。5. 实战中绕不开的坑this指向、绑定与组件化场景5.1 class方法this丢失的根因class方法挂在原型上这一特性直接导致了this丢失问题。普通函数调用方式下this由调用点决定。obj.method()调用时this指向obj但const fn obj.method; fn()调用时fn内部的this是undefined严格模式或全局对象非严格模式。class内默认严格模式所以this会是undefined。React开发中常见的“事件处理函数里拿不到组件实例”本质上就是这个原因onClick{this.handleClick}没有通过对象调用方法被当作独立函数传入了。错误可以复现到这样class Counter { constructor() { this.count 0; } increment() { this.count; } } const c new Counter(); const fn c.increment; fn(); // TypeError: Cannot read properties of undefined (reading count)这里的c.increment是原型链上的方法取出后如果没人以c为接收者调用this就不会自动绑定回c。5.2 常见的三种绑定方案怎么选解决this丢失有三个常规方案各自的适用场景差别很大。第一种是在constructor里用bind绑定实例方法class Counter { constructor() { this.count 0; this.increment this.increment.bind(this); } increment() { this.count; } }这种方案在React类组件时代最主流。优点是可读性好构造时一次性完成绑定之后无论怎么解构调用都稳定缺点是每个实例都有自己的一份绑定函数无法直接放到prototype上共享内存开销高一点点。第二种是类字段加箭头函数class Counter { count 0; increment () { this.count; }; }箭头函数没有自己的this定义时捕获了外层this而类字段初始化的时机在构造函数内部所以这里的this就是实例本身。这种写法的绑定行为最直觉也不需要在构造函数里手动bind代码更简洁。但它同样会让每个实例拥有独立的函数对象且不能被subclass通过super.increment()优雅覆盖所以在基类定义生命周期方法时请慎用。第三种是调用处包一层箭头函数例如React里写onClick{() this.increment()}。这样this的绑定发生在调用处保底正确也不会生成额外实例方法但每次渲染都会重新创建箭头函数如果子组件做了浅比较优化这个新函数会导致子组件重复渲染。三种方案没有绝对优劣我个人的实践准则是需要重写和继承的公共方法放原型上用bind方案组件内部事件处理直接用类字段箭头函数调用处的包装函数留给需要临时传参的场景。5.3 构造函数的return返回对象会造成什么影响前面提到new在第四步会根据返回类型决定结果这里展开讲实战影响。如果一个构造函数明确返回了对象那么new表达式的结果就是该对象实例上的原型链接不再指向构造函数的prototype。这在工具库中偶尔被用来实现对象池或多例约束但业务代码里出现这种情况几乎总是bug来源。尤其是重构老代码时别人在构造函数末尾加了一个return一个临时对象会导致所有实例方法瞬间失效因为拿到的对象根本不是你的类实例。排查这类问题有一个快速技巧Object.getPrototypeOf(instance) ! ClassName.prototype时说明实例身份已经不对了。Class语法的构造函数同样允许返回对象因此这个坑在class中依然是存在的。设计类时我建议保持构造函数尽量不做重计算、不返回覆盖对象把初始化逻辑收敛到普通方法或静态工厂方法里会省掉很多意料之外的麻烦。6. 什么时候该用Class什么时候该放弃从项目架构角度看选择6.1 适合class的典型场景虽然现在函数式风格盛行但class并没有过时关键是选对场景。第一类是领域模型比如电商里的订单、商品、购物车有稳定的状态结构有明确的状态变更行为用class封装可以很好地保证数据完整性。第二类是组件/插件体系框架需要为开发者约定统一的初始化、销毁、事件绑定接口基类配合多态和生命周期钩子能提供很好的扩展点React类组件、Vue组件对象、Web Component都延续了这套思路。第三类是需要内置运算符或迭代协议、自定义原生API的场景class可以方便地继承内置构造器或定制Symbol接口。在这些场景下class的核心价值是“把行为和数据绑在一起并通过继承和多态减少重复”。如果业务逻辑天然有“一类事物”抽样的倾向不要因为潮流而强行改成散装函数。6.2 不适合class的几个信号反面场景同样明显。如果一个模块只包含一组无状态的工具函数例如日期格式化、字符串校验、数组转换class化没有任何收益。把这些函数声明为static方法只会增加调用链长度和测试成本直接导出普通函数反而简洁可控。第二种不适合的是纯数据映射对象比如接口返回的JSON结构映射这类数据结构通常用Object字面量或TypeScript的interface描述就够了如果硬套class反而引入序列化反序列化的麻烦。第三种是状态逻辑以闭包为核心、不需要被继承和复用的场景比如一个计数器、一个缓存池闭包变量天然私有函数导出就是最精简的封装。6.3 一个务实的判断标准我自己在项目里判断“要不要引入class”时只问三个问题。第一这个模块是否需要创建多个实例而且是带状态的实例第二将来是否会基于它做扩展是否有多态需求第三模块的生命周期是否清晰即它是否有明确的创建、运行、销毁阶段如果三个回答都是yesclass是不错的选择如果第一个为no大概率只用导出函数就够了如果第三个为no说明模块边界不明确class只会把混乱状态封装成更难排查的黑箱。还有一个很容易被忽视的点TypeScript对class和函数式的支持都非常好如果你的class只是用来做类型约束是冗余的。用interface加类型别名更能发挥TS的结构化类型优势。回到JavaScript构造函数和Class本身。你可以发现无论写得多优雅底层就是原型链和构造函数的这块老地基。把new的四步、class编译后的映射位置、extends的双链挂接、super的执行顺序想明白代码里的绝大多数类型和方向问题都能迎刃而解。我在带新人时最强调的也是这些底层机制而不是一堆花哨语法。毕竟语法永远在变一旦你真正理解了“实例、原型、构造函数”三者的关系不管语言层面叠加多少糖衣你都能一眼看穿它真正的运行时本质。