前端基础进阶(九):详解面向对象、构造函数、原型与原型链

.

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

为了帮助你们可以更加直观的学习和了解面向对象,我会用尽可能简单易懂的描述来展现面向对象的相关知识。而且也准备了一些实用的例子帮助你们更加快速的掌握面向对象的真谛。前端

  • jQuery的面向对象实现
  • 封装拖拽
  • 简易版运动框架封装

这可能会花一点时间,可是却值得期待。因此若是有兴趣的朋友能够来简书和公众号关注我。java

而这篇文章主要来聊一聊关于面向对象的一些重要的基本功。segmentfault

1、对象的定义

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

也就是说,在JavaScript中,对象无非就是由一些列无序的key-value对组成。其中value能够是基本值,对象或者函数。app

// 这里的person就是一个对象
var person = {
    name: 'Tom',
    age: 18,
    getName: function() {},
    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;
    }
}
访问对象的属性和方法

假如咱们有一个简单的对象以下:this

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

当咱们想要访问他的name属性时,能够用以下两种方式访问。

person.name

// 或者
person['name']

若是咱们想要访问的属性名是一个变量时,经常会使用第二种方式。例如咱们要同时访问person的name与age,能够这样写:

['name', 'age'].forEach(function(item) {
    console.log(person[item]);
})
这种方式必定要重视,记住它之后在咱们处理复杂数据的时候会有很大的帮助。
2、工厂模式

使用上面的方式建立对象很简单,可是在不少时候并不能知足咱们的需求。就以person对象为例。假如咱们在实际开发中,不只仅须要一个名字叫作TOM的person对象,同时还须要另一个名为Jake的person对象,虽然他们有不少类似之处,可是咱们不得不重复写两次。

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

var perJake = {
    name: 'Jake',
    age: 22,
    getName: function() {
        return this.name
    }
}

很显然这并非合理的方式,当类似对象太多时,你们都会崩溃掉。

咱们可使用工厂模式的方式解决这个问题。顾名思义,工厂模式就是咱们提供一个模子,而后经过这个模子复制出咱们须要的对象。咱们须要多少个,就复制多少个。

var createPerson = function(name, age) {

    // 声明一个中间对象,该对象就是工厂模式的模子
    var o = new Object();

    // 依次添加咱们须要的属性与方法
    o.name = name;
    o.age = age;
    o.getName = function() {
        return this.name;
    }

    return o;
}

// 建立两个实例
var perTom = createPerson('TOM', 20);
var PerJake = createPerson('Jake', 22);

相信上面的代码并不难理解,也不用把工厂模式看得太太高大上。很显然,工厂模式帮助咱们解决了重复代码上的麻烦,让咱们能够写不多的代码,就可以建立不少个person对象。可是这里还有两个麻烦,须要咱们注意。

第一个麻烦就是这样处理,咱们没有办法识别对象实例的类型。使用instanceof能够识别对象的类型,以下例子:

var obj = {};
var foo = function() {}

console.log(obj instanceof Object);  // true
console.log(foo instanceof Function); // true

所以在工厂模式的基础上,咱们须要使用构造函数的方式来解决这个麻烦。

3、构造函数

在JavaScript中,new关键字可让一个函数变得不同凡响。经过下面的例子,咱们来一探new关键字的神奇之处。

function demo() {
    console.log(this);
}

demo();  // window
new demo();  // demo

为了可以直观的感觉他们不一样,建议你们动手实践观察一下。很显然,使用new以后,函数内部发生了一些变化,让this指向改变。那么new关键字到底作了什么事情呢。嗯,其实我以前在文章里用文字大概表达了一下new到底干了什么,可是一些同窗好奇心很足,总指望用代码实现一下,我就大概以个人理解来表达一下吧。

// 先一本正经的建立一个构造函数,其实该函数与普通函数并没有区别
var Person = function(name, age) {
    this.name = name;
    this.age = age;
    this.getName = function() {
        return this.name;
    }
}

// 将构造函数以参数形式传入
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声明建立实例,这里的p1,实际接收的正是new中返回的res
var p1 = New(Person, 'tom', 20);
console.log(p1.getName());

