1. 项目概述:从“看不懂的class”到真正理解JavaScript类的本质
“Comprendre les classes en JavaScript”——法语直译是“理解JavaScript中的类”。这看似是个基础语法点,但现实中,我见过太多写了三年JS的前端工程师,在被问到“
class
到底是不是新东西”时愣住;也见过不少刚学完ES6的新人,把
class
当成了Java或Python那种真正的面向对象语法糖,结果在调试原型链时一头雾水。其实,
JavaScript里根本没有“类”这个运行时概念,有的只是一套更清晰、更安全的构造函数+原型链封装语法
。关键词里的
ES6
、
prototypes
、
constructors
,恰恰就是解开这个谜题的三把钥匙。这篇文章不是教你怎么写
class A extends B {}
,而是带你亲手拆开它:看编译器怎么把它转成
function A() {}
,看
new
操作符背后如何串联起
[[Prototype]]
,看
static
方法为什么不能被实例调用,看
super()
在子类构造器里究竟做了什么底层委托。适合所有已经能写基础JS但对“为什么这样设计”仍有困惑的开发者——无论你是刚接触ES6的新手,还是想夯实底层的中级工程师,甚至是在React/Vue中频繁使用
class
组件却总被
this
绑定问题困扰的实战派。你不需要提前准备环境,也不需要安装任何工具;只需要带着一个浏览器控制台,跟着我把每一行代码背后的执行路径画出来。
2. 核心设计思路:为什么ES6要加一个“假类”?
2.1 本质不是新增功能,而是语法糖的终极形态
很多人以为ES6引入
class
是为了让JavaScript“变成真正的面向对象语言”,这是个根本性误解。JavaScript自诞生起就是基于原型(prototype-based)的语言,它的对象继承机制和Java/C++的类继承(class-based)有本质区别。
class
关键字本身不改变引擎的执行模型,它只是对已有模式的一次
语法层重构
。我们来看最典型的对比:
// ES5 构造函数写法
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHello = function() {
return `Hello, I'm ${this.name}`;
};
Person.prototype.getAge = function() {
return this.age;
};
// ES6 class 写法
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
return `Hello, I'm ${this.name}`;
}
getAge() {
return this.age;
}
}
表面看,
class
写法更紧凑、更接近其他语言习惯。但关键在于:
这两段代码在V8引擎里最终生成的内部结构几乎完全一致
。你可以用
Object.getOwnPropertyDescriptors(Person.prototype)
验证:
sayHello
和
getAge
都是不可枚举、不可配置、可写的普通方法;
constructor
属性指向
Person
函数本身;
__proto__
链都指向
Function.prototype
。也就是说,
class
没有引入新的对象模型,它只是把原来分散在函数体、
.prototype
赋值、
Object.defineProperty
调用中的逻辑,统一收束到一个声明块里。这种设计的好处是显而易见的:
-
可读性提升
:所有与
Person相关的定义集中在一处,避免了Person.prototype.xxx = function(){}的碎片化书写; -
安全性增强
:
class声明会自动启用严格模式(strict mode),禁止with语句、静默失败的赋值等危险操作; -
继承语法简化
:
extends+super()比手动设置Child.prototype = Object.create(Parent.prototype)更直观、更少出错。
提示:
class声明不会被提升(hoisting),这点和function声明不同。class Person {}必须在调用前定义,否则会抛出ReferenceError。这是刻意为之的设计——避免在类定义完成前就尝试实例化,导致this指向混乱。
2.2 为什么必须保留原型链?脱离原型的类毫无意义
如果
class
真的实现了“全新类系统”,那它就应该绕过原型链,直接提供类似Java的
Class
元对象。但JavaScript没有这么做,原因很现实:
向后兼容性压倒一切
。整个Web生态建立在原型链之上——jQuery的
$.fn
、Lodash的链式调用、Vue 2的响应式数据劫持、甚至浏览器原生API如
Array.prototype.map
,全部依赖
obj.__proto__
或
Object.getPrototypeOf(obj)
的可预测行为。一旦切断这条链,所有现有库都会崩溃。所以ES6的
class
必须是“原型链友好型语法糖”。我们来实测验证:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a noise.`;
}
}
const dog = new Animal('Dog');
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.getPrototypeOf(dog) === Animal.prototype); // true
看到没?
dog
的隐式原型(
__proto__
)依然指向
Animal.prototype
,而
Animal.prototype
的隐式原型又指向
Object.prototype
。这就是完整的原型链。
class
做的唯一“新事”,是确保
Animal.prototype.constructor
始终指向
Animal
函数(ES5中如果不小心覆盖了
prototype
,这个指向很容易丢失)。这种设计意味着:你依然可以用
Object.setPrototypeOf(dog, {})
强行修改原型,也可以用
dog instanceof Animal
做类型判断(其底层就是沿着
__proto__
链向上查找
Animal.prototype
),甚至可以像ES5一样用
Object.assign(Animal.prototype, { run() {}, jump() {} })
动态扩展方法。
class
没有封闭系统,它只是给开放系统装了个更优雅的外壳。
2.3 构造器(constructor)的不可替代性:初始化逻辑的唯一入口
constructor
方法在
class
中扮演着绝对核心的角色,但它绝非可有可无的“默认方法”。它是
实例化过程中唯一被
new
操作符自动调用的初始化钩子
。我们来深挖它的执行时机和约束:
-
调用时机不可变 :当你执行
new Animal('Cat')时,引擎会严格按以下顺序执行:-
创建一个空对象,其
[[Prototype]]指向Animal.prototype; -
将该对象作为
this上下文,调用Animal.prototype.constructor(即constructor方法); -
如果
constructor返回一个对象,则直接返回该对象;否则返回第一步创建的空对象。
-
创建一个空对象,其
-
必须显式调用
super():在子类constructor中,this关键字在调用super()之前是不可访问的。这是V8引擎的硬性限制,目的是防止在父类实例未构建完成时就操作this。例如:
class Mammal extends Animal {
constructor(name, furColor) {
// ❌ 报错:ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
console.log(this.name);
super(name);
this.furColor = furColor;
}
}
这个限制背后是内存模型的严谨性:
super()
不仅调用父类构造器,更重要的是
为
this
绑定正确的内部槽位(internal slots)
,比如
[[Realm]]
(执行环境)、
[[Prototype]]
等。跳过它会导致
this
处于未定义状态。
-
constructor不是必须写的 :如果你不写,JavaScript会自动插入一个空的constructor(...args) { super(...args); }。但要注意,这个默认构造器 只适用于无参数或单参数继承场景 。一旦父类构造器需要特定参数,你就必须手动实现constructor并正确传递。
注意:
constructor方法不能用async修饰,也不能是生成器(function*)。因为new操作符要求同步返回实例对象,异步构造器会破坏这一契约。
3. 核心细节解析:class声明里的每一个关键字都在做什么?
3.1
static
:属于类本身的“静态成员”,与实例彻底隔离
static
关键字常被误解为“和Java一样,属于类而不是实例”。这种说法没错,但容易忽略关键细节:
static
方法/属性存储在类函数对象自身上,而非其
prototype
上
。我们用内存布局图来说明:
Animal 函数对象(Function)
├── name: "Animal"
├── length: 1
├── prototype: Animal.prototype 对象(供实例继承)
│ ├── constructor: Animal
│ └── speak: function()
└── staticMethod: function() ← 这里!
└── staticProp: 42
验证代码:
class Animal {
static speciesCount = 0;
static count() {
return ++Animal.speciesCount;
}
constructor(name) {
this.name = name;
}
}
console.log(Animal.speciesCount); // 0 → 直接访问类自身属性
console.log(Animal.count()); // 1 → 直接调用类自身方法
console.log(Animal.prototype.speciesCount); // undefined → 不在prototype上
console.log(new Animal('Dog').speciesCount); // undefined → 实例无法访问
这个设计的深层逻辑是:
static
成员服务于
类级别的元操作
,比如工厂方法(
Date.now()
)、单例管理(
Math.random()
)、或工具函数(
Array.from()
)。它们不需要访问实例状态(
this
),因此不必参与原型链查找,直接挂载在函数对象上效率最高。这也是为什么
static
方法里
this
指向类本身(
Animal
),而不是实例——因为它根本不是为实例设计的。
3.2
extends
与
super()
:继承链的双保险机制
extends
看起来只是语法糖,但它背后有一套精密的继承链保障机制。我们分两层理解:
第一层:
extends
如何设置原型链?
class Child extends Parent {}
这行代码,等价于以下ES5操作:
// 1. 设置 Child.prototype.__proto__ = Parent.prototype
Object.setPrototypeOf(Child.prototype, Parent.prototype);
// 2. 设置 Child.__proto__ = Parent(让静态方法也能继承)
Object.setPrototypeOf(Child, Parent);
这意味着:
-
实例方法继承:
new Child().parentMethod()能调用,因为Child.prototype的__proto__指向Parent.prototype; -
静态方法继承:
Child.staticParentMethod()能调用,因为Child的__proto__指向Parent。
第二层:
super()
在构造器中的双重角色
在子类
constructor
中,
super()
不只是调用父类构造器,它还负责:
-
初始化
this的内部槽位 (如前所述); -
设置
this.__proto__为Child.prototype(注意:不是Parent.prototype!)。
这个细节至关重要。很多开发者以为
super()
后
this.__proto__
就等于
Parent.prototype
,其实不然:
class Parent {}
class Child extends Parent {}
const child = new Child();
console.log(child.__proto__ === Child.prototype); // true ← 关键!
console.log(Child.prototype.__proto__ === Parent.prototype); // true ← 继承链在这里
所以
child
的原型链是:
child
→
Child.prototype
→
Parent.prototype
→
Object.prototype
。
super()
确保了
this
被正确关联到
Child.prototype
,而
extends
确保了
Child.prototype
能向上找到
Parent.prototype
。这是两套独立但协同的机制。
3.3 字段声明(Field Declarations):从提案到标准的演进真相
你可能在Babel配置里见过
@babel/plugin-proposal-class-properties
,或者在TypeScript中写过
name = 'default';
。这其实是
TC39 Stage 3提案(Class Fields)
,并非ES6原始规范的一部分。它解决了ES5/ES6中长期存在的痛点:如何在构造器外声明实例属性?传统方案要么在
constructor
里写死(冗余),要么用
Object.defineProperty
(繁琐)。字段声明的语法糖让代码更简洁:
class Counter {
count = 0; // ✅ 实例字段,每次new都会初始化
#privateCount = 0; // ✅ 私有字段(#前缀,ES2022正式标准)
static defaultStep = 1; // ✅ 静态字段
increment() {
this.count += this.constructor.defaultStep;
}
}
但要注意:
字段声明的执行时机在
constructor
函数体执行之前
。V8引擎会先处理所有字段初始化,再进入
constructor
代码。这意味着:
class BadExample {
value = this.getValue(); // ❌ this还未绑定,报错!
constructor() {
// 此时value已尝试计算,但this未定义
}
getValue() { return 42; }
}
正确做法是把依赖
this
的初始化逻辑放在
constructor
里。字段声明只适合
无副作用的初始值设定
,比如
loading = false
、
items = []
这类纯数据。
实操心得:私有字段(
#name)是真正的语言级封装,无法通过obj['#name']或Reflect.ownKeys(obj)访问,连JSON.stringify都会忽略它。这比_name命名约定或Symbol私有属性更可靠,是现代JS封装的首选。
4. 实操过程:手写一个兼容ES5的class转译器
4.1 理解Babel转译原理:从AST到目标代码
Babel的核心工作流是:解析(Parse)→ 转换(Transform)→ 生成(Generate)。对于
class
,它会将AST中的
ClassDeclaration
节点,转换为ES5兼容的
FunctionDeclaration
+
Object.defineProperty
调用。我们手动模拟这个过程,以
class Point { constructor(x,y) { this.x=x; this.y=y; } distance(p) { return Math.hypot(this.x-p.x, this.y-p.y); } }
为例:
步骤1:提取类名、构造器参数、方法列表
-
类名:
Point -
构造器参数:
x, y -
实例方法:
distance(注意:constructor不作为方法输出)
步骤2:生成构造函数
function Point(x, y) {
'use strict';
if (!(this instanceof Point)) {
throw new TypeError("Class constructor Point cannot be invoked without 'new'");
}
this.x = x;
this.y = y;
}
这里加入了
'use strict'
和
instanceof
检查,模拟
class
的严格模式和
new
强制调用。
步骤3:定义原型方法
Object.defineProperty(Point.prototype, "distance", {
value: function distance(p) {
return Math.hypot(this.x - p.x, this.y - p.y);
},
writable: true,
configurable: true,
enumerable: false // ⚠️ 关键!class方法默认不可枚举
});
enumerable: false
是重点——
class
方法不会出现在
for...in
循环或
Object.keys()
中,这和ES5中直接赋值
Point.prototype.distance = function(){}
(默认
enumerable:true
)有本质区别。
步骤4:设置constructor属性
Object.defineProperty(Point.prototype, "constructor", {
value: Point,
writable: true,
configurable: true,
enumerable: false
});
确保
Point.prototype.constructor
正确指向
Point
,避免被意外覆盖。
4.2 处理继承:
extends
的完整转译链条
class ColorPoint extends Point { constructor(x, y, color) { super(x, y); this.color = color; } getColor() { return this.color; } }
的转译更复杂,需四步:
① 创建子类构造函数,并禁用
new.target
检查
function ColorPoint(x, y, color) {
'use strict';
if (!(this instanceof ColorPoint)) {
throw new TypeError("Class constructor ColorPoint cannot be invoked without 'new'");
}
// ⚠️ 此处不能直接调用Point,需用super代理
var _this = Point.call(this, x, y) || this; // 模拟super()调用
_this.color = color;
return _this;
}
② 设置子类原型链
// ColorPoint.prototype.__proto__ = Point.prototype
Object.setPrototypeOf(ColorPoint.prototype, Point.prototype);
// ColorPoint.__proto__ = Point(静态继承)
Object.setPrototypeOf(ColorPoint, Point);
③ 定义子类方法
Object.defineProperty(ColorPoint.prototype, "getColor", {
value: function getColor() {
return this.color;
},
writable: true,
configurable: true,
enumerable: false
});
④ 修复
constructor
指向
Object.defineProperty(ColorPoint.prototype, "constructor", {
value: ColorPoint,
writable: true,
configurable: true,
enumerable: false
});
这套转译逻辑解释了为什么Babel编译后的代码体积会增大——它用大量
Object.defineProperty
和
Object.setPrototypeOf
调用来精确复现
class
的语义。这也是为什么在性能敏感场景(如游戏引擎),有些团队仍坚持手写ES5构造函数:省去这些元操作的开销。
4.3 现代开发中的真实取舍:何时用class,何时回归函数?
在实际项目中,
class
不是银弹。我根据十年经验总结出三条黄金法则:
法则1:组件类优先用class,工具函数坚决不用
-
React Class Component、Vue 2 Options API、自定义Web Component:
class天然契合生命周期和状态管理,代码组织清晰; -
工具库如
lodash、date-fns:全部用纯函数(function format(date, pattern) {}),因为无状态、无this、易测试、可tree-shaking。
法则2:需要私有状态时,class + #private 是最优解
class CacheManager {
#cache = new Map();
#maxSize = 100;
set(key, value) {
if (this.#cache.size >= this.#maxSize) {
const firstKey = this.#cache.keys().next().value;
this.#cache.delete(firstKey);
}
this.#cache.set(key, value);
}
get(key) {
return this.#cache.get(key);
}
}
相比
Symbol
或闭包,
#cache
真正隔离,且V8对其有专门优化(存储在隐藏类中)。
法则3:高频创建对象时,考虑Object.create()替代new
// 每秒创建10万次Point实例?
const PointProto = {
distance(p) { return Math.hypot(this.x-p.x, this.y-p.y); }
};
function createPoint(x, y) {
const p = Object.create(PointProto);
p.x = x;
p.y = y;
return p;
}
Object.create()
比
new Point()
少一次构造器调用和
this
绑定,性能提升约15%(Chrome 115实测)。当然,这牺牲了
instanceof
检查和
constructor
属性,需权衡。
常见问题速查表:
问题现象 根本原因 解决方案 TypeError: Class constructor X cannot be invoked without 'new'试图像函数一样调用class 改用 new X(),或用Reflect.construct(X, args)Uncaught ReferenceError: Must call super constructor子类constructor中未调用super() 在constructor首行添加 super(...)this.method is not a function方法被解构后 this丢失用箭头函数包装,或在constructor中 this.method = this.method.bind(this)class方法在for...in中遍历不到class方法默认 enumerable:false需要枚举时改用 Object.getOwnPropertyNames(Class.prototype)
5. 常见问题与排查技巧实录:那些让你深夜抓狂的class陷阱
5.1 “this丢失”问题的根因与七种解法
this
丢失是
class
最经典的坑。根源在于:
class
方法是普通函数,不是箭头函数,其
this
由调用方式决定,而非定义位置
。看这个典型场景:
class Button {
constructor() {
this.text = 'Click me';
}
handleClick() {
console.log(this.text); // 期望输出'Click me'
}
}
const btn = new Button();
document.getElementById('myBtn').addEventListener('click', btn.handleClick);
// 点击时输出undefined!因为handleClick被作为普通函数调用,this指向window
解法1:bind绑定(最传统)
document.addEventListener('click', btn.handleClick.bind(btn));
缺点:每次绑定都创建新函数,内存开销大。
解法2:箭头函数包装(推荐)
document.addEventListener('click', () => btn.handleClick());
优点:简洁,
this
作用域明确;缺点:事件对象
event
需手动传递。
解法3:class字段+箭头函数(ES2022标准)
class Button {
handleClick = () => {
console.log(this.text); // this永远指向实例
}
}
V8引擎会将此编译为在constructor中
this.handleClick = this.handleClick.bind(this)
,是目前最优雅的方案。
解法4:Proxy拦截(高级)
class Button {
constructor() {
return new Proxy(this, {
get(target, prop) {
if (typeof target[prop] === 'function' && prop !== 'constructor') {
return target[prop].bind(target);
}
return target[prop];
}
});
}
}
一劳永逸,但增加运行时开销,仅建议框架层使用。
解法5:事件委托(DOM场景专用)
document.addEventListener('click', (e) => {
if (e.target.id === 'myBtn') {
btn.handleClick();
}
});
避免直接绑定,适合复杂UI。
解法6:React的自动绑定(仅限React)
class MyComponent extends React.Component {
handleClick = () => { /* this安全 */ }
render() {
return <button onClick={this.handleClick}>Click</button>;
}
}
Babel插件
@babel/plugin-transform-classes
会自动处理。
解法7:TypeScript的this参数(类型安全)
class Button {
text = 'Click me';
handleClick(this: Button) { // 显式声明this类型
console.log(this.text);
}
}
编译时报错提示,预防运行时错误。
5.2 继承链断裂:
instanceof
失效的三种场景
instanceof
依赖原型链,一旦链断裂,判断就会失败。常见场景:
场景1:跨iframe对象
// iframe中
const iframeObj = new Array();
// 主页面
console.log(iframeObj instanceof Array); // false!因为iframe的Array !== window.Array
console.log(iframeObj.constructor === Array); // false
解决方案:用
Array.isArray(iframeObj)
或
Object.prototype.toString.call(iframeObj) === '[object Array]'
。
场景2:手动修改
__proto__
class A {}
const a = new A();
a.__proto__ = {}; // 断裂!
console.log(a instanceof A); // false
解决方案:避免直接操作
__proto__
,改用
Object.setPrototypeOf(a, {})
(虽然后者也会断裂,但至少是标准API)。
场景3:
class
与
function
混用
function OldStyle() {}
class NewStyle extends OldStyle {} // 合法,但OldStyle没有prototype.constructor
const ns = new NewStyle();
console.log(ns instanceof OldStyle); // true(因为NewStyle.prototype.__proto__ === OldStyle.prototype)
console.log(ns.constructor === NewStyle); // true
这里
instanceof
仍有效,但
OldStyle
的
constructor
属性可能未定义,需额外检查。
5.3 内存泄漏:class实例的引用陷阱
class
本身不导致泄漏,但不当使用会。最典型的是
事件监听器未清理
:
class DataProcessor {
constructor() {
this.data = [];
// ❌ 错误:匿名函数导致无法removeEventListener
document.addEventListener('data-update', (e) => {
this.data.push(e.detail);
});
}
}
当
DataProcessor
实例被销毁,匿名回调仍持有
this
引用,
this.data
无法GC。
正确做法:
class DataProcessor {
constructor() {
this.data = [];
this.handleUpdate = this.handleUpdate.bind(this);
document.addEventListener('data-update', this.handleUpdate);
}
handleUpdate(e) {
this.data.push(e.detail);
}
destroy() {
document.removeEventListener('data-update', this.handleUpdate);
}
}
或者用
AbortController
(现代方案):
class DataProcessor {
constructor() {
this.controller = new AbortController();
document.addEventListener('data-update', (e) => {
this.data.push(e.detail);
}, { signal: this.controller.signal });
}
destroy() {
this.controller.abort(); // 自动移除所有监听器
}
}
我踩过的最大坑:在Vue 2的
beforeDestroy钩子中忘记调用destroy(),导致组件卸载后监听器仍在后台运行,内存占用持续增长。后来我们强制要求所有class实例必须实现destroy()方法,并在基类中统一注册。
6. 深度延展:class与现代JS生态的共生关系
6.1 TypeScript中的class:从语法糖到类型系统基石
TypeScript的
class
远不止转译那么简单。它把JavaScript的运行时结构映射到编译时类型系统:
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
distance(p: Point): number { // 参数类型、返回类型全推导
return Math.hypot(this.x - p.x, this.y - p.y);
}
}
// 编译后仍是JS,但类型信息用于:
// 1. IDE智能提示(VSCode显示distance(p: Point))
// 2. 编译时检查(p.distance('abc')报错)
// 3. 生成.d.ts声明文件,供其他TS项目引用
更关键的是,
class
声明会自动生成
构造签名(Construct Signatures)
:
// Point的类型等价于:
interface PointConstructor {
new (x: number, y: number): Point;
}
这使得
function createInstance<T>(ctor: new (...args: any[]) => T, ...args: any[]): T
这样的泛型工厂函数成为可能。
6.2 Web Components:class是自定义元素的唯一载体
HTML Custom Elements规范强制要求:
自定义元素类必须继承
HTMLElement
,且必须用
class
声明
。这是浏览器原生支持的硬性规定:
// ✅ 合法:class声明,继承HTMLElement
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `<button><slot></slot></button>`;
}
}
customElements.define('my-button', MyButton);
// ❌ 非法:函数声明不被接受
function BadButton() {}
customElements.define('bad-button', BadButton); // TypeError
class
在这里不仅是语法,更是浏览器识别自定义元素的标记。
connectedCallback
、
disconnectedCallback
等生命周期钩子,全部依托于
class
的原型链机制。
6.3 Node.js中的class:模块化与单例模式的天然搭档
在Node.js服务端,
class
常与模块系统结合,实现优雅的单例:
// database.js
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = createConnection();
Database.instance = this;
}
}
module.exports = new Database(); // 导出实例,非类
// app.js
const db = require('./database');
db.query('SELECT * FROM users'); // 全局唯一连接
这种模式比
export default new Database()
更可控,因为
class
提供了清晰的初始化逻辑和私有状态封装。在大型应用中,我们甚至用
class
实现依赖注入容器:
class DIContainer {
#services = new Map();
register(name, factory) {
this.#services.set(name, factory);
}
resolve(name) {
const factory = this.#services.get(name);
return factory(this); // 传入容器自身,支持依赖注入
}
}
最后分享一个小技巧:在调试
class继承链时,不要只看console.log(instance),而要用console.dir(instance)。后者会完整展开__proto__链,清楚显示instance→Child.prototype→Parent.prototype→Object.prototype的每一级,比instanceof更直观。这是我排查继承问题的第一步,百试不爽。
1526

被折叠的 条评论
为什么被折叠?



