JavaScript中的Class在提升(Hoisting)与暂时性死区(Temporal Dead Zone, TDZ)行为上,与传统的函数声明和var声明存在显著区别。本文将深入剖析Class内部各类声明机制的细节,揭示其在变量提升和访问时机上的独特表现。

一、Class声明本身的特性

Class声明存在不完全提升,并具有暂时性死区:

// 错误:Cannot access 'Person' before initialization
const p = new Person(); 

class Person {
  constructor(name) {
    this.name = name;
  }
}
  • Class声明会被提升到作用域顶部,但不能在声明前使用(与let/const类似)。

  • 声明前的区域称为Class的"暂时性死区",访问会抛出ReferenceError。

二、Class内部的变量声明

Class内部(包括构造函数、方法中)使 let/const 声明的变量存在暂时性死区,但不会提升:

class Person {
  constructor() {
    console.log(age); // 错误:Cannot access 'age' before initialization
    let age = 18;
  }
}
  • 与普通函数中的 let/const 行为一致。

  • 必须先声明再使用。

三、Class内部的函数(方法)声明

Class内部的方法声明不会提升(不能在类定义前调用类方法或创建实例)。

类方法不遵循普通函数的提升机制。但这并不影响它们在构造函数中的调用,因为类方法的初始化时机(原型方法在类定义时,实例字段在构造函数前)确保了它们在构造函数执行时已经可用。

  • 传统原型方法:在类定义时已添加到原型上,构造函数执行时可通过原型链访问。

  • 实例字段方法:在构造函数执行前已完成初始化。

  • 普通函数的提升:可以在函数声明前调用函数。

类中的方法分为两种类型,它们的行为有所不同:

3.1 传统原型方法(类方法声明)
class Person {
  constructor() {
    this.sayHello(); // 可以正常调用
  }
  sayHello() { // 传统方法声明
    console.log("Hello");
  }
}

实际行为

  • 类方法(如 sayHello )会在类定义时被添加到类的原型(Person.prototype)上。

  • 当使用new Person() 创建实例时:

    1. 先创建新对象并建立原型链(此时原型方法已存在)。

    2. 再执行构造函数(因此可以通过 this 调用原型方法)。

  • 类方法的声明顺序不影响构造函数中的调用,因为所有原型方法都在构造函数执行前完成初始化。

3.2 实例字段方法(箭头函数或函数赋值)
class Person {
  constructor() {
    this.sayGoodbye(); // 可以正常调用
  }
  sayGoodbye = () => { // 箭头函数作为实例字段
    console.log("Goodbye");
  }
}

实际行为

  • 实例字段(包括箭头函数)会在 构造函数执行前 被初始化。

  • 因此,无论声明顺序如何,都可以在构造函数中通过 this 调用。

四、Class的属性声明

ES6+中,Class支持类字段声明(Class Field Declarations),包括实例属性和静态属性:

class Person {
  // 实例属性(ES2022+)
  age = 18;
  
  // 静态属性(ES2022+)
  static species = 'Human';
  
  constructor() {
    console.log(this.age); // 18(正确:属性已初始化)
    console.log(Person.species); // Human(正确:静态属性已初始化)
  }
}

  • 类字段(包括实例属性和静态属性)不会提升。

  • 它们在构造函数执行前初始化,因此可以在构造函数中直接访问。

  • 使用等号=赋值的类字段不存在暂时性死区。

五、与传统函数的对比总结

下面是一个传统函数提升示例:

// 普通函数声明会被提升
hello(); // 可以正常执行,输出 "Hello"
function hello() { console.log("Hello"); }

// var变量声明会被提升,但赋值不会
console.log(x); // 输出 undefined
var x = 5;

六、关于提升

要理解 JavaScript 中 Class 声明的 "不完全提升",我们需要先明确:提升(Hoisting)的基本概念,再对比不同声明方式的提升行为。

6.1 提升的基本概念

提升 是 JavaScript 引擎的一种编译机制:在代码执行前,引擎会将变量和函数声明"移动"到其作用域的顶部。但不同类型的声明,提升的 程度和行为 完全不同。

6.2 三种声明的提升对比

为了理解 Class 的"不完全提升",我们将其与另外两种典型声明(var 和 let/const)进行对比:

声明类型

提升程度

初始化状态

声明前访问

var

完成提升

初始化为 undefined

返回 undefined (不报错)

let/const

不完全提升

未初始化(TDZ)

抛出 ReferenceError

class

不完全提升

未初始化(TDZ)

抛出 ReferenceError

6.3 抛出 ReferenceError

Class 声明的"不完全提升"可以拆解为两个关键特征:

  1. 「声明被提升」但「未被初始化」

    • 声明被提升:引擎在编译阶段会识别 Class 声明,并将其标识符(类名)添加到当前作用域中,避免了"未定义标识符"的错误。

    • 未被初始化:提升后,Class 不会像 var 那样被初始化为 undefined,而是处于一种"等待初始化"的状态。

  2. 存在「暂时性死区(TDZ)」
    Class 声明前的区域称为 暂时性死区,在这个区域内访问 Class 会抛出 ReferenceError。

// Class 声明前的区域:暂时性死区
console.log(Person); // 错误:Cannot access 'Person' before initialization
const p = new Person(); // 错误:Cannot access 'Person' before initialization

class Person { // Class 声明语句(初始化点)
  constructor() {}
}

// Class 声明后的区域:可用
const p = new Person(); // 正确
6.4 为什么设计成"不完全提升"?

