转载

JavaScript写类的前世今生

JavaScript 从诞生至今已经走过了 20 年的历程。它的前世(1995~2015年)是一个长达 20 年没有类的世界,它的今生随着 2015 年 6 月 ES6 的发布迎来了有类的时代。JavaScript 从无“类”到有“类”,经历了从 ES1 到 ES6 的阶段。

虽然无“类”(class),却有一个 function,这个 function 关键字除了充当 函数/方法 角色,还可以充当构造器。在无“类”阶段,一批才华横溢的程序员利用 JavaScript 的特性发明了众多 语法糖 ,一步步向“类”靠近。

本文分为四部分

  1. 原始方式写类
  2. 工具函数写类
  3. 常见类库的写类
  4. ES6的写类

一、原始方式写类

让我们从最基本的构造函数开始吧。

1、构造函数方式

/**
* Person类:定义一个人,有个属性name,和一个getName方法
* @param {String} name
*/
function Person(name) {
    this.name = name;
    this.getName = function() {
        return this.name;
    }
}

function 在 JavaScript 中除了用作函数,对象方法。另外一个用途就担当构造器,当内部结合 this 一起就可以模拟“类”了。

这么定义有以下特点

  1. 风格让写过 Java 的程序员有点亲切在于构造一个对象需要配置一些参数,参数要赋值给类里面 this。
  2. 与 Java 的区别是用 function 来代替 class,参数也无需定义类型。
  3. 可以根据参数来构造不同的对象实例 ,缺点是构造时每个实例对象都会生成 getName 方法版本,造成了内存的浪费。

为了减少创建对象时方法 getName 在内存上的消耗,有程序员提出了一种改进方案

// 外部函数
function getName() {
    return this.name;
}
 
// 构造器
function Person(name) {
    this.name = name;
    this.getName = getName;//注意这里
}

代码风格有点差强人意,怎么看也没有 Java 那么紧凑。但的确可以减少内存的消耗。

2、原型方式

JavaScript 默认是采用原型继承( prototype-based )的,包括其内置 API 和提供给客户端程序员的继承。不使用继承,原型也可以用来写一个类。

/**
* Person类:定义一个人,有个属性name,和一个getName方法
*/
function Person(){}
Person.prototype.name = "jack";
Person.prototype.getName = function() { return this.name; }

原型优缺点是

  1. 缺点就是不能通过参数来构造对象实例 (一般每个对象的属性是不相同的)。
  2. 优点是所有对象实例都共享 getName 方法(相对于构造函数方式),没有造成内存浪费 。

3、构造函数+原型方式

取前面两种的优点

  • 用构造函数来定义类属性(字段)。
  • 用原型方式来定义类的方法。
/**
* Person类:定义一个人,有个属性name,和一个getName方法
* @param {String} name
*/
function Person(name) {
    this.name = name;
}
Person.prototype.getName = function() {
    return this.name;
}

这样,即可通过构造函数构造不同 name 的人,对象实例也都共享 getName 方法,不会造成内存浪费。

但似乎这样的代码风格似乎仍然没有 Java 的类那么紧凑,把属性,构造方法(函数),方法都包在一个大括号内。

public class Person {
    //属性(字段)
    String name;  
    //构造方法(函数)
    Person(String name) {
        this.name = name;
    }  
    //方法
    String getName() {
        return this.name;
    }
}

为了让代码和 Java 一样紧凑,有个程序员想出了一种很绝的方法(无从考证谁是第一个这么写的),把 prototype 上的方法定义在 function 内部

/**
* Person类:定义一个人,有个属性name,和一个getName方法
* @param {String} name
*/
function Person(name) {
    this.name = name;
    Person.prototype.getName = function() {
        return this.name;
    }
}

第一次看到这种写法深感不安,如此怪异的代码能运行吗? 看着象是递归嵌套一样。

验证一下

var p1 = new Person("Jack");
var p2 = new Person("Tom");
console.log(p1.getName());//Jack
console.log(p2.getName());//Tom

