14 mins
关于 ES5 构造函数与 ES6 类的那些疑问

本文源于学习中的疑问:ES6类体为啥不能直接写 this. 方法?ES5 构造函数和 ES6 类的核心差异是什么?ES6 类继承 ES5 构造函数可行吗?通过底层逻辑拆解、代码实测和正确写法总结,帮你彻底理清概念,避开开发中的常见坑,掌握类与构造函数的正确使用姿势。

ES6提供的class语法糖h2

es6中,相比于之前es5的工厂模式和构造函数方式,提供了面向对象语言都该有的一种语法糖class类,使用constructor方法定义构造函数,使用this关键字定义实例属性,使用extends关键字实现继承。

回忆构造函数的写法:

function createPerson(name, age) {
this.name = name
this.age = age
this.running = function () {
console.log('running')
}
}

回顾一下,在es5的构造函数中,this在“函数执行阶段”发生了什么?

当我们写new createPerson('小王', 18)时,js内部会创建一个新对象{},接着把this指向这个对象,逐行执行函数体里的代码,返回这个对象。

所以,this.running = function () {}是运行时赋值,每 new 一次就创建一个新函数(性能一般)

开始尝试使用class来定义类h3

我们来尝试的一种低级错误的写法:

class Person1{
constructor(name,age){
this.name = name;
this.age = age;
}
this.running(){
console.log('running');
}
}

代码根本跑不起来,会直接报错SyntaxError: Unexpected token '.',因为class 体里不能直接写 this.xxx

// class的花括号内,只允许写“方法定义”,不允许写“执行语句”
// class 体里允许的东西:
class A {
constructor() {}
foo() {}
static bar() {}
get value() {}
set value(v) {}
this.x = 1 // ❌
console.log() // ❌
if (true) {} // ❌
running: function(){console.log('running');} // ❌
}

所以我们得铭记,这样的写法比如this.running = ...,js会认为你在类定义阶段执行代码,这是不被允许的

有以下两种正确写法:

// 这最贴近我们学es5的理解,this在此处明确指向新创建的Person1实例,赋值行为是给实例挂载属性或方法,完全符合es6类的语法规则。
class Person1 {
constructor(name, age) {
this.name = name
this.age = age
// this.running = function(){} 写在 constructor 内,执行时机是:当用 new Person1() 创建实例时(运行时),而非 类定义阶段(解析时);
this.running = function () {
console.log('running')
}
}
}

这种写法等价于之前写的es5构造函数写法。

或者

class Person2 {
constructor(name, age) {
this.name = name
this.age = age
}
running() {
console.log('running')
}
}

这样写,来分析一下发生了什么:首先constructor,相当于实例初始化逻辑(运行时),而running()是定义在Person.prototype上,等价于es5中:

Person.prototype.running = function () {
console.log('running')
}
function Person(name, age) {
this.name = name
this.age = age
}
Person.prototype.running = function () {
console.log('running')
}

既然说类体的核心规则是:顶层只能写方法定义,如running(){},但写在constructor内部是“实例初始化逻辑区”,允许任意赋值或者执行代码,那么就可以说明写在constructor内部的这种running是实例的自有属性,而非原型属性,并且每个实例的running应该是独立的函数(因为每个实例的running应该是一个函数,而不是同一个函数)

我们来验证:

const p1 = new Person1('小王', 18)
const p2 = new Person2('小李', 17)
// 1. running 是实例的“自有属性”(而非原型属性)
console.log(p1.hasOwnProperty('running')) // true
console.log(Person1.prototype.running) // undefined
// 2. 每个实例的 running 是独立函数
console.log(p1.running === p2.running) // false

从性能角度思考h3

现在我们从内存性能角度思考,不难发现,这个写法的核心代价是内存占用更高:若创建100个Person1实例,就会生成100个独立的running函数(每个函数占用独立内存空间);

对比原型方法(Person2的写法):所有实例共享原型上的1个running函数,内存效率远高于此。

难道把方法挂载到实例上一无是处h3

虽然内存效率低,不过,并不能说Person1这种写法一无是处,什么时候什么场景下这个写法不可替代呢

需要每个实例的方法有 “独立状态 或者 逻辑”:

// 示例:每个实例的 running 记录自己的调用次数(独立状态)
class Person1 {
constructor(name, age) {
this.name = name
this.age = age
let callCount = 0 // 实例独有的闭包变量
this.running = function () {
callCount++
console.log(`${this.name} 跑步中,已调用 ${callCount} 次`)
}
}
}
const p1 = new Person1('张三', 20)
const p2 = new Person1('李四', 21)
p1.running() // 张三 跑步中,已调用 1 次
p1.running() // 张三 跑步中,已调用 2 次
p2.running() // 李四 跑步中,已调用 1 次(不受 p1 影响)

这个场景中,方法内部依赖“实例独有的闭包变量”(如上例的 callCount),需要重写某个实例的running方法,且不影响其他实例;方法逻辑需要和具体实例强绑定(比如每个实例的方法有不同的参数默认值)。

如何理解呢?为什么let声明的变量,在函数闭包内能记住上一个调用的值?

让我们一步一步拆解,首先先搞懂“每次 new Person1 时发生了什么”

