news 2026/9/24 22:40:16

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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快速翻译成function+prototype的操作,再复杂的类结构在你眼里都会变得透明。

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”时,只问三个问题。第一,这个模块是否需要创建多个实例,而且是带状态的实例?第二,将来是否会基于它做扩展,是否有多态需求?第三,模块的生命周期是否清晰,即它是否有明确的创建、运行、销毁阶段?如果三个回答都是yes,class是不错的选择;如果第一个为no,大概率只用导出函数就够了;如果第三个为no,说明模块边界不明确,class只会把混乱状态封装成更难排查的黑箱。

还有一个很容易被忽视的点:TypeScript对class和函数式的支持都非常好,如果你的class只是用来做类型约束,是冗余的。用interface加类型别名更能发挥TS的结构化类型优势。

回到JavaScript构造函数和Class本身。你可以发现,无论写得多优雅,底层就是原型链和构造函数的这块老地基。把new的四步、class编译后的映射位置、extends的双链挂接、super的执行顺序想明白,代码里的绝大多数类型和方向问题都能迎刃而解。我在带新人时最强调的也是这些底层机制,而不是一堆花哨语法。毕竟语法永远在变,一旦你真正理解了“实例、原型、构造函数”三者的关系,不管语言层面叠加多少糖衣,你都能一眼看穿它真正的运行时本质。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 22:40:11

Zerto Virtual Replication 容灾实战:从复制机制到故障切换演练

简介:这份PPT资料聚焦Zerto Virtual Replication虚拟化容灾解决方案,面向企业IT运维、灾备架构师及云计算从业者,帮助理解基于Hypervisor层的复制容灾思路。内容涵盖传统备份与存储复制的缺陷对比、VM级别保护与恢复、虚拟保护组、分钟级故障…

作者头像 李华
网站建设 2026/9/24 22:39:31

Python json.dumps实战:ensure_ascii与separators参数详解

1. 项目概述1.1 一句话搞懂这行代码在干什么先直接说结论,json.dumps(filter_dict, ensure_asciiFalse, separators(,, :))这行代码干的事就是:把一个 Python 字典filter_dict序列化成 JSON 格式的字符串,同时保证中文不被转义成\uXXXX&#…

作者头像 李华
网站建设 2026/9/24 22:39:01

图片批处理实战:用PS动作、脚本和命令行1分钟处理100张图

做设计这行,最折磨人的往往不是改需求,而是改完需求之后要重新导一遍图。上个月我接了一组电商换季素材,甲方甩过来120张商品图,要求统一裁成800800白底居中、右上角压统一角标、命名必须按货号来。这种活在很多人眼里属于“无脑重…

作者头像 李华
网站建设 2026/9/24 22:37:17

EMC传导发射与辐射发射的分界:30MHz背后的物理与工程逻辑

刚做EMC那两个月,我差点被CISPR 32里的频段划分给绕晕。传导发射明明写着150kHz到30MHz,辐射发射却又从30MHz起步,一路测到1GHz甚至6GHz。中间的30MHz就像一条精确的国境线,两边谁也不越界。当时我脑子里冒出一个很天真的问题&…

作者头像 李华
网站建设 2026/9/24 22:36:45

Java程序运行机制全解析:从字节码到JVM内存与垃圾回收

Java程序运行机制这个话题,说实话是每个Java开发绕不开的核心。不管是刚入门准备面试的新人,还是工作了几年想回头补基础的老手,只要想把这门语言吃透,就必须把这些机制弄明白。网上关于这块的文章不少,但大多是零散知…

作者头像 李华
网站建设 2026/9/24 22:36:40

Agent技能体系实战:从Function Calling到工程化编排

1. 为什么我要做一套 Agent 技能体系:从一次失败的项目复盘说起1.1 现象:模型会聊天,但不会干活的尴尬期去年我在做一个企业内部的知识库问答 Agent,最开始方案很朴素:把文档切好片、做向量召回、塞给大模型生成回答。…

作者头像 李华