如果要我总结一下学习前端以来我遇到了哪些瓶颈,那么面向对象一定是第一个毫不犹豫想到的。尽管我现在对于面向对象有了一些的了解,但是当初的那种似懂非懂的痛苦,依然历历在目。

为了帮助大家能够更加直观的学习和了解面向对象,我会用尽量简单易懂的描述来展示面向对象的相关知识。并且也准备了一些实用的例子帮助大家更加快速的掌握面向对象的真谛。

对象的定义

在ECMAScript-262中,对象被定义为 “无序属性的集合,其属性可以包含基本值,对象或者函数”

也就是说,对象是由一系列无序的key-value对组成。其中value可以是基本数据类型,对象,数组,函数等。

// 这里的person就是一个对象
var person = {
    name: 'Tom',
    age: 18,

    // value 为函数
    getName: function() {
        return this.name;
    },

    // value 为对象
    parent: {}
}
创建对象

我们可以通过关键字new来创建一个对象。

var obj = new Object();

也可以通过对象字面量的形式创建一个对象。

var obj = {};

当我们想要给我们创建的对象添加属性与方法时

// 可以这样
var person = {};
person.name = 'TOM';
person.getName = function() {
    return this.name;
}

// 也可以这样
var person = {
    name: 'TOM',
    getName: function() {
        return this.name;
    }
}
访问对象的属性与方法

假如我们有一个简单的对象如下:

var person = {
    name: 'TOM',
    age: 20,
    getName: function() {
        return this.name;
    }
}

当我们想要访问他的name属性时,可以使用如下方式:

person.name

// or
person['name'];

//or
var _name = 'name';
person[_name];

因此当我们想要访问的属性名是一个变量时,可以使用中括号的方式,如下:

['name', 'age'].forEach(function(item) {
    console.log(person[item]);
})
构造函数与原型

在学习函数式的章节中我们知道,当我们封装函数时,其实是在封装一些公共的逻辑与功能,我们通过传入参数的形式达到自定义的效果。而当我们面对具有共同特征的一类事物时,我们可以结合构造函数与原型的方式将这类事物封装成对象。

例如我们将“人”这一类事物封装成为一个对象,那么可以这样做。

// 构造函数
var Person = function(name, age) {
    this.name = name;
    this.age = age;
}
// Person.prototype 为Person的原型,这里在原型上添加了一个方法
Person.prototype.getName = function() {
    return this.name;
}

这样,我们就利用构造函数与原型封装好了一个Person对象。

具体某一个人的特定属性,通常放在构造函数中。 例如此处的name,age,他们的值不是所有人的共同属性,且仅仅属于某一个人。因为每个人的名字与年龄可能都是不一样的。

所有人公共的方法与属性,通常会放在原型对象中。 例如此处的getName,他表示一个共同的动作,访问当前这个人的姓名。

当我们想要使用Person对象创建一个具体的“人”时,我们称这个被创建的“人”为一个 实例

var p1 = new Person('Jake', 20);
var p2 = new Person('Tom', 22);

p1.getName();  // Jake
p2.getName();  // Tom

p1, p2都是根据对象Person创建的实例。我们可以通过原型方法访问到该实例的属性,例如上例中的getName调用。

构造函数其实与普通函数并无区别,首字母大写是一种约定,来表示这是一个构造函数。但是new关键字的存在,让构造函数变得与众不同。在学习高阶函数的章节中,我们探讨了为什么构造函数中的this其实是指向的当前的实例。为了确保读者没有漏掉学习,我们再次温习一下。

构造函数中的this,与原型方法中的this(实例调用该方法时确认),都是指向的是当前的实例。
这是面试中常常会被问及的问题,但是原因是为什么呢?我们可以模拟构new关键的能力,实现一个New方法,来观察一下new关键字到底干了些什么事情。如下:

// 将构造函数以参数形式传入
function New(func) {

    // 声明一个中间对象,该对象为最终返回的实例
    var res = {};
    if (func.prototype !== null) {

        // 将实例的原型指向构造函数的原型
        res.__proto__ = func.prototype;
    }

    // ret为构造函数执行的结果,这里通过apply,将构造函数内部的this指向修改为指向res,即为实例对象
    var ret = func.apply(res, Array.prototype.slice.call(arguments, 1));

    // 当我们在构造函数中明确指定了返回对象时,那么new的执行结果就是该返回对象
    if ((typeof ret === "object" || typeof ret === "function") && ret !== null) {
        return ret;
    }

    // 如果没有明确指定返回对象,则默认返回res,这个res就是实例对象
    return res;
}

我们同样可以使用我们自己封装好的New方法来创建对象。

var Person = function(name) {
    this.name = name;
}

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

var p1 = New(Person, 'Jake');
var p2 = New(Person, 'Tom');

p1.getName(); // Jake
p2.getName(); // Tom

使用New方法声明的实例与new关键字声明的实例拥有同样的能力与特性。

因此,通过对New的封装,我们知道new 关键字在创建实例时经历了如下过程:

  • 先创建一个新的,空的实例对象
  • 将实例对象的原型,指向构造函数的原型
  • 将构造函数内部的this,修改为指向实例
  • 最后返回该实例对象。

如果使用PPrototype指代原型对象,那么构造函数、原型、实例之间有如下关系。

// -> 表示指向
Person.prototype -> PPrototype;
p1.__proto__ -> PPrototype;
p2.__proto__ -> PPrototype;
PPrototype.constructor -> Person

用图例表示如下:

从上面的分析我们可以看出,构造函数的prototype与所有实例的__proto__都指向原型对象。而原型对象的constructor则指向构造函数。

因为在构造函数中声明的变量与方法仅仅只是属于当前实例。因此我们可以 将构造函数中声明的属性与方法称为该实例的私有属性与方法。它们只能被当前实例访问。

而原型中的方法与属性能够被所有的实例访问,因此我们 将原型中声明的属性与方法称为公有属性与方法。

与在原型中添加一个方法不同,当我们将一个方法在构造函数中声明时,每创建一个实例,该方法都会被重新创建一次。而原型中的方法仅仅只会创建一次(这也是我们称其为私有方法的原因之一)。

因此在构造函数中声明私有方法会消耗更多的内存空间。

如果构造函数中声明的私有方法/属性与原型中的公有方法/属性重名,那么会优先访问私有方法/属性。如下例:

function Person(name) {
    this.name = name;
    this.getName = function() {
        return this.name + ', 你正在访问私有方法。'
    }
}

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

var p1 = new Person('Tom', 20);
p1.getName();  // Tom, 你正在访问私有方法

在这个例子中,同时在构造函数与原型中都声明了一个同名方法getName。运行的结果显示原型中的方法并没有被访问。

我们可以通过in来判断一个对象是否拥有某一个方法/属性,无论该方法/属性是否公有。

// 接上例中创建的p1实例
console.log('name' in p1);    // true
console.log('getName' in p1); // true
console.log('gender' in p1);  // false

我们常常使用in的这种特性来判断当前页面所处的环境是否在移动端。

// 特性检测,仅只有移动端环境才支持touchstart事件
var isMobile = 'ontouchstart' in document;
更简单的原型写法

如果我们要在原型上添加很多的方法与属性,我们可以这样写。

function Person() {}
Person.prototype.getName = function() {}
Person.prototype.getAge = function() {}
Person.prototype.sayHello = function() {}

除此之外,我们还可以使用更为简洁的对象字面量的写法来添加原型方法。

Person.prototype = {
    constructor: Person,
    getName: function() {},
    getAge: function() {},
    sayHello: function() {}
}

使用对象字面量能够简化写法,但同时有一个需要特别注意的地方。当我们使用Person.prototype = {} 时其实是将Person的原型指向了一个新的对象{}。如果不做特殊处理,那么将会导致原型对象的丢失。因此我们在这个新的对象中,将它的constructor属性指向构造函数Person,那么就重新建立了正确的对应关系。然后我们就可以放心大胆的使用了。

