1. 程式人生 > >JavaScript對象、原型鏈、繼承和閉包

JavaScript對象、原型鏈、繼承和閉包

當我 tle 向上 之前 就會 控制 操作 rul 簡單介紹

什麽是面向對象編程

說到面向對象,每個人的理解可能不同,以下是個人對面向對象編程的理解:

對於面向對象編程這幾個字每一個前端都應該非常熟悉,但是到底應該如何去理解他呢?
就編程方式而言,javascrip可以分成兩種發方式:面向過程和面向對象,所謂的面向過程就是比較常用的函數式編程,通過大量的單體函數調用來組織代碼,這種方式使得代碼結構紊亂,不方便代碼閱讀,由於定義了大量的函數而耗費內存;而面向對象則是另一種開發方式,面向對象的標誌就是類,但是js中卻沒有類的概念,那麽js中的面向對象又改如何理解呢?我們可以將構造函數(構造器)理解為類,通過定義構造函數,實例化對象,基於prototype實現對象繼承,改變傳餐數量控制方法功能的方式,同樣體現面向對象編程封裝、繼承、多態的特點。

對象

無序屬性的集合,其屬性可以包含基本值、對象或者函數

js中有萬物皆對象的說法,那麽當我們看到 var a = ‘asdfqwert‘,那麽這種說法還正確嗎?

var a = ‘asdfqwert‘
typeof a      //‘string‘

類型判斷是返回string,那麽說明a是一個簡單類型,但是實際我們卻可以調用a.split a.toString 等方法,這個時候實際會在內部把a 轉為String對象的實例,由於對象、函數本身就是Object,那麽說萬物皆對象完全沒有問題。

那麽創建對象的方式又有那些呢?

創建對象的方式

Object構造函數

var obj1 = new Object(); //或者var obj = new Object({name:‘object‘,age;12});
obj.name=‘object‘;
obj.age = 12

對象字面量

var obj2 = {
    name:‘Join‘,
    age:23,
    getName:function(){
        return this.age
    }
}

通過Object構造函數和對象字面量的方式創建一個實例對象,比較適合創建單個或者少量實例對象,通過這種方式創建大量的相似對象時,代碼重復,冗余。

工廠模式

function Fn(name,age){
    var obj = ne Object();
    obj.name = name;
    obj.age = age;
    obj.getName = function(){
        return this.name
    }
    return obj
}

var person1 = Fn(‘11‘,10)
var person2 = Fn(‘11‘,1yu0)

工廠模式實際是一個包含參數的函數封裝,調用後返回擁有參數和方法的對象,多次調用解決重復代碼問題,但是存在對象識別的問題,同時也擁有構造函數模式相同的缺點

構造函數模式

function Fn(name,age){
    this.name = name;
    this.age = age;
    this.getName = function(){
        return this.name;
    }
}
var person1 = new Fn(‘啦啦‘,18)
var person2 = new Fn(‘菲菲‘,17)

通過自定義構造函數可以創建特定類型,具有類似屬性和功能的的實例對象。例如,通過Fn創建出大量不同姓名,不同年齡,但是都有獲取實例姓名屬性的實例個體。只需要通過 new 關鍵字實例化就可以了,代碼量相比於字面量的方式大大減少。

但是,其缺點就是在new的過程中多個實例相同(共用)的屬性和方法在每個實例上保留一份,耗費內存。

提到new,這裏說一下new的過程,發生了什麽,實際上以下代碼體現了new的過程

//構造函數A,new 一個A的實例a
function A (){
    this.name = 111
}
var a = new A(‘梅梅‘)

//new 的過程
var obj = new Object(); //創建一個新對象
obj.__proto__ = A.prototype;  //讓obj的__proto__指向A.prototype
A.call(obj)   //在obj的作用環境執行構造函數的代碼,使this指向obj
return obj    //返回實例對象

原型模式

function Fn(){
   
}
Fn.prototype.name=‘啦啦‘
Fn.prototype.getName = function(){
    return this.name;
}
var a = new Fn()
var b = new Fn()

a.getName() //啦啦
b.getName() //啦啦
a.getName == b.getName  //true