没有报错,控制台也正确输出了。说明可以这么写,似乎很完美。

  • 可以通过传参构造对象实例
  • 对象实例都共享同一份方法不造成内存浪费
  • 代码也比较紧凑

但每次 new 一个对象的时候都会执行

Person.prototype.getName = function() {
        return this.name;
}

造成了不必要的重复的运算。因为 getName 方法挂在 prototype 上只需执行一次即可。

function Person(name) {
    this.name = name;
    if (Person._init==undefined) {
        alert("我只执行一次!");
        Person.prototype.getName = function() {
            return this.name;
        }
        Person._init = 1;  
    }
}

只需要稍微改造下,加一个是否初始化的标识 _init。

二、工具函数写类

通过上面的知识我们知道 JavaScript 中写类本质上都是 构造函数+原型 。理解了以后碰到各式各样的写类方式就不惧怕了。

前面的写类方式比较简朴易懂,但每次采用这种方式写冗余代码,重复工作比较多。在日常编码工作中,只要碰到重复性的代码 2/3 次以上我们自然的会想到用一个函数来封装它,之后类似的功能只需调用该函数即可。写类函数便应运而生。

1、工具函数1

构造函数+原型直接组装一个类,同一构造函数将组装出同一类型

/**
* $class 写类工具函数之一
* @param {Function} constructor
* @param {Object} prototype
*/
function $class(constructor, prototype) {
    var c = constructor || function(){};
    var p = prototype || {};
    c.prototype = p;
    return c;
}

「工具函数1」代码只有四行,用构造函数来生成类实例的属性(字段),原型对象用来生成类实例的方法。

用它来写类示例如下

//构造函数
function Person(name) {
    this.name = name;
}
//原型对象
var proto = {
    getName : function(){return this.name},
    setName : function(name){this.name = name;}
}
//组装
var Man = $class(Person,proto);
var Woman = $class(Person,proto);

已经得到了两个类Man,Woman。并且是同一个类型的。测试下

console.log(Man == Woman); //true
console.log(Man.prototype == Woman.prototype); //true

创建Man、Woman的对象

var man = new Man("Andy");
var woman = new Woman("Lily");
console.log(maninstanceof Man); //true
console.log(womaninstanceof Woman); //true
console.log(maninstanceof Person); //true
console.log(womaninstanceof Person); //true

很好用, 一切如我们所期望。但是有个问题,下面代码的结果输出 false

console.log(man.constructor == Person); //false

这让人不悦:从以上的代码看出 man 的确是通过 Man 类 new 出来的,那么对象实例 man 的构造器应该指向Man,但却事与愿违。

原因就在于工具函数重写了 Person 的原型:c.prototype = p;  我们把 $class 稍微改写下,将方法都挂在构造器的原型上(而不是重写构造器的原型)。

/**
* $class 写类工具函数之一
* @param {Function} constructor
* @param {Object} prototype
*/
function $class(constructor, prototype) {
    var c = constructor || function(){};
    var p = prototype || {};
//  c.prototype = p;
    for (var atrin p) {
        c.prototype[atr] = p[atr];
    }  
    return c;
}

注意被注释的那一行(第 9 行)。

2、工具函数2

构造函数+原型组装一个类,同一构造函数可以定义出多个类型

/**
* $class 写类工具函数之二
* @param {Function} constructor
* @param {Object} prototype
*/
function $class(constructor,prototype) {
    var c = constructor || function() {};
    var p = prototype || {};  
    return function() {
        for(var atrin p) {
            arguments.callee.prototype[atr] = p[atr];
        }          
        c.apply(this,arguments);
    }
}

「工具函数2」较「工具函数1」稍难理解,它返回的是一个 function,这个 function 是一个类/类型。JavaScript 中的函数强大之处在于除了返回基本类型(值类型,对象类型),还可以返回 function,而这个 function 有时是 函数/方法,有时是 类/类型。这种函数也称为 高阶函数 ,是函数式编程语言的主要特性,利用它进行高度抽象的编程范式。一些高级应用依赖于此,比如 级联/链式调用 、 科里化 等。初学者可以细读感受下。

