1. 程式人生 > >JavaScript物件、原型鏈、繼承、閉包

JavaScript物件、原型鏈、繼承、閉包

什麼是面向物件程式設計

說到面向物件,每個人的理解可能不同,以下是個人對面向物件程式設計的理解:

對於面向物件程式設計這幾個字每一個前端都應該非常熟悉,但是到底應該如何去理解他呢?
就程式設計方式而言,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. 不當使用閉包會導致被引用變數發生非預期變更