函數創建會擁有prototype屬性,指向一個存放了某些特定實例的共用屬性和方法的對象。構造函數Fn的實例a和b都可以調用Fn原型對象上的方法getName,得到的name值是一樣的。a.getName == b.getName,說明調用的是同一個getName方法,在構造函數的原型對象上,所以原型對象上的方法和屬性是其所有實例共用的。
通過原型對象可以解決構造函數模式提到每個實例保留一份的內存耗費問題

構造函數模式和原型模式組合

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

Fn.prototype.getName = function(){
    return this.name;
}
var a = new Fn(‘啦啦‘,23)
var b = new Fn(‘呼呼‘,15)

a.getName() //啦啦
b.getName() //呼呼

通過兩者組合的方式可以實現每個實例對象擁有一份自己的實例屬性,同時還能夠訪問構造函數原型對象上的共享的原型屬性和原型方法。同時擁有構造函數模式和原型模式創建對象的優點。

動態原型模式和寄生構造函數模式這裏不多說,有興趣的可以自己看看

穩妥構造函數模式

function Fn(name,age){
    var obj = new Object();
    var name = name;
    var age = age
    obj.getName = function(){
        return name
    }
    obj.setName = function(n){
        name = n
    }
    return obj;
}
var fn = Fn(‘家家‘,23)
fn.getName()  //‘家家‘
fn.setName(‘lala‘)
fn.getName()  //‘lala‘

所謂的穩妥構造函數實際上本意是起到保護變量的作用,只能通過對象提供的方法操作構造函數傳入的原始變量值,防止通過其他途徑改變變量的值,其實質是閉包。

作用域鏈

作用域

作用域分為全局作用域和函數作用域,簡單粗暴的理解就是,變量的可訪問區域。

var a = 111;
function b(){
    var c = 222
}
b()
console.log(a) //111
console.log(c) //報錯 c is not undefined

同樣是定義變量,但是定義在函數內部的變量,在函數外部無法訪問,說明c的可訪問區域只在函數內部。

執行環境(execution context)
執行環境始終是this關鍵字的值,它是擁有當前所執行代碼的對象的引用,函數的每次調用都會創建一個新的執行環境。

當執行流進入一個函數時,函數的執行環境就會被推入環境棧頂端,執行結束後環境棧將其彈出並銷毀,把控制權還給之前的執行環境。如下圖所示:

技術分享圖片

變量對象(函數執行時轉為活動對象)
每個執行環境都有一個與之關聯的變量對象,環境中的變量和函數都保存在對象中。當代碼執行結束並且環境中變量和函數不被其他對象引用,那麽變量對象隨著執行環境銷毀。

作用域鏈
當代碼在環境中執行時,會創建一個變量對象的作用域鏈,其作用就是保證對執行環境有權訪問的變量和函數做有序訪問。作用域鏈的最前端永遠都是當前執行的代碼所在執行環境的變量對象。作用域鏈中下一個變量對象為外層環境的變量對象,依次類推至最後全局對象。

function fn1(){
    var a = 1;
    return function fn2(){
        return a;
    }
}
var b = fn1()
var c = b() //1

技術分享圖片

上圖所表現的就是fn1的執行環境,當外層函數調用時,內層函數的作用域鏈就已經創建了。

執行環境、變量對象、作用域鏈之間的關系

上圖體現執行環境、作用域鏈和變量對象(活動對象)之間的關系。調用fn1返回函數fn2 ,fn2內部訪問了fn1中定義的a,當fn2調用時返回a=1,但是fn2中沒有定義1,所以會順著作用域鏈向上找,直至找到a,沒有則報錯。

因為這裏應用了閉包,當fn1執行結束,fn1的執行環境會銷毀,但是由於a被fn2訪問,所以fn1作用域鏈會斷開,但是變量對象保留,供fn2訪問。

原型鏈

原型 prototype

每個函數在創建的時候都會有一個prototype屬性,該屬性指向一個對象,用於存放可以被有所屬函數創建出來的所有實例共用的屬性和方法。

function A(){}
A.prototype.name="共用屬性"
A.prototype.getName=function(){
    return this.name
}
var a = new A();
var b = new A();
a.getName() //"共用屬性"
b.getName() // "共用屬性"