依然用它定义两个类

// 构造函数
function Person(name) {
    this.name = name;
}
// 原型对象
var proto = {
    getName : function(){return this.name},
    setName : function(name){this.name = name;}
}
// 写两个类
var Man = $class(Person,proto);
var Woman = $class(Person,proto);
// 同一个构造函数(Person)定义不同的类
console.log(Man == Woman); //false

与「工具函数1」不同的是,虽然 Man 和 Woman 都是用 Person,proto 组装的。但 Man 却不等于 Woman。即同一个构造函数(Person)可以定义出不同的类。

3、工具函数3

与「工具函数1」和「工具函数2」不同,第一个参数为类名(字符串),第二个参数为父类,第三个参数才是类的实现

/**
* $class 写类工具函数之三
* @param {String} className
* @param {Function} superClass
* @param {Function} classImp
*/
function $class(className, superClass, classImp) {
    if (superClass === "") superClass = Object;
    function clazz() {
        if (typeofthis.init == "function") {
            this.init.apply(this, arguments);
        }
    }
    var p = clazz.prototype = new superClass();
    var _super = superClass.prototype;
    window[className] = clazz;
    classImp.apply(p, [_super]);
}

用它定义类是这样的

$class('Person', '', function() {
    // 构造函数
    this.init = function(name) {
        this.name = name;
    };
    // 方法体
    this.getName = function() {
        return this.name;
    };
    this.setName = function(name) {
        this.name = name;
    };
});

创建对象

var p = new Person('Jack');
console.log(p);
console.log(p.constructor == Person); // false

使用该工具函数写类需注意,this.init 方法必不可少。因为没考虑继承,第二个参数 superClass 使用空字符串,即默认继承于 Object。

输出为 false 可看到,这个工具类没有去维护 constructor 属性。设计的每一种写类方式都是有取舍的,这完全取决于设计者的意图。

以上从易到难的列举了 3 个工具函数,它的目的是将“写类”这个动作抽象出来,减少重复编码工作。了解了 JavaScript 模拟“类”的本质后,有助于理解 JavaScript 类库的写类工具函数。

三、类库的写类

这里挑选几个代表性的 JavaScript 类库,它们都采用面向对象方式组织代码。因此都会有一个特定的写类工具函数。

1、Prototype.js 的写类方式

JavaScript写类的前世今生

Prototype.js 是 2006 年随着 Ajax 的流行而流行的一个 JavaScript 类库。它的出现立刻吸引了一大批后台程序员关注。这正是因为它采用面向对象方式组织代码,让诸如 Java、Ruby 这种面向对象语言的程序员倍感亲切,不需要过多的学习成本就能轻松转战 Web 前端。类库作者 Sam Stephenson 也是重度 Ruby 用户,因为他所在的极具 Geek 精神的公司 37signals 研发出了 Ruby on Rails。

Prototype.js 使用 Class.create 方法写类,如下

//类名Person
var Person = Class.create();
 
// 通过原型重写来定义Person
Person.prototype = {
    initialize : function(name) {
        this.name = name;
    },
    getName : function() {
        return this.name;
    },
    setName : function(name) {
        this.name = name;
    }  
}
 
// 创建对象
var p = new Person("jack");
console.log(p.constructor == Person); //false

initialize 完成对象的初始化,相当于构造函数,必不可少。方法依次往下写即可。

有个问题,p.constructor == Person 为false,这正是 Prototype.js 的一个小缺陷。修复如下

Person.prototype = {
    constructor : Person, // 注意这句将修复constructor属性
    initialize : function(name) {
        this.name = name;
    },
    getName : function() {
        return this.name;
    },
    setName : function(name) {
        this.name = name;
    }  
}

