1. 先想清楚:用ES5实现ES6里的“类”到底在实现什么
很多前端同学第一次看到“用ES5实现ES6里的类”这个需求,第一反应是:“这不就是Babel的活吗?直接上Babel不就完了?”话是这么说,但真到了实际操作中——老项目没法上构建、面试要手写、或者你正在追查一个奇奇怪怪的线上Bug,你会发现完全不了解ES6类背后的细节,根本无从下手。而且写ES5实现并不是为了替代Babel,而是为了彻底搞懂class语法糖背后的机制。
ES6的class本质上不是一个新东西,它是在函数构造器+原型链这套老机制上做的“语法糖”。比如:
javascript复制class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a sound.`);
}
static info() {
return 'animal class';
}
}
这段代码做的事情,往下拆解就是三件事:一个构造函数Animal,一个挂在Animal.prototype上的speak方法,以及一个挂在Animal自身上的静态方法info。此外还隐藏了几个细节:类声明不允许被提升(暂时性死区)、类方法不可枚举、类体内的代码强制严格模式、只能通过new调用不能当普通函数调用。
如果你平时只写ES6,可能从来没注意过这些细节点,因为浏览器帮你处理好了。但一旦需要“手工翻译”成ES5,这些地方全是坑。另一个容易被忽略的点:ES6里有一个new.target,可以在函数体内判断是被new调用还是被普通调用,这在模拟类的时候也很有用。
所以这篇文章就是围绕这条主线:把ES6类逐项拆开,然后用ES5的能力把它重新拼装回去。适合这样几类人看:一是老项目还在用ES5语法、想引入一点“类”的组织方式的;二是准备面试,被问到“ES6 class原理”时需要当场手写的;三是看Babel编译产物一头雾水,想搞懂那些helper函数到底是干嘛的。
提示:以下所有ES5代码都是基于严格模式写作的,建议在你的源码顶部也加上'use strict'。因为ES6类内部本身是严格模式,模拟行为时保持严格模式会避免很多诡异问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实现前必备的基础:构造函数、原型链和new的机制
2.1 构造函数和new的完整行为
在ES5里,最接近类的概念就是“构造函数”。一个普通函数,只要搭配new关键字使用,就能创建出一个新对象。很多人写过这样的代码:
javascript复制function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHi = function() {
console.log('Hi, ' + this.name);
};
但问起new到底做了什么,很多人只记得“创建了一个新对象、this指向它、返回它”。其实完整步骤如下:
- 创建一个全新的对象;
- 把这个对象的原型(
__proto__)指向构造函数的prototype属性; - 以这个新对象为
this执行构造函数体内的代码; - 如果构造函数显式返回一个对象,则返回该对象;否则返回步骤1创建的新对象。
这里的第2步,严格来说在ES5里调用Object.create(Person.prototype)就能完成。但如果环境太老连Object.create都没有,就要手动搞一个临时构造函数来打通原型链。
这跟ES6里的类有什么关系?ES6的构造函数本质就是这类函数,只是多了“必须用new调用”的限制。用ES5模拟时,就得在函数体入口手动拦截这种非法调用。
2.2 原型继承的经典演进
模拟类继承的时候,最常被提起的是“组合寄生式继承”。为什么不是“原型链继承”或者简单的“构造函数继承”?因为单独用某一种都有致命缺陷。
原型链继承,问题是子类实例会共享父类实例上的引用属性。如果父类构造函数里有一个数组属性,两个子类实例会操作同一个数组,这基本就是灾难。
构造函数继承,问题是虽然避免了实例属性共享,但父类原型上的方法子类拿不到,还得额外手动把Child.prototype指向Parent.prototype。
组合寄生式继承的做法是:
javascript复制function Parent(name) {
this.name = name;
this.list = [];
}
Parent.prototype.say = function() { console.log(this.name); };
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() { console.log(this.age); };
关键的Object.create(Parent.prototype)就是寄生的一环。它的目的在于只继承原型方法,而不是把父类的prototype直接赋值给子类,否则修改子类原型会污染父类原型。constructor的回指也是必须的,否则实例的constructor会一路沿着原型链指向Parent。
Object.create的兼容问题也要说清楚。IE8不支持Object.create,老项目里要自己写一个shim:
javascript复制function createObject(proto) {
function F() {}
F.prototype = proto;
return new F();
}
这一小段shim其实就是最简版的Object.create,理解它之后,后面看Babel生成的_inherits也会非常亲切,因为Babel里就是干这事。
3. 动手写核心封装:从静态方法到实例方法的全面对标
3.1 实例属性、原型方法、静态方法的翻译
ES6里的class方法分两种:实例上的(会放到prototype上)和静态的(挂在构造函数本身)。先给出一个基础版本的模拟,再逐步补充细节。
我们从一个简单的类Animal开始,需求如下:构造函数接收name参数,实例上有一个speak方法,类上有一个info静态方法。
javascript复制'use strict';
function Animal(name) {
if (!(this instanceof Animal)) {
throw new TypeError('Class constructor Animal cannot be invoked without \'new\'');
}
this.name = name;
}
Object.defineProperty(Animal.prototype, 'speak', {
value: function speak() {
console.log(this.name + ' makes a sound.');
},
writable: true,
configurable: true,
enumerable: false
});
Object.defineProperty(Animal, 'info', {
value: function info() {
return 'animal class';
},
writable: true,
configurable: true,
enumerable: false
});
这里有几个细节必须注意:
第一,Object.defineProperty把enumerable显式设为false。这是ES6 class与ES5手写原型方法的一个重大区别——ES6的类方法是不可枚举的,而传统的Animal.prototype.speak = function(){}是可以枚举的。如果你希望行为完全对齐,就必须用Object.defineProperty来定义方法。
第二,instanceof检查是模拟“必须用new调用”的简单方案。如果是普通函数调用,this在严格模式下是undefined,this instanceof Animal直接报错;在非严格模式下this指向window,所以这个检查也能拦住。但要注意这只是一个相对可靠的检查,后面会讲更接近原生行为的new.target方案。
3.2 对Getter、Setter和可计算属性名进行处理
ES6类里还支持get和set,比如:
javascript复制class User {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
get fullName() {
return this.firstName + ' ' + this.lastName;
}
set fullName(value) {
[this.firstName, this.lastName] = value.split(' ');
}
}
翻译成ES5时,Object.defineProperty同样能搞定访问器属性:
javascript复制function User(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
Object.defineProperty(User.prototype, 'fullName', {
get: function() {
return this.firstName + ' ' + this.lastName;
},
set: function(value) {
const parts = value.split(' ');
this.firstName = parts[0];
this.lastName = parts[1] || '';
},
enumerable: false,
configurable: true
});
可计算属性名在ES6 class里也能用,比如[methodName]() {}。ES5这边用Object.defineProperty的第二个参数直接传变量就行,没有额外障碍。
这里有一个很容易踩的坑:访问器属性不要直接在构造函数里用this.fullName = xxx去触发setter,除非你确实想这么干。而且定义到prototype上还是定义到实例上,行为差异很大。ES6 class的getter是定义在prototype上的,不是每个实例自己一份。如果定义到实例上,每次new都会创建一份函数,不仅浪费内存,还会导致实例属性和原型方法混乱。
3.3 继承和super:ES5模拟中最复杂的一环
继承是模拟ES6 class时最麻烦的部分,因为super关键字、子类的this初始化顺序等行为都牵扯到JavaScript底层机制。
ES6里的子类构造函数必须调用super(),否则会报错。原因是子类的this在调用super()之前不会被绑定,尤其是class语法下的“派生类”(一个继承了其他类的类)。ES5模拟时没法100%复刻这个限制,但我们可以做到“足够像”。
先看一个标准继承实现:
javascript复制function Dog(name, breed) {
// 模拟super(name)
Animal.call(this, name);
this.breed = breed;
}
// 建立原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 子类静态方法的继承
// ES6中,静态方法也会通过__proto__继承给子类
Object.setPrototypeOf(Dog, Animal);
Dog.prototype.bark = function() {
console.log(this.name + ' barks.');
};
Animal.call(this, name)就是最朴素的super模拟:调用父类构造函数,把父类的初始化逻辑作用到子类实例上。
原型链部分,Babel的_inherits函数是这样写的:
javascript复制function _inherits(subClass, superClass) {
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf(subClass, superClass);
}
这里有两个要点。第一个是Object.create(superClass.prototype, {constructor: {...}}),一步就把“原型继承”和“constructor回填”都完成了。直接写Object.create(superClass.prototype)再手动subClass.prototype.constructor = subClass也行,但把constructor当作一个属性描述符传进去,能顺手把它的enumerable设成false,更贴近原生class的定义。
第二个是Object.setPrototypeOf(subClass, superClass)。这行可能很多人第一次看到会觉得奇怪——让子类构造函数的__proto__指向父类构造函数。如果不做这一步,子类无法访问父类的静态方法。ES6 class天然支持静态属性继承,ES5里就得靠Object.setPrototypeOf或者退而求其次手动Object.assign(Dog, Animal)。
但Object.setPrototypeOf在ES5环境里并不存在。对老环境的兜底方案是subClass.__proto__ = superClass,或者干脆不处理静态继承。如果你维护的项目还要兼容IE11以下,建议先把静态继承的兼容性想清楚,不要直接无脑依赖Object.setPrototypeOf。
super.method()的模拟也有几种方案。比较实用的是在子类方法中先取到父类原型上的方法,再绑定this调用:
javascript复制Dog.prototype.speak = function() {
Animal.prototype.speak.call(this);
console.log(this.breed + ' is barking.');
};
这种方式虽然啰嗦,但思路直白、可读性好。
4. 完整封装:一个可直接复用的ES5类工厂
4.1 封装模块代码
现在把思路整合成一个defineClass函数,它接收三个参数:构造函数体、实例方法和静态方法。这套封装对标了ES6 class的大部分行为,适合直接搬进老项目里用,也能帮助你理清Babel输出的结构。
javascript复制'use strict';
function defineClass(constructor, instanceMethods, staticMethods) {
// 调用检查
if (!(this instanceof defineClass)) {
throw new TypeError('Class constructor cannot be invoked without \'new\'');
}
// 方法定义辅助函数
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ('value' in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) defineProperties(Constructor.prototype, protoProps);
if (staticProps) defineProperties(Constructor, staticProps);
return Constructor;
}
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError('Cannot call a class as a function');
}
}
var C = function() {
_classCallCheck(this, C);
return constructor.apply(this, arguments);
};
_createClass(
C,
Object.keys(instanceMethods || {}).map(function(key) {
return { key: key, value: instanceMethods[key] };
}),
Object.keys(staticMethods || {}).map(function(key) {
return { key: key, value: staticMethods[key] };
})
);
return C;
}
4.2 用法示例
javascript复制var Animal = defineClass(
function Animal(name) {
this.name = name;
},
{
speak: function() {
console.log(this.name + ' makes a sound.');
}
},
{
info: function() {
return 'animal class';
}
}
);
var dog = new Animal('Rex');
dog.speak();
console.log(Animal.info());
上面的Animal.info是可以访问的,因为静态方法被挂在了Animal本身。而dog.info访问不到,因为它在原型链之外。
如果要做继承,需要再封装一个inherits函数,并把defineClass扩展为支持父类参数。但多数真实场景里,我反而更推荐直接参考Babel的转译产物,而不是自研一套类工厂。因为Babel生成的代码经过大量实战检验,边界情况处理得比我个人写的封装更完整。上面这段封装的意义只是让你看懂机制,生产环境直接抄Babel更稳妥。
4.3 简单测试与验证
写完模拟实现后,建议用这几个用例做验证:
dog instanceof Animal是true;dog.constructor === Animal是true;Object.keys(Animal.prototype)返回空数组,因为方法不可枚举;- 试图直接
Animal('Rex')会抛TypeError; - 静态方法能通过
Animal.info()访问。
如果把这三个检查写成一个单元测试跑通,那说明这套ES5实现已经覆盖了ES6 class的绝大多数语义。
5. 常见问题与排查技巧实录
5.1 漏写new之后的行为差异
ES6 class在忘记new时直接抛类型错误,而且报错信息非常明确——“Class constructor cannot be invoked without 'new'”。ES5函数版的new和普通调用没有任何区别,忘记new就会产生全局污染(非严格模式下this指向window)或者静默失败。
模拟方案是在构造函数开头加_classCallCheck。如果你维护的老代码没做这个检查,可以在调试时临时加一行:
javascript复制if (!(this instanceof Person)) throw new Error('Please use new Person()');
这类Bug的排查技巧是:开发环境里看到某些数据莫名挂到了window上,第一反应就是看看构造函数是不是漏了new。
5.2 实例方法中的this丢失
ES6 class的实例方法被单独取出来当回调时,this照样会丢。这跟ES5函数没有本质区别,很多人误以为用了class就不用bind了,其实不是。
比如const speakFn = dog.speak; speakFn();,这行代码里的this已经不再指向dog。解决方案不外乎三种:箭头函数、Function.prototype.bind、或者把方法定义在实例属性上:
javascript复制class Dog {
constructor() {
this.speak = this.speak.bind(this);
}
speak() {}
}
ES5模拟时照搬这个思路即可,或者在封装层把所有实例方法都做一次bind。不过这会有额外的内存开销,建议根据业务场景选择是否要全局bind。
5.3 子类必须先调用super的语义分析
ES6 class里,子类构造函数不调用super直接用this,会报ReferenceError。这是因为派生类的this初始化是先由父类完成的,子类只是在这个基础上继续添加属性。
ES5模拟里,我们其实是在每行代码里手动Parent.call(this),所以这个限制“天然不成立”。但为了对齐原生行为,可以在子类构造函数开头强制调用父类初始化逻辑,否则就抛错。这算是一个强制约定,虽然我不建议在业务代码里写这类防御,但面试时如果被问到,能把这个差异讲清楚就很加分。
5.4 for...in遍历原型方法的差异
传统ES5写法:
javascript复制function Person() {}
Person.prototype.say = function() {};
for...in遍历一个Person实例时,say会出现在枚举结果里。而ES6 class的原型方法enumerable是false,所以for...in不会遍历到方法名。
如果你用ES5模拟ES6类,第3节里的Object.defineProperty已经用enumerable: false处理了这个差异。但要注意,如果你为了简单直接写Person.prototype.say = function(){},那这个方法仍然是可枚举的。判断枚举差异时,可以直接用Object.keys(person)和Object.keys(Person.prototype)来对比,很直观。
5.5 IE8和低版本环境的兼容处理
ES5的Object.defineProperty和Object.create在IE8里是半残状态。IE8支持Object.defineProperty,但只能用于DOM对象,不能用于普通的JavaScript对象,所以像第4节那样用Object.defineProperty定义方法的方案,在IE8上会直接失效。
老项目的常用降级方案是:判断Object.defineProperty存在才使用,否则退化为普通赋值。虽然枚举行为对不齐了,但至少功能可用。
Object.setPrototypeOf在旧环境里可以用subClass.__proto__ = superClass兜底,但__proto__本身也不是ES5规范里的东西,只是一个事实上的实现。遇到这种情况,最稳妥的路径还是别自己造轮子,直接让Babel帮你处理。
5.6 深拷贝实例的坑
热词里提到“es6深拷贝”,这跟类确实有关系。用JSON.parse(JSON.stringify(obj))做深拷贝时,实例上的类方法是不会带过去的,因为方法根本不参与JSON序列化。模拟类实现是同样的道理,实例方法是挂在prototype上的,拷贝完的对象原型已经变了,instanceof关系也会断掉。
所以在实际项目里,如果需要深拷贝某个类实例,建议在类里显式实现clone()方法,遍历实例自身属性去重建新实例,而不是依赖通用深拷贝工具。这个经验对ES5和ES6的类是一视同仁的。
最后再分享一个小技巧
如果你正在面试或者临时需要手写ES5类模拟,又不记得那么长的defineClass封装,就记住一个最简骨架:
javascript复制function Parent(name) { this.name = name; }
Parent.prototype.say = function() { console.log(this.name); };
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
这一坨搞定80%的场景。剩下的细节——方法不可枚举、静态继承、new检查——全部是加分项,按需补充。我个人在实际排查老代码Bug时,最常被坑的反而是第5.1节漏new导致全局变量污染这种最基础的问题。ES6 class大大减少了这类低级错误,但理解其原理后,你在写ES5代码时也能主动规避这些问题。