console.log(a.name == b.name,a.getName == b.getName) //true true

以上代碼體現的就是原型對象的特點,所有A構造函數的實例共用其原型對象上的方法和屬性,同時由原型對象組成的原型鏈也是實現繼承的基礎。

原型的鏈理解

原型鏈的形成離不開構造函數、原型對象和實例,這裏簡單介紹下原型鏈的形成原理:

每個構造函數在創建的時候都會有一個prototype(原型)屬性,該屬性指向的對象默認包含constructor(構造函數)屬性,constructor同樣是一個指針,指向prototype所在的函數,當然我們可以想prototype添加其他屬性和方法。當我們創建一個構造函數的實例時,該實例對象會包含有一個內部屬性[[prototype]],通常叫__proto__,__proto__指向創建實例的構造函數的原型對象。

當我們讓一個函數的prototype指向一個實例,那麽會發生什麽?

function A(){
    this.name = ‘lala‘;
}
A.prototype.sayHi = function(){
    console.log(‘hi‘)
}
function B(){}
B.prototype = new A();

var instance = new B()
console.log(instance)

技術分享圖片

上圖為B的實例instance的輸出結果。
instance.__proto__指向 B.prototype,B.prototype == new A() A的實例,(new A()).__proto__ = A.prototype,即:
instance.__proto__ == B.prototype.__proto__ == A.prototype,所以B的實例可以通過這種鏈的形式訪問A的原型方法和原型屬性,由於A實例化的時候復制了A的實例屬性,所以instance可以訪問復制到B.prototype上得name屬性。

javascript的基於原型鏈的繼承原理也是這樣的,下面對繼承的原理就不再重復說明。
通過下圖可以更加直觀的反應原型鏈和繼承的原理:

技術分享圖片

繼承

類的三部分

構造函數內的:供實例化對象復制用的
構造函數外的:通過點語法添加,供類使用,實力化對象無法訪問
類的原型中的:供所有實例化對象所共用,實例化對象可以通過其原型鏈間接的訪問
(__proto__:總是指向構造器所用的原型,constructor:構造函數)

類式繼承

  • 實現方式:類式繼承需要將第一個類的實例賦值給第二個類的原型
  • 繼承原理:父類的實例賦值給子類的原型,那麽這個子類的原型同樣可以訪問父類原型上的屬性和方法與從父類構造函數中復制的屬性和方法
  • 本質:重寫子類的原型對象(導致子類的圓形對象中的constructor的指向發生改變,變為父類構造函數)
  • 特點:實例即是子類的實例也是父類的實例;父類新增的原型方法和原型屬性實例均可以訪問,並且是所有實例共享
  • 缺點:如果父類中的共有屬性是引用類型,那麽就會被子類的所有實例共用,其中一個子類的實例改變了從父類繼承來的引用類型則會直接影響其他子類;創建子類實例時,無法向父類構造函數傳參;無法實現多繼承
function SuperClass(){ //父類
    this.superName = ‘類式繼承-父類‘;
    this.superArr = [‘a‘,‘b‘];
}
SuperClass.prototype.getSuperName = function(){ //父類原型方法
    return this.superName;
}
function SubClass(){ //子類
    this.subName = ‘類式繼承-子類‘;
    this.subArr = [1,2];
}
var superInstance = new SuperClass(); //父類實例
SubClass.prototype = superInstance;  //繼承父類
SubClass.prototype.getSubName = function(){ //子類原型方法
    return this.subName;
}
var instance1 = new SubClass(); //子類的實例
var instance2 = new SubClass();//子類的實例
console.log(instance1.superArr,
    instance1.getSuperName(),
    instance1.subArr,
    instance1.getSubName()); //‘a,b‘ ‘類式繼承-父類‘ [1,2] ‘類式繼承-子類‘
    
instance1.subArr.push(3); //更改子類的實例屬性引用值
instance1.superArr.push(‘c‘);//更改父類的實例屬性引用值

console.log(instance2.superArr,instance2.subArr) // ‘a,b,c‘ [1,2] 由於是引用類型所以其他實例上的屬性也被更改

