JavaScript中的面向对象设计


Building Objects

Object Literal

JS中构造Object的方式有很多种,比如使用Object Literal

var person = {
    firstname: "John",
    lastname: 'Doe',
    greet: function(){
        console.log('Hello' + this.firstname + ' ' + this.lastname)
    }
};
person.greet();

Object Literal这种方式很直观,也很有表现力,并且它限制了this的scope。

使用new

除了使用这种方式以外,也可以使用所谓的”构造函数”来创建一个对象,通常所说的造函数是指定义在某个类中用于完成对象初始化的函数,由于JavaScript没有类的概念,因此使用了一种特殊的函数来模拟构造函数

function Person(firstname, lastname){
    this.firstname = firstname;
    this.lastname = lastname;
}
var john = new Person("John","Doe");

可以看到,对于Person和普通函数有几个不同的地方,分别是:

  1. 它的第一个字母是大写的,这个规则在JavaScript中表明它是一个”构造函数”
  2. 在它调用语句前面加上了一个关键字new
  3. 函数内部没有return语句,但是它却有返回值。

显然这其中new起到了关键作用,如果去掉new,上述代码变为

function Person(firstname, lastname){
    this.firstname = firstname;
    this.lastname = lastname;
}
var john = Person("John","Doe");

这时,由于Person没有返回值,因此johnundefined,而此时Person为全局函数,里面的this指向了window。因此如果不使用new,那么Person就变成了一个普通函数。实际上,编译器在执行new的时候,改变了Person函数的执行逻辑,其过程如下:

  1. 创建一个空object
  2. 创建this指向这个空object
  3. 在构造函数最后增加一行return this
  4. 绑定this.__proto__obejct.prototype

这里要注意的是,对于Person,它是一个”构造函数”对象,由前一篇文章可知,它拥有一个prototype成员,对于一个函数来说,只有它是”构造函数“时,这个prototype对象才有意义。

使用Object.create

另一种创建Object的方法是使用Object.create(obj),这种方法是将obj作为Prototype来构造新的对象

var person = {
    firstname: "",
    lastname: "",
    greet: function(){
        return this.firstname + ' ' + this.lastname;
    }
}
var john = Object.create(person)
john.firstname="John"
john.lastname="Doe"
console.log(john.greet())
console.log(john.__proto__ == person) //true

上述例子中john的Prototype对象为person,因此它也具有person的一系列属性。由于prototype仅仅是个Object,因此john同样也可以作为Prototype来继续构造其它对象

var jane = Object.create(john);
jane.firstname = "Jane"
jane.lastname = "Doe"
console.log(jane.greet()); //Jane Doe

使用Object.create可以避免new带来的一些问题,实际上使用new并不是一个很好的方式,如果忘记写new,那么程序在编译时不会报错,运行时也不会报错,这是极为危险的,不仅会造成隐蔽的bug,还污染了全局变量。

如果浏览器不支持Object.create这时需要用到一个概念叫做Pollyfill,所谓”Pollyfill”是指如果用到的API浏览器不支持,我们需要自己实现这个API

if(!Object.create){
    Object.create = function(o){
        if(arguments.length>1){
            throw new Error('Object.create implementation' + ' only accepts the first parameter. ')
        }
        function F(){}
        F.prototype = o;
        return new F();
    }
}

Prototype

有了上面的铺垫,我们接着来理解Prototype,如果想要给上面的Person函数对象增加一个成员greet的方法,又不想修改Person函数,则需要使用Prototype

Person.prototype.greet = function(){
     console.log('Hello' + this.firstname + ' ' + this.lastname);
}
var john = new Person("John","Doe")
john.greet();

上面已经知道prototype的类型是object,而且每个函数对象数有一个成员叫做prototype,让人困惑的是这个prototype对象不仅可以被Person使用,还可以被其它类使用:

function Animal(name){
    this.name = name;
}
//set prototype to Animal
Animal.prototype = Person.prototype;
var dog = new Animal("Pluto");
dog.greet(); //Hello undefined undefined

Personprototype赋给了Animal,则dog可以调用greet()方法,由于dog并没有firstnamelastname,因此输出undefined

Prototype设计的初衷是为了代码复用,通过让多个类共享Prototype来实现对公共API的抽象。对于不同的对象,如果他们share了同一个Prototype中的API,那么可以认为它们有共同的”父类”。但是这种机制看起来更像是Interface或者Protocol,而非继承。对于上面dogjohn的例子,PersonAnimal的关系是并列的,我们用Prototype使dogjohn都具有了greet方法,因此这种结构更像是dogjohn共享了某种Interface。

Inheritance

如果要模拟继承,我们可以换一个例子:

function Person(firstname, lastname){
    this.firstname = firstname;
    this.lastname = lastname;
}
Person.prototype.greet = function(){
    return "Hello "  + this.firstname + ' ' + this.lastname;
}
function Policemen(number){
    this.number = number
}
Policemen.prototype = new Person("John","Doe")
var john = new Policemen("1234")
console.log(john.number) //1234
console.log(john.greet()) //Hello John Doe

这种写法和之前其实并无本质区别,都是改变Person.prototype,但这种写法更像是继承关系,当join调用greet之后,会现在Person中寻找,发现没有,然后在Person.prototype中寻找。而Person.prototype指向Parent,感觉上像是在Person的”父类“中寻找。因此这种写法相当于通过prototype对象将两个对象链接起来了,如下图所示