// 固然,这里也能够判断出实例的类型了
console.log(p1 instanceof Person); // true
JavaScript内部再经过其余的一些特殊处理,将 var p1 = New(Person, 'tom', 20); 等效于 var p1 = new Person('tom', 20);。就是咱们认识的new关键字了。具体怎么处理的,我也不知道,别刨根问底了,一直回答下去太难 - -!

老实讲,你可能很难在其余地方看到有如此明确的告诉你new关键字到底对构造函数干了什么的文章了。理解了这段代码,你对JavaScript的理解又比别人深入了一分,因此,一本正经厚颜无耻求个赞可好?

固然,不少朋友因为对于前面几篇文章的知识理解不够到位,会对new的实现表示很是困惑。可是老实讲,若是你读了个人前面几篇文章,必定会对这里new的实现有似曾相识的感受。并且我这里已经尽力作了详细的注解,剩下的只能靠你本身了。

可是只要你花点时间,理解了他的原理,那么困扰了无数人的构造函数中this到底指向谁就变得很是简单了。

因此,为了可以判断实例与对象的关系,咱们就使用构造函数来搞定。

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

var p1 = new Person('Ness', 20);
console.log(p1.getName());  // Ness

console.log(p1 instanceof Person); // true

关于构造函数,若是你暂时不可以理解new的具体实现,就先记住下面这几个结论吧。

  • 与普通函数相比,构造函数并无任何特别的地方,首字母大写只是咱们约定的小规定,用于区分普通函数;
  • new关键字让构造函数具备了与普通函数不一样的许多特色,而new的过程当中,执行了以下过程:

    1. 声明一个中间对象;
    2. 将该中间对象的原型指向构造函数的原型;
    3. 将构造函数的this,指向该中间对象;
    4. 返回该中间对象,即返回实例对象。
4、原型

虽然构造函数解决了判断实例类型的问题,可是,说到底,仍是一个对象的复制过程。跟工厂模式很有类似之处。也就是说,当咱们声明了100个person对象,那么就有100个getName方法被从新生成。

这里的每个getName方法实现的功能实际上是如出一辙的,可是因为分别属于不一样的实例,就不得不一直不停的为getName分配空间。这就是工厂模式存在的第二个麻烦。

显然这是不合理的。咱们指望的是,既然都是实现同一个功能,那么能不能就让每个实例对象都访问同一个方法?

固然能,这就是原型对象要帮咱们解决的问题了。

咱们建立的每个函数,均可以有一个prototype属性,该属性指向一个对象。这个对象,就是咱们这里说的原型。

当咱们在建立对象时,能够根据本身的需求,选择性的将一些属性和方法经过prototype属性,挂载在原型对象上。而每个new出来的实例,都有一个__proto__属性,该属性指向构造函数的原型对象,经过这个属性,让实例对象也可以访问原型对象上的方法。所以,当全部的实例都可以经过__proto__访问到原型对象时,原型对象的方法与属性就变成了共有方法与属性。

咱们经过一个简单的例子与图示,来了解构造函数,实例与原型三者之间的关系。

因为每一个函数均可以是构造函数,每一个对象均可以是原型对象,所以若是在理解原型之初就想的太多太复杂的话,反而会阻碍你的理解,这里咱们要学会先简化它们。就单纯的剖析这三者的关系。
// 声明构造函数
function Person(name, age) {
    this.name = name;
    this.age = age;
}

// 经过prototye属性,将方法挂载到原型对象上
Person.prototype.getName = function() {
    return this.name;
}

var p1 = new Person('tim', 10);
var p2 = new Person('jak', 22);
console.log(p1.getName === p2.getName); // true

图示

经过图示咱们能够看出,构造函数的prototype与全部实例对象的__proto__都指向原型对象。而原型对象的constructor指向构造函数。

除此以外,还能够从图中看出,实例对象实际上对前面咱们所说的中间对象的复制,而中间对象中的属性与方法都在构造函数中添加。因而根据构造函数与原型的特性,咱们就能够将在构造函数中,经过this声明的属性与方法称为私有变量与方法,它们被当前被某一个实例对象所独有。而经过原型声明的属性与方法,咱们能够称之为共有属性与方法,它们能够被全部的实例对象访问。

