深入 ES6 面向对象

Unless otherwise indicated, the text of documents in this site is available under the Creative Commons Attribution 3.0 Unported License, or any later version. Copyright 2009 - 2017 leopku.

通常我们需要在代码中表述一个想法或概念——一部汽车引擎、一个电脑文件、一个路由器甚至一个温度计的度数。使用代码直接描述这些概念通常分为两部分:「表示状态的数据」与「表示行为的函数」。「类」给我们一个捷径来表述我们想表示的对象的状态和行为。同时,还通过初始化函数确定执行、更方便的定义约定的操作数据与维护状态的函数等机制,使得我们的代码更可控。如果你觉得某些「事物」是一个独立的实体,那么是时候单独为这个「事物」定义一个「类」了

看一段没有类的代码,有多少错误能肉眼判断出来?又如何修复这些错误?

// 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&前端交流群