JavaScript 引擎将 Class 设计为"不完全提升",主要是为了:

  • 保持代码可读性:避免开发者在声明前使用 Class,导致逻辑混乱。

  • 确保初始化顺序:Class 可能依赖其他 Class 或变量,强制声明后使用可以避免依赖错误。

  • 与 let / const 保持一致:Class 声明的行为与块级作用域变量(let/const)保持一致,减少语言的复杂性。

下面通过代码对比,更清晰地理解 Class的提升行为:

Class 与 var 的对比示例
// var 的完全提升
console.log(name); // undefined(不报错)
var name = "水月";

// Class 的不完全提升  
console.log(Person); // ReferenceError(报错)
class Person {}
Class 与 let 的相似性
// let 的不完全提升
console.log(age); // ReferenceError: age is not defined
let age = 18;

// Class 的不完全提升
console.log(Person); // ReferenceError: Person is not defined 
class Person {}
6.5 "不完全提升"的核心

Class 声明的"不完全提升"可以简单理解为:

  • 声明被识别,但不能在声明语句执行前使用。

  • 引擎知道 Class 标识符的存在(已提升)。

  • 但在声明语句执行前,Class 无法被访问或实例化(未初始化,处于 TDZ)。

总结

  1. Class声明:存在不完全提升和暂时性死区,声明前不可使用。

  2. Class内部方法:无提升,但声明顺序不影响调用顺序。类方法的可用性取决于类的初始化阶段(类定义时或实例创建时),与声明顺序无关。

  3. Class字段:无提升,但在构造函数执行前初始化,可在构造函数中直接访问。

  4. Class内部变量:使用 let/const 声明时,遵循与普通作用域相同的暂时性死区规则。

理解:类方法的可用性取决于类的初始化阶段(类定义时或实例创建时),与声明顺序无关。

这句话需要从类方法的两种类型JavaScript类的初始化机制两个维度来理解,先回顾上面提到的两种类型的类方法:

1. 传统原型方法(格式:method() { ... })

  • 语法: 直接在类内部定义的方法。

  • 初始化时机:在类定义时,(即 class Person { ... } 这段代码执行时)进行初始化。

  • 存储位置:类的原型对象( Person.prototype )上。

2. 箭头函数作为实例字段( 格式:method = () => { ... } )

  • 语法:使用赋值语法定义的箭头函数。

  • 初始化时机:在实例创建时、构造函数执行前,(即 new Person() 时)。

  • 存储位置:每个类实例对象本身(不是原型)。

“类方法的可用性取决于类的初始化阶段”的具体含义

类方法的“可用”状态,取决于它们是否已经被 初始化到正确的位置(原型或实例),而这又与类的两个关键初始化阶段相关:

阶段1:类定义时(Class Definition Phase)

当JavaScript引擎执行 class Person { ... } 这段代码时:

  • 会创建类的构造函数。

  • 会将所有传统原型方法(如 sayHello() )添加到原型 Person.prototype 上。

  • 此时,类的原型已经包含了所有传统方法,这些方法对所有实例“潜在可用”。

阶段2:实例创建时(Instance Creation Phase)

当使用 new Person() 创建实例时:

  • 先创建一个新的空对象(实例)。

  • 建立原型链(将实例的 __proto__ 指向 Person.prototype )。

  • 初始化所有实例字段(包括箭头函数作为实例字段)。

  • 最后执行构造函数体(constructor() { ... })。

  • 此时,实例对象本身包含了所有箭头函数方法,这些方法对当前实例“实际可用”。

“与声明顺序无关”的原因

类内部方法的 声明顺序不影响初始化时机,因此也不影响它们在构造函数中的可用性。

传统原型方法:声明顺序无关

所有传统原型方法都在 类定义时被同时添加到原型上,无论它们在类中声明的先后顺序如何。因此,在构造函数中可以随意调用这些方法。

class Person {
  constructor() {
    this.sayGoodbye(); // 后声明的方法(可用,因为已在类定义时添加到原型)
    this.sayHello();   // 先声明的方法(可用,同样已添加到原型)
  }
  
  sayHello() { console.log('Hello'); }
  sayGoodbye() { console.log('Goodbye'); }
}

箭头函数作为实例字段:声明顺序影响初始化顺序,但不影响可用性

箭头函数作为实例字段的 初始化顺序与声明顺序一致 ,但由于它们都在 构造函数执行前 完成初始化,因此在构造函数中可以随意调用,不受声明顺序影响。

class Person {
  constructor() {
    this.method3(); // 最后声明的箭头函数(可用,因为已在构造函数前初始化)
    this.method1(); // 先声明的箭头函数(可用,同样已初始化)
  }
  
  method1 = () => console.log('Method 1');
  method2 = () => console.log('Method 2');
  method3 = () => console.log('Method 3');
}

关键要点

  1. 类定义时:传统原型方法被添加到类的原型上。

  2. 实例创建时、构造函数执行前:箭头函数作为实例字段被初始化到实例对象上。

  3. 构造函数执行时:所有类方法(无论是传统方法还是箭头函数)都已经可用,因此可以按任意顺序调用。

  4. 声明顺序无关:因为所有方法的初始化都在构造函数执行前完成,与它们在类中声明的先后顺序无关。

最终理解

这句话的核心是:类方法的可用性不取决于它们在类中的书写顺序,而是取决于JavaScript类的初始化机制——传统方法在类定义时就绪,箭头函数在实例创建时就绪,两者都在构造函数执行前完成初始化,因此可以在构造函数中随意调用。