当你执行 new Person1('张三', 20) 时,js会做这些事:

  1. 创建一个空的新对象(比如 {}),这个对象就是 p1
  2. 执行 Person1constructor 函数,此时 constructor 的执行上下文(可以理解为 “临时的运行环境”)被激活;
  3. constructor 的执行上下文中:
    • this 绑定到新对象 p1,所以 this.name = '张三' 就是给 p1 加属性;
    • 声明 let callCount = 0:这个变量属于 “本次 constructor 执行上下文” 的私有变量,不是 p1 的属性,只在这次 constructor 执行时存在;
    • 创建 running 函数,并且这个函数引用了当前上下文的 callCount 变量;
    • running 函数赋值给 p1.running(相当于把这个函数挂载到 p1 上);
  4. constructor 执行完毕,但因为 p1.running 函数还引用着 callCount,所以 js 不会回收这个 callCount 变量 —— 这就是闭包的核心:内部函数引用外部函数的变量,外部函数执行完后,变量不会被销毁,而是被内部函数 保留

其次,搞懂“为什么调用 p1.running() 能记住 callCount 的值”?

  • 第一次调用 p1.running():函数内部找到它引用的 callCount(初始值 0),执行 callCount++ 变成 1,然后打印;
  • 第二次调用 p1.running():函数还是找它绑定的那个 callCount(现在是 1),再 ++ 变成 2,打印;
  • p2 = new Person1('李四', 21) 时,会重新执行一次 constructor,生成全新的执行上下文,里面有全新的 callCount = 0,以及全新的 running 函数(引用这个新的 callCount)—— 所以 p2.running() 操作的是自己的 callCount,和 p1 完全无关。

用比喻理解更直观

把每个 new Person1() 比作 “开一个独立的小房间”:

  • constructor 执行 等于 装修这个房间;
  • callCount = 0 等于 房间里放了一个只有你能看到的计数器(不是挂在门上的,别人看不到);
  • running 函数 等于 房间里的一个按钮,按一下计数器就 + 1,并且按钮和这个房间的计数器绑定死了;
  • p1 等于 这个房间的钥匙,你拿着钥匙(调用 p1.running())按按钮,只会改这个房间的计数器;
  • p2 等于 另一个新房间,有自己的计数器和按钮,按 p2 的按钮,不会影响 p1 的房间。

小结h3

如下这张表格将实例独有方法和原型方法做一个对比:

维度Person1(实例独有方法)Person2(原型方法)
挂载位置实例自身类的 prototype 上
内存占用高(每个实例独立函数)低(所有实例共享)
独立状态支持(闭包 / 独立变量)不支持(共享逻辑)
适用场景实例独有逻辑通用共享逻辑

构造函数和类的核心差异h2

es5中,构造函数本质上就是普通函数,如果忘了写new,它会作为一个普通的函数执行,this可能会意外指向全局对象window,导致严重污染。

但是在es6中class,这种行为完全被禁止了,为了安全,类必须使用new调用,否则会抛出错误。

// ES5
function Person(name) {
this.name = name
}
const p = Person('小王') // 不报错,但 window.name 变成了 '小王',p 是 undefined
// ES6
class Person {
constructor(name) {
this.name = name
}
}
const p = Person('小王')
// ❌ TypeError: Class constructor Person cannot be invoked without 'new'

第二个差异就是es6不存在变量提升,es5中的函数声明会被提升到作用域顶部,这就意味着你可以“先使用,后定义”

但是es6的class和let、const一样,你必须先定义,先定义类,才能实例化它,这有助于让代码逻辑更清晰

// ES5:可行
const p = new Person('小王');
function Person(name) { ... }
// ES6:报错
const p = new Person('小王'); // ❌ ReferenceError
class Person { ... }

还有一个一个非常底层但重要的细节,在es5中,我们在原型上定义的方法,默认是可枚举的,这就意味着如果我们使用for...in遍历实例,这些方法会被遍历出来

而在es6中,定义在类体内的所有方法(除了constructor),默认都是不可枚举的。这更符合我们要把数据(属性)和行为(方法)分离的思维,通常我们遍历对象只想拿数据,不想拿到一堆方法。

// ES5
function Person5() {}
Person5.prototype.running = function () {}
// ES6
class Person6 {
running() {}
}
const p5 = new Person5()
const p6 = new Person6()
// 验证
console.log(Object.keys(Person5.prototype)) // ['running'] -> 可枚举
console.log(Object.keys(Person6.prototype)) // [] -> 不可枚举!

类继承构造函数可行吗h2

// ES5 构造函数
function createPerson1(name, age) {
this.name = name
this.age = age
this.running = function () {
console.log('running')
}
}
// ES6 类继承 ES5 构造函数
class Student extends createPerson1 {}
// 实例化并调用方法
const student1 = new Student('小王', 18)
student1.running() // 输出:running

这段代码能正常运行,为什么可行

es6的class extends并非只能继承es6类,而是可以继承“任意能通过new调用的构造函数”,核心原因有两点:

  • 类的默认构造函数自动调用super:当class Student没有显式定义constructor时,js会自动生成默认构造函数
class Student extends createPerson1 {
constructor(...args) {
super(...args) // 关键:调用父类构造函数 createPerson1
}
}

super(...args)等价于createPerson1.call(student1实例, '小王', 18),此时 createPerson1 内部的 this 会绑定到 Student 的实例,因此 this.name/this.age/this.running 都会挂载到 student1 上,最终 student1.running() 能正常执行。

  • es5构造函数满足extends的继承条件:extends 对被继承的对象只有一个核心要求:能通过 new 调用(即符合构造函数的规范)。createPerson1 作为 es5 构造函数,只要通过 new 调用,就能为子类实例挂载属性或方法,因此能被 class 继承。

但这是语法兼容带来的结果,并非推荐的写法,它存在内存效率低、语法风格割裂等隐藏问题。

Comments