JavaScript称这种方式的调用为Prototype Chain。当object调用一个方法,首先在自己的构造函数对象中寻找,找不到则在__proto__中寻找,这个__proto__对象也有自己的__proto__会一直向上找,直到__proto__对象为空,例如ArrayhasOwnProperty方法:

Array arr = new Array()
arr. hasOwnProperty('length') //true
//hasOwnProperty这个方法定义在 Array.__proto__.__proto__中

回到上面的例子,虽然找到了类似”继承”的感觉,但是上代码不够优雅,甚至是有些丑陋,且模式无法复用,我们接下来可以想一些办法让上面代码稍微优雅一点,并且可以让”继承”的模式可以被复用起来,首先我们先写个Module:

//util.js
module.exports = {
    inheritate : function(Child,Parent){
        Child.prototype = Object.create(parent).prototype
        Child.prototype.constructor = Child
    }
}

显然,这个Module的作用是将”继承”这个动作抽象出来,但是仅有这个函数是不够的,它只能让Child继承Parentprototype上的API,而定义在Parent内部的方法或者属性则无法被集成,因此我们还需要将this指针进行关联:

//app.js
var util = require('./util')
var Person = require('./Person')

util.inheritate(Policemen,Person); //Policement是”基类“,Person是”父类“
function Policemen(firstname,lastname,number){ //需要传入”父类“需要的参数
    //调用"父类"构造函数
    Person.apply(this,[firstname,lastname])
    this.number = number;
}

var john = new Policemen("John","Doe",1234)
console.log(john.number) //1234
console.log(john.greet()) //Hello John Doe

上述代码在可读性上有了很大的提高,并且Policemen函数看起来有了点真正的构造函数的味道。但是上面的代码仍不够完美,比如当Policement的构造参数多了,Policemen自身也要修改,将这些参数透传给”父类“,这显然不利于程序扩展,因此,可以对上述代码再进行一次修改

function Policemen(options){ //需要传入”父类“需要的参数
    //调用"父类"构造函数
    Person.call(this,options)
    this.number = option.number;
}

var john = new Policemen({firstname: "John", lastname:"Doe", number:1234})
console.log(john.number) //1234
console.log(john.greet()) //Hello John Doe

Overload Pitfalls

还是上面的例子,我们可以为Person定义两个参数类型不同的greet函数,

function Person(firstname, lastname){
    this.firstname = firstname;
    this.lastname = lastname;
}
Person.prototype.greet = function(){
     console.log('Hello ' + this.firstname + ' ' + this.lastname);
}
Person.prototype.greet = function(msg){
    console.log(msg);
}

var john = new Person("John","Doe");
john.greet(); //undefined
john.greet("Hello");  //Hello

按照其它语言的经验,两个greet的函数签名不同,应该会各自调用不同版本的函数,而在JS中,第二个greet函数则会覆盖第一个函数。这点要格外注意,解决办法是定义不同名称的函数。

Reflection

JavaScript的设计如此灵活,自然少不了强大的反射能力,这种动态语言所具备的优良特性对于静态语言来说简直是梦寐以求的神器,但是也会带来一定的安全问题,这里就不展开讨论了。这一节我们来试验一下JavaScript中关于反射相关的API

继续上面的例子,在JS中我们可以很容易反射出john的所有成员

var person = {
    firstname: 'Default',
    lastname: 'Default',
    getFullName: function(){
        var fullname = this.firstname + ' ' + this.lastname;
        return fullname;
    }
}
var john = {
    firstname: 'John',
    lastname: 'Doe',
}

john.__proto__ = person
for(var key in john){
    console.log(key+": "+john[key])
}

由于john__proto__指向person,上述代码会打印出john所有属性,连同其在__proto__上的方法:

firstname: John
lastname: Doe
getFullName: function (){
    var fullname = this.firstname + ' ' + this.lastname;
    return fullname;
}

如果只想输出自己的属性,需要使用hasOwnProperty

for(var key in john){
    if(john.hasOwnProperty(prop)){
        console.log(key+": "+john[key])
    }
}
//firstname: John
//lastname: Doe

同样,可以单独反射其__proto__的成员

for(var key in john.__proto__){
    if(john.hasOwnProperty(prop)){
        console.log(key+": "+john[key])
    }
}
/*
firstname: Default
lastname: Default
getFullName: function (){
    var fullname = this.firstname + ' ' + this.lastname;
    return fullname;
}
*/

反射的另一个用处是做运行时的类型检查,在JavaScript中使用typeof查看对象的类型

var a = "Hello"
console.log(typeof a) //string
var b = {}
console.log(typeof b) //object
var c = []
console.log(typeof c) //object
console.log(Object.prototype.toString.call(c)) //[Object Array]

最后我们可以用Object上的一些API来实现“merge”两个Object

const obj1 = {
  name: 'Jason',
  greet: msg => {
    console.log(msg);
  }
};
const obj2 = {};

Object.getOwnPropertyNames(obj1).forEach(name => {
  console.log(name); //name, greet
  const descriptor = Object.getOwnPropertyDescriptor(obj1, name);
  Object.defineProperty(obj2, name, descriptor);
});
console.log(obj2.name);
obj2.greet('msg');

Resource