原因是之前重写了 Person 原型,为了使 constructor 能指向正确的构造器,只需在原型重写时维护好 constructor 属性即可。

2、Dojo.js 的写类方式

JavaScript写类的前世今生

Dojo.js 的历史非常悠久,是一个大而全的框架。最早由 IBM 基金会维护,现在是独立的 Dojo基金会 。目前 jQuery 基金会也已经并入该基金会。这个基金会牛人辈出,如著名的 Commonjs 社区创始人就来自该基金会,又如 RequireJS 的作者 James burke 也曾是 Dojo.js 的贡献者。

Dojo 用 dojo.declare 方法来定义一个类,dojo.declare有三个参数

  1. 类名className
  2. 继承的类superclass
  3. 构造器/方法props

定义一个简单的类实际只需传第一,三两个参数,因为这里只讨论如何定义一个类,不考虑继承。

// 定义类名
var className = "Person";
// 定义构造器及方法
var proto = {
    constructor : function(name){this.name=name;},
    getName : function(){ return this.name;},
    setName : function(name){ this.name = name;}
}
 
// 定义类Person
dojo.declare(className,null,proto);
 
// 创建一个对象
var p = new Person("tom");
console.log(p.getName()); //tom
p.setName("jack");
console.log(p.getName()); //jack
 
// 测试instanceof及p.constructor是否正确指向了Person
console.log(p instanceof Person); //true
console.log(p.constructor === Person); //true

测试看出 Dojo 正确的维护了对象的 constructor 属性。

3、ExtJS 的写类方式

JavaScript写类的前世今生

ExtJS 也是一个大而全的框架,它是为构建应用(app)而来而非 Web Pages。开始借鉴 YUI 而来,也采用面向对象方式构建,是大规模 JavaScript 应用的典范。

ExtJS 改名 Sencha 之后,形成了囊括 JS 框架(ExtJS)、测试工具(Sencha Test)、构建工具(Sencha Cmd)、Java工具GXT、调试工具(Sencha Inspector)、移动端(Sencha Touch)、IDE Plugs 等一整套开发工具包。

EXTJS 写类用 Ext.define,参数如下

  1. 类名 className
  2. 成员 members
  3. 回调 onClassCreated
Ext.define(className, members, onClassCreated);

示例定义了一个 Person

// 定义类 Person
Ext.define('Person', {
    name: '',
    constructor: function(name) {
        if (name) {
            this.name = name;
        }
    },
    eat: function(foodType) {
        alert(this.name + " is eating: " + foodType);
    }
});
// 创建对象
var bob = Ext.create('Person', 'James');
bob.eat("Salad"); // alert("Bob is eating: Salad");

与其它类库不同的是,ExtJS 创建类的实例不是直接用 new,而是 Ext.create 方法。 更多的写类示例,看 官方文档 。

4、Mootools.js 的写类方式

JavaScript写类的前世今生

Mootools.js 是一个简洁、模块化的面向对象的 JavaScript 类库。从 Prototype.js 中吸取了很多营养,部分语法也较类似。它的命运却和 Prototype.js 一样,随着 jQuery 的崛起而迅速没落。

Mootools 写类用 Class 标识符,它本身是一个类。

/**
* Person类
* @param {Object} name
*/
var Person = new Class({  
    initialize: function(name){
        this.name = name;
    },
    setName : function(name) {
        this.name = name;
    },
    getName : function() {
        return this.name;
    }
})
 
// new一个对象
var p = new Person("jack");
 
// 测试set,get方法
console.log(p.getName());//jac
p.setName('andy');
console.log(p.getName());//andy
 
// 测试instanceof及p.constructor是否正确指向了Person
console.log(p instanceof Person); //true
console.log(p.constructor == Person);  //true

可以看到和 Prototype.js 类似,都有一个 initialize,但它能正确维护所创建对象的 constructor 属性。

下面再介绍两个专门写类的小 lib,都是专门用来写类使用 OOP 方式组织代码的。

