通常我们需要在代码中表述一个想法或概念——一部汽车引擎、一个电脑文件、一个路由器甚至一个温度计的度数。使用代码直接描述这些概念通常分为两部分:「表示状态的数据」与「表示行为的函数」。「类」给我们一个捷径来表述我们想表示的对象的状态和行为。同时,还通过初始化函数确定执行、更方便的定义约定的操作数据与维护状态的函数等机制,使得我们的代码更可控。如果你觉得某些「事物」是一个独立的实体,那么是时候单独为这个「事物」定义一个「类」了
看一段没有类的代码,有多少错误能肉眼判断出来?又如何修复这些错误?
// set today to December 24
let today = {
day: 12,
month: 24,
};
let tomorrow = {
year: today.year,
month: today.month,
day: today.day + 1;
};
let dayAfterTomorrow = {
year: tomorrow.year,
month: tomorrow.month,
day: tomorrow.day + 1 <= 31 ? tomorrow + 1 : 1
};
today
是个非法的日期,没有 24 这个月份。同时,today
并没有完全初始化,没有「年份」。如果有初始化函数,能更好的保证这种状况不会发生。同时需要注意,我们在一处添加了日期不走过 31 号的约束,但是在另外一处,我们却没有加上同样的约束。显然通过统一的小方法来跟操作数据并保持约束是一个更棒的想法。
来看一下使用「类」的正确版本
class SimpleDate {
constructor(year, month, day){
// 检查(year, month, day) 是否合法
// ...
// 若合法,初始化 "this" 日期
this._year = year;
this._month = month;
this._day = day;
}
addDays(nDays){
// 'this' 日期增加 n 天
// ...
}
getDay(){
return this._day;
}
}
// 'today' 完全被初始化与保证合法
let today = new SimpleDate(2000, 2, 28);
// 通过约定的函数来操作数据,保证状态合法
today.addDays(1);
小提示
- 「类」或「对象」中的方法,通常称为「方法」
- 通过一个「类」创建一个「对象」,通常称这个「对象」为这个「类」的「实例」
构造函数
构造函数 constructor
是一个特殊的「方法」,它的职责是以合法的状态初始化一个实例,它将自动调用而不会遗漏初始化实例。
保持数据私有
在设计类时就需要保证状态的合法性。我们提供了构造函数来创建合法的数值,还提供了方法使你可以将数值合法性的检查忘诸脑后。反之,如果任何人都可以读写我们类中的数值,那么数值可能被弄成一团麻。因此,我们需要保持除了我们提供的函数外,数据是不可直接访问的。
通用的私有化方法
不幸的是,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;
}
}
}
通过 Symbols 实现私有化
Symbols 是 JavaScript 的新特性,可以利用以作为实现伪私有的另外一个途径。无须再使用下划线开头命名的属性。通过唯一的 symbol 对象键,类可以在闭包中引用这些对象键。这也有个小缺陷,容易导致内存泄露。JavaScript 的另一个新特性是 Object.getOwnPropertySymbols
,它可以实现想保持私有的 symbol 键能被外部访问。
let SimpleDate = (function() {
let _yearKey = Symbol();
let _monthKey = Symbol();
let_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 的新特性。可以用来在键/值对中保存私有的属性,以实例为键,类在闭包中引用键/值 maps。
let SimpleDate = (function() {
let _years = new WeakMap();
let _months = new WeakMap();
let _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;
}());
其它访问修饰符
其它语言具有 「protected」,「internal」,「package private」或者「friend」修饰符。JavaScript 仍然没有一个正式途径可以强制不同级别的访问限制。如果需要,必须依赖自定义的约束和规范。
引用当前对象
回头再看 getDay()
方法,未指定任何参数的情况下,怎么知道调用它的是哪个对象呢?当函数以 object.function
方式调用时,会以隐式传参方式标识调用的对象,并赋值给一个叫 this
的隐式参数。下面展示了如何显式而不是隐式地传参
// Get a reference to the "getDay" function
let 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 (argumenets.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);
let defaultDate = new SimpleDate();
继承类
通常需要找出类之间的共性——需要提炼的重复代码。通过继承类整合其他类的状态和行为到一起。这个过程通常被称为『继承』,继承类则被称为从基类继承。继承具有避免重复、简化类与类间重复实现的数据与函数。继承还允许我们通过基类提供的 interface 实现替换继承类,
继承避免重复
下面是没有使用继承的代码
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 模式。也就是说,一个 manager (的实例)「是(is a)」并且「行为符合(works like a)」某种特定的 employee,就像在任何地方操作一个基类实例时,应该能够用一个继承类来替换,此时一切都应该仍然可以工作。违反和遵守这一原则的区别有时非常微妙。下面的 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 替换原则,它是面向对象设计时重要原则。
小心滥用
(未完待续)
本文根据 Jeff Mott 的《Object-Oriented Javascript - A Deep Dive into ES6》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:http://www.sitepoint.com/object-oriented-javascript-deep-dive-es6-classes/ 。欢迎加入 Node.js&前端交流群