原型链

原型对象其实也是普通对象。

几乎所有的对象都可以是原型对象,也可以是实例对象,也可以是构造函数,甚至可以身兼多职。

那么当一个对象身兼多职时,那么它就可以被看做原型链中的一个节点。因此理解了原型,原型链并不是一个太复杂的概念。

当一个对象A作为原型时,那么它会有一个constructor属性,指向它的构造函数.A.constructor
当一个对象B作为构造函数时,它会有一个prototype属性,指向它的原型。B.prototype
当一个对象C作为实例时,它会有一个__proto__属性,指向它的原型。C.__proto__
当我们想要判断一个对象foo是否是构造函数Foo的实例时,使用instanceof关键字。返回一个boolean值。

foo instanceof Foo   // true: foo是Foo的实例   false:不是

当我们创建一个对象时,可以使用new Object()来创建。因此Object其实是一个构造函数。而其对应的原型Object.prototype则是原型链的终点。因此:

Object.prototype.__proto__ === null

// 我们知道所有的函数与对象都有一个toString与valueOf方法就是来自于Object.prototype
Object.prototype.toString = function() {}
Object.prototype.valueOf = function() {}

当我们创建函数时,除了可以使用function关键字外,还可以使用Function对象

var add = new Function("a", "b", "return a + b");


// 等价于
var add = function(a, b) {
    return a + b
}

因此我们这里创建的add方法是一个实例,它对应的构造函数是Function, 它的原型是Function.prototype

add.__proto__ === Function.prototype // true

这里还有一个非常特殊的地方,Function同时是Function.prototype的构造函数与实例。

add.__proto__ === Function.prototype  // true
Function.prototype.constructor === Function  // true
Function.prototype === Function.prototype // true
Function.__proto__ === Function.prototype  // true
add instanceof Function   // true  判断add是否是构造函数Function的实例

而与此同时,Function.prototype除了是Function的原型之外,它还是Object.prototype的实例。

Function.prototype.__proto__ === Object.prototype
Function instanceof Function  // true

所有的函数都是构造函数Function的实例。所以有如下关系:

Object.__proto__ === Function.prototype   // true
Object instanceof Function   // true

因此,当我们随便创建一个函数add时,与它相关的原型链可以用图表示如下:

原型链上方法与属性的访问,与作用域链的访问类似,也是一个单向的查找过程。虽然add方法与Object并没有直接的关系,但是他们同处于一条原型链上,因此add可以根据原型链的特点访问到Object上的方法。

function add() {}

add.toString === Object.toString  // true

我们需要注意的是,当构造函数与原型上拥有的同名的方法/属性时,那么当我们用创建的实例访问该方法/属性时,会优先访问构造函数的方法/属性。

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

var p1 = new Person('alex');

// 原型上的方法被覆盖
p1.getName();   // name in Person
实例方法、原型方法、静态方法

在最开始分析New函数的实现时我们已经知道,构造函数中的this其实是指向的新创建的实例。因此当我们通过往this上添加方法与属性时,其实是在往新创建的实例上添加属性与方法。因此构造函数中的方法,我们称之为 实例方法

那么通过prototype添加的方法,将会挂载到原型对象上,因此我们称之为 原型方法

那么什么是静态方法呢?我们在使用jquery的时候,往往会使用一些由构造函数直接调用,而非通过实例调用的方法,例如$.each, $.ajax, $.extend, $.isArray等,这些方法被直接挂载在构造函数上,我们称之为 静态方法。如果大家能够非常准确的区分实例,构造函数与原型,那么就应该能够想到,静态方法不能够通过实例访问。我们只能直接通过构造函数来访问。

function Foo() {
    this.bar = function() {
        return 'bar in Foo';  // 实例方法
    }
}

Foo.bar = function() {
    return 'bar in static'   // 静态方法
}

Foo.prototype.bar = function() {
    return 'bar in prototype'  // 原型方法
}

静态方法又成为工具方法,常用来实现一些常用的,与具体实例无关的功能。例如遍历方法each

