从 ES5 的构造函数一路写到 ES6 的类,很多人第一反应就是“这不就是换个写法的事吗?”。我在带前端小组的时候,几乎每次聊到原型链都会被问到同一句话:“既然 class 底层还是构造函数和原型链,那它到底比 ES5 高级在哪?”这个问题看起来基础,但真能讲清楚的人不多。这篇文章我不打算堆术语,准备用大白话配合代码,把两者的区别、原理、坑和选型经验一次说透。无论你是准备面试,还是手头项目正在从老代码往 ES6 迁移,都值得看完。
1. 先看本质:两者都是在和原型链打交道
1.1 构造函数的真实身份是“普通函数 + prototype”
ES5 时代没有“类”这个概念,我们把一个普通函数和new关键字配合使用,它就变成了构造函数。比如这样写:
function Person(name) { this.name = name; } Person.prototype.sayHello = function () { console.log('Hello, I am ' + this.name); }; const alice = new Person('Alice'); alice.sayHello(); // Hello, I am Alicenew调用它们时,引擎偷偷做了几件事:创建一个新对象,把这个对象的原型指向Person.prototype,然后把函数体内的this绑定到这个新对象上,最后执行函数体。如果函数里没有显式返回对象,就自动把新对象返回出来。这也是为什么构造函数里习惯用this.xxx = ...来添加属性,而所有实例共享的方法都挂到prototype上,避免每个实例都复制一份函数,浪费内存。
这里有个关键点:Person本身就是一个普通函数,你可以直接Person('Alice')去调用它,不报错。问题在于这种调用方式下,this在非严格模式中指向全局对象,你等于在全局绑了一堆垃圾属性,项目里出现莫名变量就是从这里开始的。严格模式下this是undefined,直接给undefined加属性会立刻抛TypeError,反而更容易暴露问题。
1.2 ES6 的 class 不是“语法糖”那么简单
很多人说“class 只是构造函数的语法糖”,这话只对了一半。它的底层确实还是构造函数 + 原型链,控制台里typeof Person打出来依然是function,但它比普通构造函数多了很多“行为约束”。例如类只能通过new调用,直接调用会报错;类里面的方法默认不可枚举;类体内的所有代码都运行在严格模式下。这些约束不是为了炫技,而是为了把开发者容易踩的坑提前堵上。
用一个生活化类比:ES5 构造函数像你拿钥匙坯自己手动配钥匙,技术好了能配出花来,但手一抖就废了;ES6 的 class 像一把标准钥匙模板,你能动手的地方更少,但每一把出来形状基本稳定。灵活性和安全性的取舍,这两者一直都在。
1.3 为什么理解这些底层差异比背语法更重要
如果只记class怎么写,遇到老代码里的function风格还是只会用Object.create(Super.prototype)去模仿继承,你会一直觉得 ES6 类“玄学”。反过来,如果只懂 ES5 而完全忽略 class 的行为约束,你会写出把类当函数调用的低级 bug。真正要理解的是:无论哪种写法,最后都在操作prototype和this,区别只是在语法层做了多少保护和规定。
2. 语法对照:长相各有千秋,运行原理其实同源
2.1 定义方式:函数声明 vs class 声明
ES5 构造函数可以用两种形式:函数声明和函数表达式。
function Person(name) { this.name = name; } const Person = function(name) { this.name = name; };ES6 的类也有类声明和类表达式,但写法更受限:
class Person { constructor(name) { this.name = name; } } const Person = class { constructor(name) { this.name = name; } };还有一个很实用的小差异:函数声明会提升,可以在定义之前使用;而class声明的行为更像let、const,存在暂时性死区,必须在声明之后才能使用。我遇到过同事把类的定义写在调用后面,结果浏览器报ReferenceError: Cannot access 'Person' before initialization,这个问题放到构造函数身上就不会出现。如果你新写代码时必须要靠“类提升”来解耦,那大概率是结构没组织好,建议把类定义放到使用它的模块之前。
2.2 调用方式:一个能直接调,一个必须 new
这是两者最直观的区别。构造函数可以当成普通函数调用:
function Person(name) { this.name = name; } // 直接调用,不报错,但 this 会出问题 Person('Alice');而类直接调用会立刻报错:
class Animal { constructor(name) { this.name = name; } } Animal('dog'); // TypeError: Class constructor Animal cannot be invoked without 'new'这个限制意味着类的使用姿势是确定的:必须用new。有人觉得这不自由,但在团队协作里,这种“不自由”其实是好事。你看到class Animal,就知道所有实例都要通过new Animal(...)创建,不必担心有同事拿它去当工具函数用。ES5 时代你只能靠命名规范,比如大写首字母来提醒别人“这是构造函数”,可这种约定在紧急情况下很容易被忽略。
2.3 属性和方法:prototype 手动添加 vs 类体直接声明
ES5 在构造函数里用this.xxx声明实例属性,用构造函数.prototype.yyy = ...声明共享方法。ES6 类则把属性和方法统一收进类体:
function Person(name) { this.name = name; // 实例属性 } Person.prototype.sayHello = function () { ... }; class Person2 { constructor(name) { this.name = name; // 实例属性 } sayHello() { ... } // 共享方法,等价于 Person2.prototype.sayHello }看起来只是整理了一下格式,但实际有个重要区别:ES5 中通过Person.prototype添加的方法是“可枚举”的,也就是说用for...in遍历实例时,会遍历到这些方法;而 ES6 类里面定义的方法默认是“不可枚举”的,for...in不会出现它们。以前我写扩展库的时候,遍历对象属性,莫名其妙多出一堆原型方法,每次都要手动判断hasOwnProperty。换了 class 以后,这种噪音少了,遍历更干净。
需要注意的是,类中的方法不能当构造函数使用。const sayHello = person.sayHello; new sayHello()会报TypeError。这是因为类中的方法没有自己的prototype属性,它的行为更像一种“不可构造的函数”。这一点和 ES5 的普通函数不同。
2.4 静态成员与实例成员:static 关键字
ES5 里添加静态方法,通常直接在构造函数上加属性:
function Person(name) { this.name = name; } Person.create = function(name) { return new Person(name); }; Person.create('Bob'); // 静态方法,实例访问不到ES6 提供了更语义化写法:
class Person { constructor(name) { this.name = name; } static create(name) { return new Person(name); } }两种写法的本质是一样的,静态方法都是挂在类构造函数本身而不是prototype上,所以实例访问不到。类的写法把静态方法和普通方法放在同一块区域,可读性好了很多。如果你去看 ES5 老代码,静态方法往往散落在构造函数定义周围,维护起来容易漏。
3. 继承的方式:从手写原型链到 extends 方案
3.1 ES5 继承为什么绕来绕去?
简单粗暴的继承可以写:Child.prototype = Parent.prototype,但这会导致子类和父类共享同一个原型对象,子类加方法时父类也会被污染,所以很快就被抛弃。常见组合继承是这样写的:
function Animal(name) { this.name = name; } Animal.prototype.getName = function () { return this.name; }; function Dog(name, breed) { Animal.call(this, name); // 第一次调用父构造函数 this.breed = breed; } Dog.prototype = Object.create(Animal.prototype); // 第二次“接触”父类原型 Dog.prototype.constructor = Dog; Dog.prototype.bark = function () { console.log('Woof! My breed is ' + this.breed); }; const dog = new Dog('Buddy', 'Husky');这段代码里有三个细节特别容易踩坑。第一,为什么用Object.create(Animal.prototype)而不是new Animal()?因为前者只复制原型关系,不会执行父类构造函数,避免了父类实例属性被放在子类原型上;后者会把name这类属性也挂到子类原型上,导致子类的所有实例共享一份name,怎么改都会互相影响。第二,为什么要手动设置Dog.prototype.constructor = Dog?因为Object.create得到的新对象没有.constructor指向,如果你不修正,用dog.constructor拿到的会是Animal,很多依赖构造器判断的库会出错。第三,Animal.call(this, name)的作用是借用父类构造函数里的初始化逻辑,相当于主动把父类的实例属性复制到子类实例上。
这种写法可以说是“能用,但费脑”,而且没有彻底解决调用两次父类的问题。工程实践里还得再包一层寄生组合式,才勉强接近 class 继承的效果。
3.2 ES6 的 extends 和 super 是什么魔法?
同样两个类,ES6 写法干净得多:
class Animal { constructor(name) { this.name = name; } getName() { return this.name; } } class Dog extends Animal { constructor(name, breed) { super(name); // 调用父类构造函数 this.breed = breed; } bark() { ... } }extends的继承范围比 ES5 的手工作法更全面:既继承Animal.prototype上的方法,又继承Animal本身的静态成员。这等于一次性完成了“原型继承 + 借用构造函数 + 静态属性和方法继承”三件事。而super也不只是个语法关键字,它会在子类构造函数中帮你完成父类实例属性的初始化。
这里有一个硬性规则:如果子类自定义了constructor,那么必须在constructor内第一行调用super(...),否则访问this会报错。在子类中,this要先等父类构造函数执行完并初始化完实例,才能在子类里继续操作。你可以理解为:父类负责把包工头先叫来把地基打好,子类再进场做装潢。ES5 的组合继承靠Animal.call(this, name)手动打地基,ES6 则强制要求super(name),漏掉就编译不过去,从根上防止了顺序错误。
3.3 不写 constructor 的默认行为
如果子类不写constructor,ES6 会自动生成一个constructor(...args) { super(...args); },把传给子类的参数全部传给父类。所以你能看到很多类继承写法里,子类只是扩展方法而完全不碰构造逻辑。ES5 里你不写继承担心逻辑,还得手动Child.prototype = Object.create(Parent.prototype),问题更多。
extends还能继承原生构造函数,例如让一个类继承Array:
class MyArray extends Array { last() { return this[this.length - 1]; } } const arr = new MyArray(1, 2, 3); console.log(arr.last()); // 3这在 ES5 时代几乎无法可靠实现,因为原生对象内部操作不依赖普通的this初始化流程。所以extends不是简化了组合继承,而是扩展出了新能力。
4. 行为细节:容易忽略的坑,也是面试最爱问的点
4.1 变量提升与暂时性死区
前面提过,构造函数天生有函数提升,类声明没有提升。比较一下:
console.log(typeof Person); // 'function' function Person() {}console.log(typeof Person); // ReferenceError: Cannot access 'Person' before initialization class Person {}原因是类声明和let、const一样,被绑定在了词法环境中,进入作用域后直到执行到声明语句之前,都处于“暂时性死区”。这段区域内访问类名,引擎不让你用。这个差异直接影响你的编码习惯:类的使用必须严格遵循“先声明后使用”,没法像函数声明那样到处乱放。
4.2 类体内默认启用严格模式
ES6 规范规定类体和模块内部默认是严格模式。所以你在类里写this指向undefined的代码,使用未声明变量等,都会比普通函数更快暴露问题。ES5 构造函数如果你忘了写'use strict',在非严格模式下直接调用Person('Alice'),this.name会在全局上创建一个属性,等到排查 bug 时一脸懵。类天然帮你规避了这种低级错误,这是很多新手没注意到的隐形优势。
4.3 方法枚举性、不可构造性与类方法的特殊标签
用一段代码演示枚举差异:
function Person(name) { this.name = name; } Person.prototype.sayHello = function () {}; class Person2 { constructor(name) { this.name = name; } sayHello() {} } const p1 = new Person('a'); for (const key in p1) console.log(key); // name, sayHellofor...in会从p1的原型链上找到sayHello,因为它是可枚举的。而类方法的enumerable默认是false,for...in只会打印name。另外,类中的方法没有prototype属性,不能作为构造器。这意味着你不会把一个类方法误当成构造函数去new,一定程度上减少了“方法被new后悄悄给全局加字段”的奇怪路由。
4.4 this 绑定的问题并没有因为 class 而消失
无论 ES5 还是 ES6,方法里的this都遵循“谁调用它指向谁”的规则。把实例方法拿出来单独交给事件回调时,this就会丢失。
class Counter { constructor() { this.count = 0; } inc() { this.count++; } } const counter = new Counter(); setTimeout(counter.inc, 100); // this 不再指向 counter我过去在 React 老项目里频繁踩这个坑,解决方案无非三种:回调里包一层箭头函数() => counter.inc(),手动counter.inc.bind(counter),或者用 class 字段 + 箭头函数。ES5 构造函数里也可以用bind,但没人会把这些当成“ES6 与构造函数”的区别,因为这是 JavaScript 函数机制本身的通病。想真正解决 this 问题,要么坚持用箭头函数绑定到词法作用域,要么每次显式 bind,不要把希望寄托在 class 自己会修 bug。
5. 实战选型:不迷信 class,也别做老顽固
5.1 什么时候我还会用 ES5 构造函数
别急着全面换掉老写法。以下几种场景我依然用构造函数:
- 需要兼容非常老旧的环境,而且没有 Babel/TypeScript 做转译,class 语法直接跑不起来;
- 维护十几年的老项目,代码风格统一是 functionality 风格,为了一个类破坏全局一致性不划算;
- 想构造一个“既可以 new 又可以直接调用”的函数。例如某些工具库采用
function create() {}的写法,它希望调用者不用new也能操作; - 底层框架已经在用
new.target、prototype做复杂元编程,再套一层 class 反而碍事。
ES5 构造函数的生命力和“灵活”是绑在一起的,它允许你直接在prototype上操作,很多老库就是这么设计的。如果你对原型链的拿捏不够熟练,强行用 class 反而发现功能不够用。
5.2 什么时候 class 明显更合适
新项目、现代浏览器(或完整配置了转译工具链)中,我基本都会优先 class。理由很实际:
- 语义清晰,团队的人一看到
class就知道这里是一个抽象数据类型; - 天然支持
static、getter、setter和私有字段,代码更结构化; - 可以继承
Array、Error等内置对象,扩展能力更强; - 类方法不可枚举,
for...in和对象合并时更安全; - 默认严格模式 + 必须
new,拦截了很多低级错误。
我自己在实现一个自定义错误类时,通常会写:
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; this.name = 'HttpError'; } }这在 ES5 里要同时处理Error.call(this)和原型链修正,代码冗长还容易有兼容性问题。跟着现代标准走,写起来舒服,排查问题也简单。
5.3 一个场景两种写法对比:计数器
直接上一段抽象对比。ES5:
function Counter() { this.count = 0; } Counter.prototype.increment = function () { this.count++; }; Counter.prototype.getCount = function () { return this.count; };ES6:
class Counter { constructor() { this.count = 0; } increment() { this.count++; } getCount() { return this.count; } }可读性上的差距一目了然:类把方法和属性放在一个“盒子”里,阅读顺序是按声明从上到下,而 ES5 的prototype方法散落在后面,需要跳着看。对于长期维护的代码,类的组织方式更符合人类认知。
5.4 别把类当成万能银弹
class 仍然没有接口和抽象类的概念,如果你需要“多继承”,class 也做不到(只能通过 mixin 或组合)。JavaScript 本身的 mixin 模式并不复杂,但用 class 实现起来要拼管道写法,工程上我会更推荐用普通函数组合替代。另外,class 的方法天然不能作为构造器,但如果你想要“既是抽象类型又能调用”的复杂对象,function 仍然是唯一路径。选型时多考虑业务场景,不要因为“ES6 比较新”就无脑用。
6. 常见报错与排查技巧实录
6.1 Class constructor cannot be invoked without 'new'
这是把类当函数调用时最常见的报错。我一般在两种场景遇到:
- 直接把类引用塞给事件回调:
button.onClick = MyClass,用户点击时引擎用普通函数方式调用; - 忘了在类名后面加括号:
new MyClass写成了MyClass()。
排查方法很简单:确认调用位置是否带了new。如果是在回调里需要绑类实例,应该写成() => new MyClass()或function() { new MyClass(); }。
6.2 构造函数不 new 引发全局污染
老代码中有人直接调用构造函数:
function Person(name) { this.name = name; } const p = Person('Alice'); // 非严格模式下 this 指向 window console.log(window.name); // Alice排查这种 bug 最直接的方式是在构造函数头部加'use strict',或者检查new.target:
function Person(name) { if (!new.target) { throw new Error('Person must be called with new'); } this.name = name; }ES6 类自带这个保护性检查,这也是我推荐新代码用类的原因之一。
6.3 子类 constructor 中 super 的顺序错误
写继承时最容易犯这个错:
class Child extends Parent { constructor() { this.age = 1; // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor super(); } }必须先super()再操作this,这是硬规则,没有商量。我还见过在if分支里调用super()导致某些路径没触发super的案例,这种代码会直接报运行时错误。如果你需要条件初始化,务必保证所有分支都调用super(),尤其不要在return之前漏掉。
6.4 方法 this 丢失后怎么快速定位
特征非常明显:方法里的数据变成undefined,或报Cannot read properties of undefined。遇到这种情况,先看调用现场是不是把方法从实例上剥下来再调用的。我自己常用的三个修法:
onClick() { console.log(this.count); } // 方式1:箭头函数类字段 onClick = () => { console.log(this.count); }; // 方式2:bind constructor() { this.onClick = this.onClick.bind(this); } // 方式3:调用处包裹 <button onClick={() => this.onClick()} />需要注意的是,方式1 的类字段不是 ES6 原生,而是 ES2022 标准,转译工具(Babel/TypeScript)基本能处理,但在老环境里要配好 polyfill。方式2 在 ES5 和 ES6 里都通用。方式3 最直观,但是每次渲染都会创建新函数,对性能极致敏感的场景需要留意。
6.5 排查小工具:用 Object.getOwnPropertyNames 观察差异
如果你在调试某个对象“为什么多出方法”,可以用Object.getOwnPropertyNames和Object.getOwnPropertySymbols查看自身属性,甚至在getOwnPropertyDescriptor里检查enumerable。对比构造函数和类的实例,你会立刻看到枚举性的不同。这类技巧虽然冷门,但在排查“for...in 多出原型方法”的老问题时非常有效。
7. 最后再聊几句经验
带团队这几年,我对 ES5 构造函数和 ES6 类的态度其实经历过一次反转。最开始觉得class很高级,天天用;后来翻旧项目时被各种prototype写法折磨,觉得“还是老函数灵活”;再到现在,写新代码我基本首选class,但随时准备回归原型链去理解底层问题。个人体会是,这两者本质上是同一套原型机制的不同包装形式,真正决定代码质量的不是“用了哪个关键字”,而是你有没有彻底理解this、prototype、super三个概念。
如果你正站在学习路口,我的建议是:先从 ES5 构造函数入手,把new的流程、原型链的查找规则、组合继承的痛点逐个搞清,再切到 ES6 类,你会发现class里的每条限制都有它存在的理由。反过来先学class再补原型链会非常别扭。面试时如果有人问你“ES6 类真的是语法糖吗”,你不妨回答:它是基于原型系统的语法糖,但同时也是加了安全约束的语法糖,它牺牲了灵活性,换来了普通业务代码中更少的低级错误。
最后分享一个小技巧:在代码里同时保留两种写法时,用Object.getPrototypeOf(instance)这个 API 去观察实例的真正原型,它能让你在一行代码里看清构造函数的原型链全貌,比死记硬背很多文字结论快得多。希望这篇长文能帮你少踩几个坑。