構造函數繼承

  • 實現方式:在子類內對父類執行SuperClass.call(this,name...)語句同時傳入this和指定參數
  • 實現原理:通過call函數改變執行環境(this的指向),使得子類復制得到父類構造函數內的屬性並進行自定賦值,同時子類也可以有自己的屬性(沒有用到原型)
  • 本質:父類構造函數增強子類實例
  • 特點:改善類式繼承中的父類的引用屬性被所有子類實例共用的問題;可以在子類內call多個父類構造函數來實現多繼承;子類實例化時可向父類傳參
  • 缺點:只能繼承父類的實例屬性和實例方法,無法繼承父類的原型屬性和原型方法;通過子類new出來的實例只是子類的實例,不是父類的實例;影響性能
function SuperClassA(name){
    this.superName = name;
    this.books = [‘aa‘,‘bb‘];
}
SuperClassA.prototype.getSuperName = function(){
    return this.superName;
}
function SubClassA(name,age){
    SuperClassA.call(this,name);//通過call繼承,new一次就復制一次
    this.age = age;
}
var instance1 = new SubClassA(‘莉莉‘,21);
var instance2 = new SubClassA(‘佳佳‘,23);
instance1.books.push(‘cc‘);
console.log(instance1.superName,instance1.age,instance1.books,instance2.superName,instance2.age,instance2.books,instance1.getSuperName);
//莉莉 , 21 , ["aa", "bb", "cc"] , 佳佳 , 23 , ["aa", "bb"] , undefined
console.log(SubClassA.prototype) //只包含constructor屬性的對象

組合繼承

  • 實現方式:在子類構造函數中執行弗雷構造函數,在子類原型上實例化父類
  • 實現原理:類式繼承+構造函數繼承,通過call函數改變執行環境(this的指向),使得子類復制得到父類構造函數內的屬性並進行自定賦值,同時子類也可以有自己的屬性,再通過類式繼承的原型來繼承父類的原型屬性和原型方法
  • 本質:父類構造函數增強子類實例+重寫子類原型對象
  • 特點:實例屬性,實例方法,原型屬性,原型方法均可繼承;解決引用屬性共享的問題;可向父類傳參;new出來的實例即是父類實例也是子類實例
  • 缺點:耗內存
function SuperClassB(name){
    this.superName = name;
    this.books = [‘aa‘,‘bb‘];
}
SuperClassB.prototype.getSuperName = function(){
    return this.superName;
}
function SubClassB(name,age){
    SuperClassB.call(this,name);//通過call繼承,new一次就復制一次
    this.age = age;
}
 
var instanceSuperClassB = new SuperClassB();
SubClassB.prototype = instanceSuperClassB;
 
var instance1 = new SubClassB(‘莉莉‘,21);
var instance2 = new SubClassB(‘佳佳‘,23);
 
instance1.books.push(‘cc‘);
console.log(instance1.superName,instance1.age,instance1.books,instance2.superName,instance2.age,instance2.books,instance1.getSuperName());
//莉莉 , 21 , ["aa", "bb", "cc"] , 佳佳 , 23 , ["aa", "bb"] , 莉莉

原型式繼承(只強調子類原型等於父類原型方式的嚴重問題,不推薦)

  • 實現方式:直接原型相等的原型繼承方式,子類原型等於父類原型
  • 實現原理:原型鏈
  • 本質:原型鏈
  • 特點:不推薦
  • 缺點:子類原型等於父類原型的繼承方式會導致修改子類原型會直接反應在父類的原型上;引用類型共用
//父類
function SuperClassC(){
    this.superName = ‘原型繼承-父類‘;
}
SuperClassC.prototype.superTit = ‘父類原型屬性‘;
SuperClassC.prototype.getSuperName = function(){
    return this.superName;
}
 
//-------直接用原型相等繼承-------
function SubClassC(){
    this.subName = ‘原型繼承-子類‘;
}
SubClassC.prototype = SuperClassC.prototype;
 
var instance0 = new SubClassC();
 
//修改子類原型
SubClassC.prototype.subTit1 = ‘直接原型-子類原型屬性‘;
//對子類原型對象的修改同時反映到了父類原型
console.log(SuperClassC.prototype.subTit1);  //‘直接原型-子類原型屬性‘,修改子類原型父類受到影響