继承

假设原型链的终点Object.prototype为原型链的E(end)端,原型链的起点为S(start)端。

通过前面原型链的学习我们知道,处于S端的对象,可以通过S -> E的单向查找,访问到原型链上的所有方法与属性。因此这给继承提供了理论基础。我们只需要在S端添加新的对象,那么新对象就能够通过原型链访问到父级的方法与属性。因此想要实现继承,是一件非常简单的事情。

因为封装一个对象由构造函数与原型共同组成,因此继承也会分别有构造函数的继承与原型的继承。

假设我们已经封装好了一个父类对象Person。如下。

var Person = function(name, age) {
    this.name = name;
    this.age = age;
}

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

Person.prototype.getAge = function() {
    return this.age;
}

构造函数的继承比较简单,我们可以借助call/apply来实现。假设我们要通过继承封装一个Student的子类对象。那么构造函数可以如下实现。

var Student = function(name, age, grade) {
    // 通过call方法还原Person构造函数中的所有处理逻辑
    Student.call(Person, name, age);
    this.grade = grade;
}


// 等价于
var Student = function(name, age, grade) {
    this.name = name;
    this.age = age;
    this.grade = grade;
}

原型的继承则稍微需要一点思考。首先我们应该考虑,如何将子类对象的原型加入到原型链中?我们只需要让子类对象的原型,成为父类对象的一个实例,然后通过__proto__就可以访问父类对象的原型。这样就继承了父类原型中的方法与属性了。

因此我们可以先封装一个方法,该方法根据父类对象的原型创建一个实例,该实例将会作为子类对象的原型。

function create(proto, options) {
    // 创建一个空对象
    var tmp = {};

    // 让这个新的空对象成为父类对象的实例
    tmp.__proto__ = proto;

    // 传入的方法都挂载到新对象上,新的对象将作为子类对象的原型
    Object.defineProperties(tmp, options);
    return tmp;
}

简单封装了create对象之后,我们就可以使用该方法来实现原型的继承了。

Student.prototype = create(Person.prototype, {
    // 不要忘了重新指定构造函数
    constructor: {
        value: Student
    }
    getGrade: {
        value: function() {
            return this.grade
        }
    }
})

那么我们来验证一下我们这里实现的继承是否正确。

var s1 = new Student('ming', 22, 5);

console.log(s1.getName());  // ming
console.log(s1.getAge());   // 22
console.log(s1.getGrade()); // 5

全部都能正常访问,没问题。在ECMAScript5中直接提供了一个Object.create方法来完成我们上面自己封装的create的功能。因此我们可以直接使用Object.create.

Student.prototype = create(Person.prototype, {
    // 不要忘了重新指定构造函数
    constructor: {
        value: Student
    }
    getGrade: {
        value: function() {
            return this.grade
        }
    }
})

完整代码如下:

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

function Student(name, age, grade) {
    // 构造函数继承
    Person.call(this, name, age);
    this.grade = grade;
}

// 原型继承
Student.prototype = Object.create(Person.prototype, {
    // 不要忘了重新指定构造函数
    constructor: {
        value: Student
    }
    getGrade: {
        value: function() {
            return this.grade
        }
    }
})


var s1 = new Student('ming', 22, 5);

console.log(s1.getName());  // ming
console.log(s1.getAge());   // 22
console.log(s1.getGrade()); // 5
属性类型

在上面的继承实现中,使用了一个大家可能不太熟悉的方法defineProperties。并且在定义getGrade时使用了一个很奇怪的方式。

getGrade: {
    value: function() {
        return this.grade
    }
}

这其实是对象中的属性类型。在我们平常的使用中,给对象添加一个属性时,直接使用object.param的方式就可以了,或者直接在对象中挂载。

var person = {
    name: 'TOM'
}