5 、使用 Klass.js 写类

Klass.js 非常小,源码不到 100 行。作者是 dustin diaz,他同时是《 Pro JavaScript Design Patterns 》和 Ender.js 的作者,当然他还写过 Promise 的实现 when.js 。

用 Klass 定义一个类

var Person = klass(function(name) {
      this.name = name
  })
  .statics({
      head: ':)',
      feet: '_|_'
  })
  .methods({
      walk: function () {}
  });
 
var pp = new Person('James');
Person.head; // statics
Person.feet; // statics
pp.name; // property
pp.walk; // method

Klass的特点在于链式调用,书写起来非常自然:先定义构造器,接着添加类属性或方法,再接着添加对象方法。

6、使用 Class.js 写类

Class.js 是我在学习 JavaScript OOP 编程时的写的,也非常小巧源码不到 300 行。它支持写类、私有属性/函数、继承、命名空间、事件等。实现了对象系统的 PME(Properties-Method-Event

) 架构。对象有状态(Properties)、操作(Method)、通讯(Event)。

Class.js 写类示例

Class('Person', function() {
    this.init = function(name) {
        this.name = name;
    }
    this.getName = function() {
        return this.name;
    }
    this.setName = function(name) {
        this.name = name;
    }
    this.println = function() {
        alert('Name is ' + this.name);
    }
})
var pp = new Person('John Backus');
console.log('Define a single class: ' + Person);
console.log('Create a instance of Person, his name is ' + pp.getName());

这里 this.init 方法必不可少。

四、ES6 的写类

上一节介绍了各个类库/框架的写类方式,风格迥异。其本质依然是 构造函数+原型 ,在语言自身没有提供写类语法的时候程序员利用现有机制实现了各种语法糖。

随着 JavaScript 应用范围逐渐扩大,业务逻辑逐渐往前端放置。出现了越来越多的大规模 JavaScript 应用,传统的面向对象语言(如Java、Ruby)则有着成熟稳定的大规模软件开发模式。因此 JavaScript 中引入对面向对象机制的需求愈加迫切。

经过 10 年的讨论,有着 20 岁的 JavaScript 终于迎来了 ES6。其中就有类系统,包括关键字 classextendssuper 。这标志着 JavaScript 开始迈入企业级大型应用的开发语言的行列。

需要注意的是,ES6 引入的 class 其实是一个语法糖。继承方式其内部依然是原型方式,只是让我们用更简洁明了的语法创建对象及处理相关的继承。

ES6 的写类方式定义 Person

// 定义类 Person
class Person {
 
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
 
  setName(name) {
    this.name = name;
  }
 
  getName() {
    return this.name;
  }
 
  toString() {
    return 'name: ' + this.name + ', age: ' + this.age;
  }
}

定义了一个类 Person,constructor 是构造器,这点和 Java 不同,Java的构造器名和类名相同。

再看看继承

class Man extends Person {
  constructor(name, age, school) {
    super(name, age); // 调用父类构造器
    this.school = school;
  }
 
  setSchool(school) {
    this.school = school;
  }
 
  getSchool() {
    return this.school;
  }
 
  toString() {
    return super.toString() + ', school:' + this.school; // 调用父类的toString()
  }
}
 
var man = new Man('张三', '30', '光明中学');
maninstanceof Man; // true
maninstanceof Person; // true
console.log(man);

以上代码中,constructor 和 toString 方法中,都出现了 super 关键字,它指代父类的实例(即父类的 this 对象)。 之前 ES5 有个 Object.getPrototypeOf 来获取父类的原型对象。

扩展阅读

https://github.com/ded/klass

https://github.com/snandy/class

https://en.wikipedia.org/wiki/Class-based_programming

https://en.wikipedia.org/wiki/Prototype-based_programming

https://en.wikipedia.org/wiki/Category:Class-based_programming_languages

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes

原文  http://jdc.jd.com/archives/2942
正文到此结束
Loading...