前端基礎進階(九):詳解面向對象、構造函數、原型與原型鏈

.

若是要我總結一下學習前端以來我遇到了哪些瓶頸,那麼面向對象必定是第一個絕不猶豫想到的。儘管我如今對於面向對象有了一些的瞭解,可是當初的那種似懂非懂的痛苦,依然歷歷在目。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

相關文章
相關標籤/搜索