es5 编写类风格的代码

1,458 阅读9分钟

分享下《JavaScript忍者秘籍》中的一种编写类风格代码的方法

JavaScript可以让我们通过原型实现继承,许多开发人员,尤其是那些有传统面向对象背景的开发人员,都希望将JavaScript的继承系统简化并抽象成一个他们更熟悉的系统。
所以,这不可避免地引导我们走向类的领域。类是面向对象开发人员所期望的内容,尽管JavaScript本身不支持传统的类继承。
通常,这些开发人员希望它有如下特性:

  • 一套可以构建新构造器函数和原型的轻量级系统
  • 一种简单的方式来执行原型继承
  • 一种可以访问被函数原型所覆盖的方法的途径

以下代码展示了一个可以实现上述目标的示例。

//通过subClass()方法,创建一个Person类作为Object的一个子类,该方法之后实现
var Person = Object.subClass({
  init: function (isDancing) {
    this.dancing = isDancing;
  },
  dance: function () {
    return this.dancing;
  }
});

//通过继承Person类,创建一个Ninja子类
var Ninja = Person.subClass({
  init: function () {
//需要一种调用父类构造器的方法——这里展示我们将这样做
    this._super(false);
  },
  dance: function () {
    //Ninja-specific stuff here
    return this._super();
  },
  swingSword: function () {
    return true;
  }
});

//创建一个实例对Person类进行测试,看其是否能够跳舞
var person = new Person(true);
assert(person.dance(),
  "The person is dancing.");

//创建一个实例对Ninja类进行测试,看其是否有swingSword方法以及继承过来的dance方法
var ninja = new Ninja();
assert(ninja.swingSword(),
  "The sword is swinging.");
assert(!ninja.dance(),
  "The ninja is not dancing.");

//执行instanceof测试,验证类的继承
assert(person instanceof Person,
  "Person is a Person");
assert(ninja instanceof Ninja && ninja instanceof Person,
  "Ninja is a Ninja and a Person");

注意事项:

  • 通过调用现有构造器函数的subClass()方法可以创建一个新“类”,例如,通过Object创建一个Person类,以及通过Person创建一个Ninja类
  • 为了让构造器的创建更加简单。我们建议的语法是,为每个类只提供一个init()方法,就像为Person和Ninja提供的init()方法一样
  • 我们所有的“类”最终都继承于一个祖先:Object。因此,如果要创建一个新类,它必须是Object的一个子类,或者是一个在层级上继承于Object的类(完全模仿当前的原型系统)
  • 该语法的最大挑战是访问被覆盖的方法,而且有时这些方法的上下文也有可能被修改了。通过this._super()调用Person的原始init()和dance()方法,我们就可以了解这种用法

实现:

(function () {
  var initializing = false,
//粗糙的正则表达式用于判断函数是否可以被序列化。
    superPattern =
      /xyz/.test(function () {
        xyz;
      })?
         /\b_super\b/: 
         /.*/;

//给Object添加一个subClass方法
  Object.subClass = function (properties) {
    var _super = this.prototype;

//初始化超类
    initializing = true;
    var proto = new this();
    initializing = false;

    for (var name in properties) {

//将属性复制到prototype里
      proto[name] = typeof properties[name] === 'function' &&
      typeof _super[name] === 'function' &&
      superPattern.test(properties[name]) ?
        //定义一个重载函数
        (function (name, fn) {
          return function () {
            var tmp = this._super;

            this._super = _super[name];

            var ret = fn.apply(this, arguments);
            this._super = tmp;

            return ret;
          }
        })(name, properties[name]) :
        properties[name];
    }

//创造一个仿真类构造器
    function Class() {
      if (!initializing && this.init) {
        this.init.apply(this, arguments);
      }
    }

//设置类的原型
    Class.prototype = proto;

//重载构造器引用
    Class.constructor = Class;

//让类继续可扩展
    Class.subClass = arguments.callee;

    return Class;
  };
})();

检测函数是否可序列化

代码实现的一开始就很深奥,而且还可能让人困惑。在后续代码中,我们需要知道浏览器是否支持函数序列化。但该测试又有相当复杂的语法,所以现在就要得到结果,然后保存结果,以便在后续代码中不再进行复杂的操作,因为后续代码本身已经够复杂了。
函数序列化就是简单接收一个函数,然后返回该函数的源码文本。稍后,我们可以使用这种方法检查一个函数在我们感兴趣的对象中是否存在引用。
在大多数浏览器中,函数的toString()方法都会奏效。一般来说 ,一个函数在其上下文中序列化成字符串,会导致它的toString()方法被调用。所以,可以用这种方法测试函数是否可以序列化。
在设置一个名为initializing的变量为false之后,我们使用如下表达式测试一个函数是否能够被序列化:

/xyz/.test(function () { xyz; })