当咱们访问实例对象中的属性或者方法时,会优先访问实例对象自身的属性和方法。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.getName = function() {
        console.log('this is constructor.');
    }
}

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

var p1 = new Person('tim', 10);

p1.getName(); // this is constructor.

在这个例子中,咱们同时在原型与构造函数中都声明了一个getName函数,运行代码的结果表示原型中的访问并无被访问。

咱们还能够经过in来判断,一个对象是否拥有某一个属性/方法,不管是该属性/方法存在与实例对象仍是原型对象。

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

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

var p1 = new Person('tim', 10);

console.log('name' in p1); // true

in的这种特性最经常使用的场景之一,就是判断当前页面是否在移动端打开。

isMobile = 'ontouchstart' in document;

// 不少人喜欢用浏览器UA的方式来判断,但并非很好的方式

更简单的原型写法

根据前面例子的写法,若是咱们要在原型上添加更多的方法,能够这样写:

function Person() {}

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

除此以外,我还可使用更为简单的写法。

function Person() {}

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

这种字面量的写法看上去简单不少,可是有一个须要特别注意的地方。Person.prototype = {}其实是从新建立了一个{}对象并赋值给Person.prototype,这里的{}并非最初的那个原型对象。所以它里面并不包含constructor属性。为了保证正确性,咱们必须在新建立的{}对象中显示的设置constructor的指向。即上面的constructor: Person

5、原型链

原型对象其实也是普通的对象。几乎全部的对象均可能是原型对象,也多是实例对象,并且还能够同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。所以理解了原型,那么原型链并非一个多么复杂的概念。

咱们知道全部的函数都有一个叫作toString的方法。那么这个方法究竟是在哪里的呢?

先随意声明一个函数:

function add() {}

那么咱们能够用以下的图来表示这个函数的原型链。

原型链

其中add是Function对象的实例。而Function的原型对象同时又是Object原型的实例。这样就构成了一条原型链。原型链的访问,其实跟做用域链有很大的类似之处,他们都是一次单向的查找过程。所以实例对象可以经过原型链,访问处处于原型链上对象的全部属性与方法。这也是foo最终可以访问处处于Object原型对象上的toString方法的缘由。

基于原型链的特性,咱们能够很轻松的实现继承

6、继承

咱们经常结合构造函数与原型来建立一个对象。由于构造函数与原型的不一样特性,分别解决了咱们不一样的困扰。所以当咱们想要实现继承时,就必须得根据构造函数与原型的不一样而采起不一样的策略。

咱们声明一个Person对象,该对象将做为父级,而子级cPerson将要继承Person的全部属性与方法。

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

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

首先咱们来看构造函数的继承。在上面咱们已经理解了构造函数的本质,它实际上是在new内部实现的一个复制过程。而咱们在继承时想要的,就是想父级构造函数中的操做在子级的构造函数中重现一遍便可。咱们能够经过call方法来达到目的。

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

而原型的继承,则只须要将子级的原型对象设置为父级的一个实例,加入到原型链中便可。

// 继承原型
cPerson.prototype = new Person(name, age);

// 添加更多方法
cPerson.prototype.getLive = function() {}

原型链

固然关于继承还有更好的方式。

7、更好的继承

假设原型链的终点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
8、属性类型

在上面的继承实现中,使用了一个你们可能不太熟悉的方法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
}
9、总结

关于面向对象的基础知识大概就是这些了。我从最简单的建立一个对象开始,解释了为何咱们须要构造函数与原型,理解了这其中的细节,有助于咱们在实际开发中灵活的组织本身的对象。由于咱们并非全部的场景都会使用构造函数或者原型来建立对象,也许咱们须要的对象并不会声明多个实例,或者不用区分对象的类型,那么咱们就能够选择更简单的方式。

咱们还须要关注构造函数与原型的各自特性,有助于咱们在建立对象时准确的判断咱们的属性与方法究竟是放在构造函数中仍是放在原型中。若是没有理解清楚,这会给咱们在实际开发中形成很是大的困扰。

最后接下来的几篇文章,我会挑几个面向对象的例子,继续帮助你们掌握面向对象的实际运用。

前端基础进阶系列目录

clipboard.png

相关文章
相关标签/搜索