
从 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 的默认行为如果子类不写constructorES6 会自动生成一个constructor(...args) { super(...args); }把传给子类的参数全部传给父类。所以你能看到很多类继承写法里子类只是扩展方法而完全不碰构造逻辑。ES5 里你不写继承担心逻辑还得手动Child.prototype Object.create(Parent.prototype)问题更多。extends还能继承原生构造函数例如让一个类继承Arrayclass 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默认是falsefor...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 一个场景两种写法对比计数器直接上一段抽象对比。ES5function Counter() { this.count 0; } Counter.prototype.increment function () { this.count; }; Counter.prototype.getCount function () { return this.count; };ES6class 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.targetfunction 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); }; // 方式2bind 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 去观察实例的真正原型它能让你在一行代码里看清构造函数的原型链全貌比死记硬背很多文字结论快得多。希望这篇长文能帮你少踩几个坑。