语法
类的本质是提供一种模版,用于创建一系列用途相似的对象,如果学习果其他编程语言,对下面的写法不会陌生:
class User{
constructor(name, age){
this.name = name;
this.age = age;
}
toString(){
return `${this.name} ${this.age}`;
}
}
let user = new User('Mike', 30);
console.log(user.toString());
// Mike 30
JS 和其他语言不同的是,如同在中学到的,JS 可以利用函数和原型继承,使用函数也可以完成类相同的工作:
function User2(name, age){
this.name = name;
this.age = age;
}
User2.prototype.toString = function(){
return `${this.name} ${this.age}`;
}
let user2 = new User2('Mike', 30);
console.log(user2.toString());
// Mike 30
事实上,如果查看类的类型:
console.log(typeof User);
// function
console.log(User.prototype.constructor === User);
// true
console.log(user.__proto__ === User.prototype);
// true
可以看到,不仅类的类型是function,在原型继承的实现上也与通过函数new类似。
区别
通过上面的讨论,似乎在 JS 中class仅仅是区别于new function的一种语法糖,不过在细节上还有一些区别。
类的内部有一个特殊属性[[IsClassConstructor]],被设置为true,该属性是true的时候仅能使用new关键字调用,比如:
User();
// TypeError: Class constructor User cannot be invoked without 'new'
此外,class中定义的方法,默认会被设置为enumerable:false:
let userDesc = Object.getOwnPropertyDescriptors(User.prototype);
console.log(userDesc);
// {
// constructor: {
// value: [class User],
// writable: true,
// enumerable: false,
// configurable: true
// },
// toString: {
// value: [Function: toString],
// writable: true,
// enumerable: false,
// configurable: true
// }
// }
类表达式
正如前面说的,JS 中类更像是“特殊的函数”,因此就像函数可以用表达式赋值给变量使用,类也可以:
let UserClass = class{
constructor(name, age){
this.name = name;
this.age = age;
}
toString(){
return `${this.name} ${this.age}`;
}
}
let user3 = new UserClass('Mike', 30);
console.log(user3.toString());
// Mike 30
同样的,为了在类表达式内部使用一个“确切的类名”,需要为匿名的类表达式指定一个仅在类表达式内部可用的名称:
let UserClass2 = class UserClass2{
constructor(name, age){
this.name = name;
this.age = age;
}
toString(){
return `${UserClass2.name}(${this.name} ${this.age})`;
}
}
let user4 = new UserClass2('Mike', 30);
console.log(user4.toString());
// UserClass2(Mike 30)
甚至你可以在 JS 中动态创建类:
function createUserClass(helloPrefix) {
return class {
constructor(name, age) {
this.name = name;
this.age = age;
}
toString() {
return `${this.name} ${this.age}`;
}
hello() {
console.log(`${helloPrefix} ${this.name}`);
}
}
}
let UserClass3 = createUserClass('Hello');
let user5 = new UserClass3('Mike', 30);
user5.hello();
// Hello Mike
Getter/Setter
就像对象字面量一样,类也可以创建访问器属性:
class UserClass4 {
constructor(name) {
this.name = name;
}
get name() {
return this._name;
}
set name(name) {
if(!name){
throw new Error('name is empty');
}
if (name.length < 4) {
throw new Error('name is too short');
}
this._name = name;
}
}
let user6 = new UserClass4('Mike');
console.log(user6.name);
user6.name = 'Mik';
// Error: name is too short
计算名称
可以在类中使用[...]计算方法名称:
class UserClass5 {
constructor(name) {
this.name = name;
}
['say'+'Hello'](){
console.log('Hello ' + this.name);
}
}
let user7 = new UserClass5('Mike');
user7.sayHello();
// Hello Mike
类字段
在旧的 JS 中,类中只能定义方法,在较新版本中,可以添加类字段(属性):
class UserClass6 {
name = 'Tom';
constructor(name) {
if(name){
this.name = name;
}
}
sayHello() {
console.log('Hello ' + this.name);
}
}
let user8 = new UserClass6();
user8.sayHello();
类字段在创建对象时为对象添加,并不存在于原型中:
console.log(UserClass6.prototype.name);
// undefined
使用类字段制作绑定方法
在中我们讨论过函数中的this丢失问题,这个问题对于类同样存在:
class UserClass7 {
constructor(name) {
this.name = name;
}
sayHello() {
console.log('Hello ' + this.name);
}
}
let user9 = new UserClass7('Mike');
setTimeout(user9.sayHello, 1000);
// Hello undefined
在传递方法时,改为使用包装函数可以解决这个问题:
setTimeout(() => user9.sayHello(), 1000);
// Hello Mike
或者使用方法绑定:
setTimeout(user9.sayHello.bind(user9), 1000);
// Hello Mike
对于类方法,还有一个选择——使用字段制作绑定方法:
class UserClass8 {
constructor(name) {
this.name = name;
}
sayHello = () => {
console.log('Hello ' + this.name);
}
}
let user10 = new UserClass8('Mike');
setTimeout(user10.sayHello, 1000);
// Hello Mike
这里的sayHello是一个类字段,其值是一个箭头函数,箭头函数内的this被绑定到类对象,因此在传递方法时没有任何特殊处理也能得到想要的结果。
继承
类之间可以继承:
class Animal {
constructor(name) {
this.name = name;
}
}
class Cat extends Animal {
constructor(name) {
super(name);
}
meow() {
console.log(`${this.name} meow`);
}
}
let cat = new Cat('Mike');
cat.meow();
// Mike meow
这种继承本质上也是通过原型继承实现的:
console.log(cat.__proto__ === Cat.prototype);
console.log(Cat.prototype.__proto__ === Animal.prototype);
console.log(Animal.prototype.__proto__ === Object.prototype);
// true
// true
// true
在 JS 中,extends不仅能继承类,还可以继承表达式:
function createUser(helloPrefix){
return class {
constructor(name, age) {
this.name = name;
this.age = age;
}
toString() {
return `${this.name} ${this.age}`;
}
hello() {
console.log(`${helloPrefix} ${this.name}`);
}
}
}
class Teacher extends createUser('Hello') {
constructor(name, age, grade) {
super(name, age);
this.grade = grade;
}
toString() {
return `${super.toString()} ${this.grade}`;
}
}
let teacher = new Teacher('Jeck Chen', 30, 1);
console.log(teacher.toString());
teacher.hello();
// Jeck Chen 30 1
// Hello Jeck Chen
重写方法
正如前文所说,在子类中不存在的方法,会通过原型链查找父类的方法并调用,如果子类需要改变方法行为,可以重写该方法:
class Animal2{
constructor(name) {
this.name = name;
}
toString() {
return `${this.name}`;
}
}
class Cat2 extends Animal2{
constructor(name) {
super(name);
}
toString() {
return `${super.toString()} meow`;
}
}
let cat2 = new Cat2('Mike');
console.log(cat2.toString());
// Mike meow
如果在重写时需要调用父类的同名方法,可以使用super关键字。
就像箭头函数没有this一样,箭头函数同样没有super:
class Cat2 extends Animal2 {
// ...
show() {
setTimeout(() => console.log('show', super.toString()), 1000);
}
}
// ...
cat2.show();
// show Mike
箭头函数的super会从外部查找。
如果这里使用的是普通函数表达式就会报错:
show() {
setTimeout(function(){console.log('show', super.toString())}, 1000);
// SyntaxError: 'super' keyword unexpected here
}
重写构造函数
如果继承时没有定义构造函数:
class User3{
constructor(name){
this.name = name;
}
}
class Teacher3 extends User3{
}
let teacher3 = new Teacher3('Tom');
JS 会默认为其添加一个重写的构造函数:
class Teacher3 extends User3{
constructor(args){
super(args);
}
}
如果需要修改子类的构造函数:
class Teacher3 extends User3{
constructor(name, school){
this.school = school;
this.name = name;
// ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
}
}
这里存在一个特殊要求,即在使用this关键字前,需要先调用父类的构造函数(super(...)):
class Teacher3 extends User3{
constructor(name, school){
super(name);
this.school = school;
}
}
重写字段
可以重写字段(属性):
class Animal3 {
name = 'Animal';
showName() {
console.log('showName', this.name);
}
}
class Cat3 extends Animal3 {
name = 'Cat';
}
let cat3 = new Cat3();
cat3.showName();
// showName Cat
let animal3 = new Animal3();
animal3.showName();
// showName Animal
大多数情况下这样不会存在问题,但如果在父类的构造函数中调用重写字段:
class Animal4{
name = 'Animal';
constructor() {
console.log('name', this.name);
}
}
class Cat4 extends Animal4 {
name = 'Cat';
}
let cat4 = new Cat4();
let animal4 = new Animal4();
// name Animal
// name Animal
子类的构造函数调用和父类的构造函数调用时输出的结果相同,this.name的值都是Animal,但如果父类构造函数中使用的不是字段而是方法,就不存在这样的问题:
class Animal5 {
constructor() {
console.log('name', this.getName());
}
getName(){
return 'Animal';
}
}
class Cat5 extends Animal5 {
getName() {
return 'Cat';
}
}
let cat5 = new Cat5();
let animal5 = new Animal5();
// name Cat
// name Animal
之所以会出现这样的原因,是因为 JS 的类字段初始化顺序,当父类的构造器执行时,子类的类字段还没有被初始化,因此此时使用this.name获取到的只能是父类的字段而非子类的。而类方法并不存在这个问题。
如果一定需要在父类构造函数中使用字段重载,可以用 Getter 解决这个问题,因为它本质上是方法:
class Animal6 {
constructor() {
console.log('name', this.name);
}
get name(){
return 'Animal';
}
}
class Cat6 extends Animal6 {
get name() {
return 'Cat';
}
}
let cat6 = new Cat6();
let animal6 = new Animal6();
// name Cat
// name Animal
静态属性和方法
JS 的类中同样可以为类添加静态方法:
class Rectangle{
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
static compare(r1, r2) {
return r1.area > r2.area;
}
}
let r1 = new Rectangle(10, 20);
let r2 = new Rectangle(20, 30);
console.log(Rectangle.compare(r1, r2));
// false
在静态方法中,this指向的是类本身:
class Sample{
static test(){
console.log(this === Sample);
}
}
Sample.test();
// true
除了上述常见的方式(在类定义中添加静态方法),JS 也可以更灵活地为类对象添加静态方法:
class Sample2{}
Sample2.show = function() {
console.log(this===Sample2);
}
Sample2.show();
// true
静态属性
较新的 JS 也支持静态属性:
class Counter{
static count = 0;
constructor() {
Counter.count++;
}
}
let c1 = new Counter();
let c2 = new Counter();
console.log(Counter.count);
// 2
静态方法的继承
静态方法可以继承:
class Shape{
static compare(s1, s2) {
if (s1.area === s2.area) {
return 0;
}
if (s1.area < s2.area) {
return -1;
}
return 1;
}
}
class Rectangle2 extends Shape{
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
}
let r3 = new Rectangle2(10, 20);
let r4 = new Rectangle2(20, 30);
console.log(Rectangle2.compare(r3, r4));
// -1
这种继承同样是通过原型继承实现的:
console.log(Rectangle2.__proto__ === Shape);
// true
私有属性
编程中有一条规则:尽可能的少对外暴露属性/方法,因此一般会习惯性地先将所有客户端程序用不到的类属性和方法设置为私有的。这样做的好处是为代码保留灵活性,我们可以在后续重构中很容易地改变内部实现,而不需要(或少的)改变外部代码。
在 JS 中,私有属性通常约定为以_开头命名:
class Person7{
constructor(name) {
this.name = name;
}
get name() {
return this._name;
}
set name(name) {
this._name = name;
}
sayHello() {
console.log('Hello ' + this.name);
}
}
let person7 = new Person7('Jim');
person7.sayHello();
示例中定义了一个私有属性_name,并使用访问器属性对其进行读写,当然你也可以用普通方法。
需要说明的是,JS 的这种私有属性只是约定,并没有任何语言层面的强制和保障,客户端程序依然可以很容易地直接读写。
真私有属性
较新的 JS 添加了新特性——使用#可以定义一个语言层面保证的私有属性:
class Person8{
#name = '';
constructor(name) {
this.#name = name;
}
sayHello() {
console.log('Hello ' + this.#name);
}
}
let person8 = new Person8('Jeck Chen');
person8.sayHello();
person8.#name;
// SyntaxError: Private field '#name' must be declared in an enclosing class
但需要注意的是,这种私有属性无法被子类继承。
扩展内置类
class MyArray extends Array {
isEmpty() {
return this.length === 0;
}
}
let arr = new MyArray();
console.log(arr.isEmpty());
// true
arr.push(1);
console.log(arr.isEmpty());
// false
let arr2 = new MyArray(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
arr2 = arr2.filter(item => item > 5);
console.log(arr2);
console.log(arr2.isEmpty());
// MyArray(5) [ 6, 7, 8, 9, 10 ]
// false
这个示例用自定义类扩展了内置的数组类Array,有意思的是 Array 的filter方法返回的部分数组不是普通的数组对象,而是自定义的MyArray对象。
这是因为这些内置类方法返回处理后的对象时,使用的是this的构造器进行创建新对象,而不是内之类自己的构造器。
可以使用一个特殊名称的 Getter 指定这些内置类应该使用哪个构造器:
class MyArray2 extends Array {
isEmpty() {
return this.length === 0;
}
static get [Symbol.species]() {
return Array;
}
}
let arr3 = new MyArray2(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
arr3 = arr3.filter(item => item > 5);
console.log(arr3);
// [ 6, 7, 8, 9, 10 ]
这里用[Symbol.species]指定了使用Array构造器创建新对象,因此最后返回的部分数组是一个普通数组。
instanceof
可以使用instanceof关键字判断对象是否属于某个类,这通常在处理多态的函数中很有用:
class Reactangle{
constructor(width, height) {
this.width = width;
this.height = height;
}
}
class Square extends Reactangle{
constructor(length) {
super(length, length);
}
}
class Circle {
constructor(radius) {
this.radius = radius;
}
}
function printArea(shape) {
if (shape instanceof Reactangle) {
console.log(shape.width * shape.height);
return;
}
if (shape instanceof Circle) {
console.log(Math.PI * shape.radius * shape.radius);
return;
}
}
printArea(new Square(5));
printArea(new Circle(5));
printArea(new Reactangle(5, 10));
// 25
// 78.53981633974483
// 50
这实际上是通过在对象的原型链上进行查找匹配实现的:
function isInstanceOf(obj, clazz){
if(!obj){
return false;
}
if(!obj.__proto__){
return false;
}
let proto = obj.__proto__;
do{
if(proto === clazz.prototype){
return true;
}
proto = proto.__proto__;
if(!proto){
return false;
}
}
while(true);
}
console.log(isInstanceOf(new Square(5), Square));
console.log(isInstanceOf(new Square(5), Reactangle));
console.log(isInstanceOf(new Square(5), Circle));
// true
// true
// false
这也解释了一些奇怪的编程问题,比如:
function Cat9() {
this.name = 'Cat';
}
let cat9 = new Cat9();
Cat9.prototype = {};
console.log(cat9 instanceof Cat9);
// false
这个示例中使用方法new了一个对象后,对方法用于创建对象的原型(Cat9.prototype)进行了重新赋值,因此也就无法从对象的原型链上匹配出结果,所以最终的instanceof是false。
除了常用的instanceof外,JS 还提供一个方法判断某个对象是否是另一个对象的原型,也能起到同样的效果:
console.log(Reactangle.prototype.isPrototypeOf(new Reactangle(1,5)));
console.log(Reactangle.prototype.isPrototypeOf(new Square(1,5)));
console.log(Reactangle.prototype.isPrototypeOf(new Circle(1,5)));
// true
// true
// false
Symbol.hasInstance
正常情况下像上面说的,JS 通过比对原型链判断对象是否属于另一种类型,但我们也可以自行添加判断依据:
class Animal10 {
static [Symbol.hasInstance](instance) {
if(instance.eat){
return true;
}
}
}
console.log({eat: true} instanceof Animal10);
console.log({} instanceof Animal10);
// true
// false
JS 会在类型判断时,先检查是否存在静态方法[Symbol.hasInstance],如果存在,就用它完成类型判断。
toString
还有一个小技巧,Object.prototype.toString为所有的对象用原型继承的方式添加了一个返回字符串内容的标准实现,它返回的内容是有规律的,我们可以利用这一点进行方法借用,并实现类型判断:
let toString = Object.prototype.toString;
console.log(toString.call(new Reactangle(1, 5)));
console.log(toString.call([1, 2, 3]));
console.log(toString.call(null));
console.log(toString.call(undefined));
console.log(toString.call(new Date()));
console.log(toString.call(1));
console.log(toString.call('1'));
// [object Object]
// [object Array]
// [object Null]
// [object Undefined]
// [object Date]
// [object Number]
// [object String]
Symbol.toStringTag
可以使用特殊的对象属性[object MyObject]改变Object.toString返回的内容:
let obj = {
[Symbol.toStringTag]: 'MyObject'
}
console.log({}.toString.call(obj));
// [object MyObject]
Mixins
原型继承是一种单继承,即只能为[[prototype]]指定一个对象的方式来为当前对象添加额外的特性。鉴于这种局限性,可以通过 Mixins(混入)的方式更为灵活地为已有对象(类)添加额外功能。
class Car{
constructor(name) {
this.name = name;
}
}
let operateMixin = {
start() {
console.log(`${this.name} start`);
},
stop() {
console.log(`${this.name} stop`);
}
}
Object.assign(Car.prototype, operateMixin);
let car = new Car('BMW');
car.start();
car.stop();
// BMW start
// BMW stop
混入组件之间也是可以存在继承关系的:
let safeOperateMixin = {
__proto__: operateMixin,
start() {
console.log('safe check before start');
super.start();
},
stop() {
console.log('safe check before stop');
super.stop();
}
}
class SafeCar {
constructor(name) {
this.name = name;
}
}
Object.assign(SafeCar.prototype, safeOperateMixin);
let safeCar = new SafeCar('BMW');
safeCar.start();
safeCar.stop();
// safe check before start
// BMW start
// safe check before stop
// BMW stop
因为混入是以对象字面量的E形式定义的,所以是用__proto__而非extends的方式实现对另一个混入的继承。在重写方法中,与普通继承类似,依然使用super访问被继承的混入的方法。
示例
下面的示例是使用混入实现一个事件框架,对于前台菜单组件,融合了相应的混入后,就可以绑定事件处理器以及触发事件:
let eventMixin = {
getEventHandlers(eventName) {
if (!this._eventHandlers) {
this._eventHandlers = {};
}
if (!this._eventHandlers[eventName]) {
this._eventHandlers[eventName] = [];
}
return this._eventHandlers[eventName];
},
off(eventName, handler) {
let handlers = this.getEventHandlers(eventName);
for (let i = handlers.length - 1; i >= 0; i--) {
if (handlers[i] === handler) {
handlers.splice(i, 1);
}
}
},
on(eventName, handler) {
let handlers = this.getEventHandlers(eventName);
handlers.push(handler);
},
trigger(eventName, args) {
let handlers = this.getEventHandlers(eventName);
for (let handler of handlers) {
handler.call(this, args);
}
}
}
class Menu {
constructor(name) {
this.name = name;
}
select() {
this.trigger('select', this.name);
}
}
Object.assign(Menu.prototype, eventMixin);
let title = new Menu('title');
title.on('select', function (name) {
console.log(`selected ${name}`);
});
title.select();
// selected title
The End.
本文的完整示例可以从获取。
参考资料

文章评论