翻译自《Object-oriented JavaScript: A Deep Dive into ES6 Classes》
一句话总结:类是一种有效优化代码结构的方法。ES6的类至少在形式上提供了正式的类定义方式,降低了在JavaScript中采用面向的对象设计的门槛。
通常我们需要在我们的程序中表示一个想法或概念 - 可能是汽车引擎,计算机文件,路由器或温度读数。直接在代码中表示这些概念分为两部分:表示状态的数据和表示行为的函数。ES6类为我们提供了一种方便的语法,用于定义代表我们概念的对象的状态和行为。
ES6类通过保证调用初始化函数使代码更安全,并且可以更容易地定义一组固定的函数,这些函数对该数据进行操作并保持有效状态。如果你可以将某个事物视为一个单独的实体,那么你可能应该定义一个类来表示程序中的“事物”。
考虑这个非类代码。你能找到多少错误?你会如何修理它们?
// set today to December 24
const today = {
month: 24,
day: 12,
};
const tomorrow = {
year: today.year,
month: today.month,
day: today.day + 1,
};
const dayAfterTomorrow = {
year: tomorrow.year,
month: tomorrow.month,
day: tomorrow.day + 1 <= 31 ? tomorrow.day + 1 : 1,
};
日期today无效:没有第24个月。此外,today还没有完全初始化:它缺少一年。如果我们有一个不能忘记的初始化函数会更好。另请注意,在添加一天时,我们检查了一个地方,如果我们超过31但在另一个地方错过了该检查。如果我们只通过一组小而固定的函数来处理数据会更好,每个函数都保持有效状态。
这是使用类的更正版本。
class SimpleDate {
constructor(year, month, day) {
// Check that (year, month, day) is a valid date
// ...
// If it is, use it to initialize "this" date
this._year = year;
this._month = month;
this._day = day;
}
addDays(nDays) {
// Increase "this" date by n days
// ...
}
getDay() {
return this._day;
}
}
// "today" is guaranteed to be valid and fully initialized
const today = new SimpleDate(2000, 2, 28);
// Manipulating data only through a fixed set of functions ensures we maintain valid state
today.addDays(1);
作者提示:
当函数与类或对象关联时,我们将其称为方法(method)。
当从类创建对象时,该对象被称为该类的实例(instance)。
构造函数
constructor
方法很特殊,它解决了第一个问题。它的工作是将一个实例初始化为一个有效状态,它将自动调用,所以我们不会忘记初始化我们的对象。
保持数据私密
我们尝试设计我们的类,以确保它们的状态有效。我们提供了一个只创建有效值的构造函数,我们设计的方法也总是只留下有效值。但只要允许每个人都可以访问课程的数据,就总会有人搞砸。我们通过只允许通过给出的方法访问数据来避免这个问题。
作者提示:保持数据私有以保护它称为封装(encapsulation)。
通过约定实现私有
不幸的是,JavaScript中不存在私有对象属性。我们必须伪造它们。最常见的方法是遵循一个简单的约定:如果属性名称以下划线为前缀(或者不太常见,后缀为下划线),则应将其视为非公开。我们在前面的代码示例中使用了这种方法。通常这个约定简单约定,但数据在技术上仍然可供所有人访问,因此我们必须依靠自己的规则来做正确的事情。
通过特权方法实现私有
伪造私有对象属性的下一种最常见的方法是在构造函数中使用普通变量,并在闭包中捕获它们。这个技巧为我们提供了外部无法访问的真正私有数据。但为了使它工作,我们的类的方法本身需要在构造函数中定义并附加到实例:
class SimpleDate {
constructor(year, month, day) {
// Check that (year, month, day) is a valid date
// ...
// If it is, use it to initialize "this" date's ordinary variables
let _year = year;
let _month = month;
let _day = day;
// Methods defined in the constructor capture variables in a closure
this.addDays = function(nDays) {
// Increase "this" date by n days
// ...
}
this.getDay = function() {
return _day;
}
}
}
通过符号(Symbol)实现私有
从ES6开始,符号是JavaScript的一个新特性,它们为我们提供了伪造私有对象属性的另一种方法。我们可以使用唯一符号对象键,而不是下划线属性名称,我们的类可以在闭包中捕获这些键。但是有泄漏。JavaScript的另一个新功能是,它允许外部访问我们试图保密的符号键:Object.getOwnPropertySymbols
const SimpleDate = (function() {
const _yearKey = Symbol();
const _monthKey = Symbol();
const _dayKey = Symbol();
class SimpleDate {
constructor(year, month, day) {
// Check that (year, month, day) is a valid date
// ...
// If it is, use it to initialize "this" date
this[_yearKey] = year;
this[_monthKey] = month;
this[_dayKey] = day;
}
addDays(nDays) {
// Increase "this" date by n days
// ...
}
getDay() {
return this[_dayKey];
}
}
return SimpleDate;
}());
Weak maps实现私有
Weak maps也是JavaScript的新功能。我们可以使用实例作为键来存储键/值对中的私有对象属性,并且我们的类可以在闭包中捕获这些键/值映射:
const SimpleDate = (function() {
const _years = new WeakMap();
const _months = new WeakMap();
const _days = new WeakMap();
class SimpleDate {
constructor(year, month, day) {
// Check that (year, month, day) is a valid date
// ...
// If it is, use it to initialize "this" date
_years.set(this, year);
_months.set(this, month);
_days.set(this, day);
}
addDays(nDays) {
// Increase "this" date by n days
// ...
}
getDay() {
return _days.get(this);
}
}
return SimpleDate;
}());
其他访问范围修饰符
除了“private”之外,还有其他级别的可见性,你可以在其他语言中找到,例如“protected”,“internal”,“package private”或“friend”。JavaScript仍然没有为我们提供强制执行其他级别可见性的方法。如果你需要它们,你将不得不依赖惯例和自律。
引用当前对象
再看一下getDay()
方法,它没有指定任何参数,那么它如何知道调用它的对象?当使用符号将函数作为方法调用时,会使用隐式参数来标识对象,并将隐式参数分配给名为this
的隐式参数。为了说明,这里是我们如何显式而不是隐式地发送对象参数:
// Get a reference to the "getDay" function
const getDay = SimpleDate.prototype.getDay;
getDay.call(today); // "this" will be "today"
getDay.call(tomorrow); // "this" will be "tomorrow"
tomorrow.getDay(); // same as last line, but "tomorrow" is passed implicitly
静态方法和属性
我们可以选择定义数据和方法作为类的一部分但不属于该类的任何实例,它们分别称为静态属性和静态方法。每个实例只有一个静态属性副本而不是新副本:
静态方法
class SimpleDate {
static setDefaultDate(year, month, day) {
// A static property can be referred to without mentioning an instance
// Instead, it's defined on the class
SimpleDate._defaultDate = new SimpleDate(year, month, day);
}
constructor(year, month, day) {
// If constructing without arguments,
// then initialize "this" date by copying the static default date
if (arguments.length === 0) {
this._year = SimpleDate._defaultDate._year;
this._month = SimpleDate._defaultDate._month;
this._day = SimpleDate._defaultDate._day;
return;
}
// Check that (year, month, day) is a valid date
// ...
// If it is, use it to initialize "this" date
this._year = year;
this._month = month;
this._day = day;
}
addDays(nDays) {
// Increase "this" date by n days
// ...
}
getDay() {
return this._day;
}
}
SimpleDate.setDefaultDate(1970, 1, 1);
const defaultDate = new SimpleDate();
静态属性
目前,并没有实现静态属性的直接方法。虽然,直接在类上指定属性的方法似乎可以模拟静态属性,其实不是。例如:
class A {}
A.p = 'a'
a = new A()
// 类A的实例并不能访问“静态”属性p,必须要通过类自身访问
console.log(a.p) // undefined
console.log(A.p) // 'a'
class B extends A {}
// 这样定义的属性也不支持继承
console.log(B.p) // undefined
子类
我们经常发现我们定义的类之间存在共性 - 我们想要整合的重复代码。子类允许我们将另一个类的数据和行为合并到我们自己的类中。这个过程通常称为继承(inheritance),我们的子类称为从父类,也称为超类(superclass),“继承”。继承可以避免重复并简化与另一个类相同的数据和函数的实现。继承还允许我们替换子类,仅依赖于公共超类提供的接口。
继承以避免重复
考虑这个非继承代码:
class Employee {
constructor(firstName, familyName) {
this._firstName = firstName;
this._familyName = familyName;
}
getFullName() {
return `${this._firstName} ${this._familyName}`;
}
}
class Manager {
constructor(firstName, familyName) {
this._firstName = firstName;
this._familyName = familyName;
this._managedEmployees = [];
}
getFullName() {
return `${this._firstName} ${this._familyName}`;
}
addEmployee(employee) {
this._managedEmployees.push(employee);
}
}
我们的类之间重复数据属性_firstName
和_familyName
方法getFullName
。我们可以通过让Manager
类继承自Employee
类来消除重复。当我们这样做时,Employee
班级的状态和行为- 它的数据和功能 - 将被纳入我们的Manager
班级。
这是一个使用继承的版本。注意使用super:
// Manager still works same as before but without repeated code
class Manager extends Employee {
constructor(firstName, familyName) {
super(firstName, familyName);
this._managedEmployees = [];
}
addEmployee(employee) {
this._managedEmployees.push(employee);
}
}
IS-A和WORKS-LIKE-A
有一些设计原则可以帮助你确定何时适合继承。继承应始终建立IS-A和WORKS-LIKE-A关系的模型。也就是说,经理“是一个”并且“像一个”特定类型的员工一样工作,这样任何对超类实例的操作,都应该能够在子类实例上进行,并且一切都应该仍然有效。违反和遵守这一原则之间的区别有时可能是微妙的。细微违规的典型示例是Rectangle超类和Square子类:
class Rectangle {
set width(w) {
this._width = w;
}
get width() {
return this._width;
}
set height(h) {
this._height = h;
}
get height() {
return this._height;
}
}
// A function that operates on an instance of Rectangle
function f(rectangle) {
rectangle.width = 5;
rectangle.height = 4;
// Verify expected result
if (rectangle.width * rectangle.height !== 20) {
throw new Error("Expected the rectangle's area (width * height) to be 20");
}
}
// A square IS-A rectangle... right?
class Square extends Rectangle {
set width(w) {
super.width = w;
// Maintain square-ness
super.height = w;
}
set height(h) {
super.height = h;
// Maintain square-ness
super.width = h;
}
}
// But can a rectangle be substituted by a square?
f(new Square()); // error
正方形可以是数学上的矩形,但是正方形在行为上不像矩形。
任何使用超类实例的地方都能够由子类实例替代的规则称为Liskov替换原则,它是面向对象类设计的重要部分。
小心过度使用
在任何地方都很容易找到共性,即使是经验丰富的开发人员,拥有功能大而全的类的前景也很诱人。但是继承也有缺点。回想一下,我们通过仅通过一组小而固定的函数来操作数据来确保有效状态。但是当我们继承时,我们会增加可以直接操作数据的函数列表,然后这些附加函数也负责维护有效状态。如果太多函数可以直接操作数据,那么该数据几乎与全局变量一样糟糕。太多的继承会产生整体类,它们会稀释封装,更难以正确使用,并且难以重用。相反,最好设计仅包含一个概念的最小类。
让我们重新审视代码重复问题。我们能否在不继承的情况下解决它?另一种方法是通过引用连接对象以表示部分 - 整体关系。我们称这种构成。
这是使用组合而不是继承的经理 - 员工关系的一个版本:
class Employee {
constructor(firstName, familyName) {
this._firstName = firstName;
this._familyName = familyName;
}
getFullName() {
return `${this._firstName} ${this._familyName}`;
}
}
class Group {
constructor(manager /* : Employee */ ) {
this._manager = manager;
this._managedEmployees = [];
}
addEmployee(employee) {
this._managedEmployees.push(employee);
}
}
在这里,经理不是一个单独的类。相反,管理器类持有普通Employee实例的引用。如果继承模拟IS-A关系,则组合模拟HAS-A关系。也就是说,一个小组“有一个”经理。
如果继承或组合可以合理地表达我们的程序概念和关系,那么组合更好。
继承替换子类
继承还允许通过公共超类提供的接口交替使用不同的子类。将超类实例作为参数也可以传递给子类实例,而函数不必知道任何子类。替换具有公共超类的类通常称为多态(polymorphism):
// This will be our common superclass
class Cache {
get(key, defaultValue) {
const value = this._doGet(key);
if (value === undefined || value === null) {
return defaultValue;
}
return value;
}
set(key, value) {
if (key === undefined || key === null) {
throw new Error('Invalid argument');
}
this._doSet(key, value);
}
// Must be overridden
// _doGet()
// _doSet()
}
// Subclasses define no new public methods
// The public interface is defined entirely in the superclass
class ArrayCache extends Cache {
_doGet() {
// ...
}
_doSet() {
// ...
}
}
class LocalStorageCache extends Cache {
_doGet() {
// ...
}
_doSet() {
// ...
}
}
// Functions can polymorphically operate on any cache by interacting through the superclass interface
function compute(cache) {
const cached = cache.get('result');
if (!cached) {
const result = // ...
cache.set('result', result);
}
// ...
}
compute(new ArrayCache()); // use array cache through superclass interface
compute(new LocalStorageCache()); // use local storage cache through superclass interface
不仅仅是语法糖
JavaScript的类语法经常被认为是语法糖,并且在很多方面确实是,但也存在真正的差异 —— 我们可以用ES6类做ES5中做不到的事情。
静态属性是继承的
ES5没有让我们在构造函数之间创建真正的继承。可以创建一个普通的对象,但不是一个函数对象。我们通过手动复制它们来伪造静态属性的继承。现在有了ES6类,我们在子类构造函数和超类构造函数之间得到了一个真正的原型链接:Object.create
// ES5
function B() {}
B.f = function () {};
function D() {}
D.prototype = Object.create(B.prototype);
D.f(); // error
// ES6
class B {
static f() {}
}
class D extends B {}
D.f(); // ok
内置构造函数可以进行转换
有些对象是“奇异的”,并且不像普通对象那样。例如,数组将其length属性调整为大于最大整数索引。在ES5中,当我们尝试子类化时Array,new运算符将为我们的子类分配一个普通对象,而不是我们超类的异类对象:
// ES5
function D() {
Array.apply(this, arguments);
}
D.prototype = Object.create(Array.prototype);
var d = new D();
d[0] = 42;
d.length; // 0 - bad, no array exotic behavior
ES6类通过更改分配对象的时间和对象来修复此问题。在ES5中,在调用子类构造函数之前分配了对象,子类将该对象传递给超类构造函数。现在使用ES6类,在调用超类构造函数之前分配对象,并且超类使该对象可用于子类构造函数。这样Array即使在我们new的子类上调用时也可以分配一个奇特的对象。
// ES6
class D extends Array {}
let d = new D();
d[0] = 42;
d.length; // 1 - good, array exotic behavior
其它
还有一小部分其他的,可能不太重要的差异。类构造函数不能被函数调用。这可以防止忘记调用构造函数new。此外,prototype无法重新分配类构造函数的属性。这可以帮助JavaScript引擎优化类对象。最后,类方法没有prototype属性。这可以通过消除不必要的对象来节省内存
以富有想象力的方式运用新特征
此处和其他SitePoint文章中描述的许多功能都是JavaScript的新功能,社区正在尝试以新的和富有想象力的方式使用这些功能。
使用代理进行多重继承
其中一个实验使用代理,这是JavaScript的一个新功能,用于实现多重继承。JavaScript的原型链只允许单继承。对象只能委托给另一个对象。代理为我们提供了一种将属性访问委托给多个其他对象的方法:
const transmitter = {
transmit() {}
};
const receiver = {
receive() {}
};
// Create a proxy object that intercepts property accesses and forwards to each parent,
// returning the first defined value it finds
const inheritsFromMultiple = new Proxy([transmitter, receiver], {
get: function(proxyTarget, propertyKey) {
const foundParent = proxyTarget.find(parent => parent[propertyKey] !== undefined);
return foundParent && foundParent[propertyKey];
}
});
inheritsFromMultiple.transmit(); // works
inheritsFromMultiple.receive(); // works
我们可以扩展它以适应ES6类吗?类prototype可以是将属性访问转发给多个其他原型的代理。JavaScript社区正在努力解决这个问题。你能搞清楚吗?加入讨论并分享您的想法。
类工厂的多重继承
JavaScript社区一直在尝试的另一种方法是按需生成扩展变量超类的类。每个班级仍然只有一个父母,但我们可以用有趣的方式将这些父母联系起来:
function makeTransmitterClass(Superclass = Object) {
return class Transmitter extends Superclass {
transmit() {}
};
}
function makeReceiverClass(Superclass = Object) {
return class Receiver extends Superclass
receive() {}
};
}
class InheritsFromMultiple extends makeTransmitterClass(makeReceiverClass()) {}
const inheritsFromMultiple = new InheritsFromMultiple();
inheritsFromMultiple.transmit(); // works
inheritsFromMultiple.receive(); // works
是否有其他富有想象力的方法来使用这些功能?现在是时候将您的足迹留在JavaScript世界中了。
结论
如下图所示,目前对类的支持非常好。
希望本文能让您深入了解ES6中的类如何工作,并揭开了围绕它们的一些术语的神秘面纱。