子類原型直接等於父類原型的繼承方式雖然能夠實現原型對象的繼承,但是卻有嚴重問題。所以並不推薦這種寫法,如果只想繼承原型對象上得屬性和方法,可以通過間接的方式,如下

function instanceSuper(obj){
    var Fn = function(){};
    Fn.prototype = obj;
    return new Fn()
}
SubClassC.prototype = instanceSuper(SuperClassC.prototype);

var insranceSub = new SubClassC();
SubClassC.prototype.subTit = ‘子類屬性‘
console.log(SubClassC.prototype)
console.log(SuperClassC.prototype)

技術分享圖片

以上代碼通過間接的方式同樣實現了只繼承父類的原型方法和屬性,但是修改子類原型,打印子類原型和父類原型後顯示父類原型並為被修改。這種方式跟類式繼承很像,不同點只是賦值給子類原型的實例是通過一個函數封裝返回的實例。

繼承的方式還有寄生式繼承、寄生組合式繼承,是對以上繼承方式的封裝,感興趣的可以自己找資料看下。

閉包

閉包是指有權限訪問另一個函數作用域中的變量的函數。

上面在說作用域鏈的時候提到內部函數可以訪問外部函數的變量,由於函數執行時會創建作用域鏈,當前函數的活動對象處於最前端,當訪問變量時如果當前函數的活動對象內沒有則會查找作用域鏈的下一個也就是外層函數的變量對象,依次向上直到找到或者查找完全局對象仍沒有則報錯,然而外部函數卻無法訪問內部函數的變量。然而閉包的特點就是訪問其他函數內的變量。當函數執行結束後其執行環境會、變量對象都會銷毀,但是當其內的變量被其他函數引用訪問時,即使執行環境銷毀,作用域鏈斷開,但是變量對象依然存在,供引用了其內變量的函數去訪問。

function fn(){
    var i = 1;
    return function(){
        console.log(++i)
    }
}
var fn1 = fn();
fn1(); //2
fn1(); //3
fn1 = null

fn執行結束,其執行環境和所有變量應該隨之銷毀,但是當執行fn1的時候,輸出fn內i的值,fn1沒執行一次輸出i自加一的結果。說明fn執行結束後,其執行環境相關連的變量對象並沒有銷毀,而是供fn1引用訪問。如下圖所示:

技術分享圖片

以上就是閉包的原理,fn1作為外部函數,但是執行時仍然可以訪問fn內的變量。

常見demo

1.異步函數調用傳參,setTimeout 事件執行函數

function fn(a,b){
    return function(){
        console.log(a,b)
    }
}
setTimeout(fn(1,2),1000)
dom.addEventListener(‘click‘,fn(1,3),false)

2.解決異步導致的執行函數參數在執行時真實值不符合期望

var arr = []
for(var i =0; i < 3; i++){
    arr[i] = function(i){
        console.log(i)
    }
}
arr[0]()  //3
arr[1]()  //3
arr[2]()  //3

改進,閉包+自執行函數實現i的順序輸出

var arr = []
for(var i =0; i < 3; i++){
    arr[i] = (function(i){
        return function(i){
           console.log(i)
        }
    })(i)
}
arr[0]()  //0
arr[1]()  //1
arr[2]()  //2

3.封裝私有函數插件,避免命名沖突

var obj = (function(){
    var name = ‘111‘
    var obj = {
        setName:function(){
            name = ‘222‘
        },
        getName:function(){
            return name
        }
    }
    return obj
})()
obj.setName()
obj.getName()   //222

外部無法訪問name,只能通過obj對象訪問name

閉包的優缺點

作用:

  1. 訪問其他函數內部的變量
  2. 使變量常駐內存,在特定情況下實現共享(多個閉包函數訪問同一個變量)
  3. 封裝變量,避免命名沖突,多用在插件封裝,閉包可以將不想暴漏的變量封裝成私有變量

缺點:

  1. 當大量變量常駐內存時會導致內存耗費過量,影響速度,在ie9-版本會導致內存泄漏
  2. 不當使用閉包會導致被引用變量發生非預期變更

JavaScript對象、原型鏈、繼承和閉包