在ECMAScript5中,对每个属性都添加了几个属性类型,来描述这些属性的特点。他们分别是

  • configurable: 表示该属性是否能被delete删除。当其值为false时,其他的特性也不能被改变。默认值为true
  • enumerable: 是否能枚举。也就是是否能被for-in遍历。默认值为true
  • writable: 是否能修改值。默认为true
  • value: 该属性的具体值是多少。默认为undefined
  • get: 当我们通过person.name访问name的值时,get将被调用。该方法可以自定义返回的具体值时多少。get默认值为undefined
  • set: 当我们通过person.name = 'Jake'设置name的值时,set方法将被调用。该方法可以自定义设置值的具体方式。set默认值为undefined

需要注意的是,不能同时设置value、writable 与 get、set的值。

我们可以通过Object.defineProperty方法来修改这些属性类型。

下面我们用一些简单的例子来演示一下这些属性类型的具体表现。

configurable

// 用普通的方式给person对象添加一个name属性,值为TOM
var person = {
    name: 'TOM'
}

// 使用delete删除该属性
delete person.name;  // 返回true 表示删除成功

// 通过Object.defineProperty重新添加name属性
// 并设置name的属性类型的configurable为false,表示不能再用delete删除
Object.defineProperty(person, 'name', {
    configurable: false,
    value: 'Jake'  // 设置name属性的值
})

// 再次delete,已经不能删除了
delete person.name   // false

console.log(person.name)    // 值为Jake

// 试图改变value
person.name = "alex";
console.log(person.name) // Jake 改变失败

enumerable

var person = {
    name: 'TOM',
    age: 20
}

// 使用for-in枚举person的属性
var params = [];

for(var key in person) {
    params.push(key);
}

// 查看枚举结果
console.log(params);  // ['name', 'age']

// 重新设置name属性的类型,让其不可被枚举
Object.defineProperty(person, 'name', {
    enumerable: false
})

var params_ = [];
for(var key in person) {
    params_.push(key)
}

// 再次查看枚举结果
console.log(params_); // ['age']

writable

var person = {
    name: 'TOM'
}

// 修改name的值
person.name = 'Jake';

// 查看修改结果
console.log(person.name); // Jake 修改成功

// 设置name的值不能被修改
Object.defineProperty(person, 'name', {
    writable: false
})

// 再次试图修改name的值
person.name = 'alex';

console.log(person.name); // Jake 修改失败

value

var person = {}

// 添加一个name属性
Object.defineProperty(person, 'name', {
    value: 'TOM'
})

console.log(person.name)  // TOM

get/set

var person = {}

// 通过get与set自定义访问与设置name属性的方式
Object.defineProperty(person, 'name', {
    get: function() {
        // 一直返回TOM
        return 'TOM'
    },
    set: function(value) {
        // 设置name属性时,返回该字符串,value为新值
        console.log(value + ' in set');
    }
})

// 第一次访问name,调用get
console.log(person.name)   // TOM

// 尝试修改name值,此时set方法被调用
person.name = 'alex'   // alex in set

// 第二次访问name,还是调用get
console.log(person.name) // TOM

请尽量同时设置get、set。如果仅仅只设置了get,那么我们将无法设置该属性值。如果仅仅只设置了set,我们也无法读取该属性的值。

Object.defineProperty只能设置一个属性的属性特性。当我们想要同时设置多个属性的特性时,需要使用我们之前提到过的Object.defineProperties

var person = {}

Object.defineProperties(person, {
    name: {
        value: 'Jake',
        configurable: true
    },
    age: {
        get: function() {
            return this.value || 22
        },
        set: function(value) {
            this.value = value
        }
    }
})

person.name   // Jake
person.age    // 22
读取属性的特性值

我们可以使用Object.getOwnPropertyDescriptor方法读取某一个属性的特性值。

var person = {}

Object.defineProperty(person, 'name', {
    value: 'alex',
    writable: false,
    configurable: false
})

var descripter = Object.getOwnPropertyDescriptor(person, 'name');

console.log(descripter);  // 返回结果如下

descripter = {
    configurable: false,
    enumerable: false,
    value: 'alex',
    writable: false
}

results matching ""

    No results matching ""