该表达式创建一个包含xyz的函数,将该函数传递给正则表达式的test()方法,该正则表达式对字符串“xyz”进行测试。如果函数能够正常序列化(test()方法将接收一个字符串,然后将触发函数的toString()方法),最终结果将返回true。
使用该文本表达式,我们在随后的代码中使用了该正则表达式:

superPattern =
  /xyz/.test(function () {
    xyz;
  }) ?
    /\b_super\b/ :
    /.*/;

建立了一个名为superPattern的变量,稍后用它来判断一个函数是否包含字符串"_super"。只有函数支持序列化才能进行判断,所以在不支持序列化的浏览器上,我们使用一个匹配任意字符串的模式进行代替。

子类的实例化

此时,我们准备开始定义一个方法用于子类化父类,我们使用如下代码进行实现:

Object.subClass = function (properties) {
  var _super = this.prototype;

给Object添加一个subClass()方法,该方法接收一个参数,该参数是我们期望添加到子类的属性集。
为了用函数原型模拟继承,我们创建父类的一个实例,并将其赋值给子类的原型。我们在代码中定义了一个initializing变量,每当我们想使用原型实例化一个类的时候,都将该变量设置为true。
因此,在构造实例时,我们可以确保不再实例化模式下进行构建实例,并可以相应地运行或跳过init()方法:

if (!initializing && this.init) {
  this.init.apply(this, arguments);
}

尤其重要的是,init()方法可以运行各种昂贵的启动代码(连接到服务器、创建DOM元素,还有其他未知内容),所以如果只是创建一个实例作为原型的话,我们要避免任何不必要的昂贵启动代码。

保留父级方法

大多数支持继承的语言中,在一个方法被覆盖时,我们保留了访问被覆盖方法的能力。这是很有用的,因为有时候我们是想完全替换方法的功能,但有时候我们却只是想增加它。在我们特定的实现中,我们创建一个名为_super的临时新方法,该方法只能从子类方法内部进行访问,并且该方法引用的是父类中的原有方法。
例如:

var Person = Object.subClass({
    init: function (isDancing) {
        this.dancing = isDancing;
    }
});

var Ninja = Person.subClass({
     init: function () {
        this._super(false);
    }
});

在Ninja构造器内,我们调用了Person的构造器,并传入了一个相应的值。这可以防止重新复制代码——我们可以重用父类中已经编写好的代码。
该功能的实现是一个多步骤的过程。为了增强子类,我们向subClass()方法传入了一个对象哈希,只需要将父类的属性和传入的属性合并在一起就可以了。
首先,使用如下代码,创建一个超类的实例作为一个原型:

initializing = true;
var proto = new this();
initializing = false;

注意,我们是如何“保护”初始化代码的,正如我们在前一节中讨论的initializing变量的值。
现在,是时候将传入的属性合并到proto对象中了。如果不在意父类函数,合并代码将非常简单:

for (var name in properties)  {
    proto[name] = properties[name];
}

但是,我们需要关心父类的函数,所以前面的代码和除了调用父类函数的函数之外是等价的。重写函数时,可以通过_super调用父函数,我们需要通过名为_super的属性,将子类函数和父类函数的引用进行包装。但在完成该操作之前,我们需要检测即将被包装的子类函数。可以使用如下条件表达式:

typeof properties[name] === "function" &&
typeof _super[name] === "function" &&
superPattern.test( properties[name] )

这个表达式包含三个检测条件:

  • 子类属性是否是一个函数?
  • 超类属性是否是一个函数?
  • 子类函数是否都包含一个_super()引用?

只有三个条件都为true的时候,我们才能做所要做的事情,而不是复制属性值。注意,我们使用了之前设置的正则表达式,和函数序列化一起,测试函数是否会调用等效的父类。
如果条件表达式表明我们必须包装功能,我们通过给即时函数的结果进行赋值,将该结果作为子类的属性:

(function (name, fn) {
  return function () {
    var tmp = this._super;

    this._super = _super[name];

    var ret = fn.apply(this, arguments);
    this._super = tmp;

    return ret;
  }
})(name, properties[name])

该即时函数创建并返回了一个新函数,该新函数包装并执行了子类的函数,同时可以通过_super属性访问父函数。首先需要先保持旧的this._super引用(不管它是否存在),然后处理完以后再恢复该引用。这在同名变量已经存在的情况下会很有用(不想意外的丢失它)。
接下来,创建新的_super方法,它只是在父类原型中已经存在的一个方法的引用。值得庆幸的是,我们不需要做任何额外的代码修改或作用域修改。当函数成为我们对象的一个属性时,该函数的上下文会自动设置(this引用的是当前的子类实例,而不是父类实例)。
最后,调用原始的子类方法执行自己的工作(也有可能使用了_super),然后将_super恢复成原来的状态,并将方法调用结果进行返回。
有很多方式可以达到类似的结果(有的实现,会通过访问arguments.callee,将_super方法绑定到方法自身),但是该特定技术提供了良好的可用性和简便性。