1. 程式人生 > >js系列教程13-原型、原型鏈、作用鏈、閉包全解

js系列教程13-原型、原型鏈、作用鏈、閉包全解

全棧工程師開發手冊 (作者:欒鵬)

【物件、變數】

一個物件就是一個類,可以理解為一個物體的標準化定義。它不是一個具體的實物,只是一個標準。而通過物件例項化得到的變數就是一個獨立的實物。比如通過一個物件定義了“人”,通過“人”這個標準化定義,例項化了“小明”這個人。其中“人”就是物件,“小明”就是變數。例項化的過程就是通過建構函式,來初始化設定標準定義中是具體指。比如在建立“小明”這個變數時,同時設定了他的名稱,性別等資訊。在變數中包含對物件的引用,所以可以通過變數操作物件,或引用物件的函式、屬性。比如“小明”有手有腳(屬性),可以擡頭低頭(函式)。

在新標籤中開啟圖片即可檢視清晰大圖
這裡寫圖片描述

【js中資料的型別】

underfined、null、boolean、string、number為基本數值型別,其他的為引用型別。

所以在淺複製物件時,基本數值型別的複製就是數值複製,而物件的複製是引用複製。修改副本的基本數值型別,不影響源物件,修改引用型別,會影響源物件。

下圖是淺複製的過程
這裡寫圖片描述

【原型、原型鏈】

什麼是派生?

原型物件派生另一個物件,就是建立了原型物件的副本,佔有獨立的記憶體空間,並在副本上新增一個獨特的屬性和方法。

在js系統中,Object物件派生了Number物件、Boolean物件、String物件、Function物件、Array物件、RegExp物件、Error物件、Date物件。當然你可以通過Object物件派生自己的物件。

右鍵圖片-在新標籤頁開啟圖片-檢視清晰圖片
這裡寫圖片描述

在js系統中,Function物件又派生了Number函式、Boolean函式、String函式、Object函式、Function函式、Array函式、RegExp函式、Error函式、Date函式、自定義函式。

這也就是為什麼說函式是一種特殊的物件。因為函式是通過Function物件派生的。

從上面的介紹我們知道一切物件派生於Object物件。Object物件中包含了一系列屬性和方法,可以參考js系列教程2-物件、物件屬性全解

這裡主要介紹__proto__和constructor屬性。由於所有物件都繼承自Object物件,所以所有物件(包括函式)都擁有這兩個屬性。

每個物件的proto屬性是儲存當前物件的原型物件。

所以Number物件、Boolean物件、String物件、Object物件、
Function物件、Array物件、RegExp物件、Error物件、Date物件的proto都指向Object物件。

Number函式、Boolean函式、String函式、Object函式、Function函式、Array函式、RegExp函式、Error函式、Date函式、自定義函式的proto都指向Function物件。

這種派生物件使用__proto__指標儲存對原型物件的連結,就形成了原型鏈。物件通過原型鏈相互連線。所有的物件都在原型鏈上。所有的原型鏈頂端都是Object物件。

我們在原型物件中的一般用來實現所有可能的派生物件或例項變數的公共方法和公共屬性。

構造/例項化

上面講了什麼是派生,原型鏈的形成。

那什麼是例項化呢?

例項化即建立一個例項物件的過程,是先為例項物件開闢一個記憶體,然後新增對原型物件的引用到__proto__ 屬性,然後通過建構函式來對這個佔有獨立空間的物件進行初始化。

你可以用“人”派生了“男人”、“女人”。男人例項化了“小明”、“小王”。

例項化時的記憶體操作如下

這裡寫圖片描述

例項物件在查詢變數時是先查詢本身是否具有該屬性,再沿原型鏈向上查詢,例如

console.log(例項物件.數值變數1)

不能通過例項物件屬性的形式修改原型物件的屬性,這樣只會在例項物件中新增同名屬性。例如

例項物件.數值變數1=12;   //這是為例項物件增加“數值變數1”屬性
例項物件.引用變數1=[1,2];   //這是為例項物件增加“引用變數1”屬性

但是可以通過例項物件屬性的形式修改原型物件引用變數指向的資料。例如

例項物件.引用變量2.append(1);   //修改物件2空間的資料

可以通過例項物件__proto__ 屬性獲取原型物件的引用再修改屬性。或者通過建構函式的prototype屬性獲取原型物件的引用再修改屬性。

例項物件.__proto__.數值變數1=12;  //修改原型物件的屬性

所以建構函式中用this來獲取原型物件的屬性。

function A()
{
    this.aa=1;
    this.bb='aa';
    this.cc=['aa0'];
}

上面的函式作為建構函式,就是新增或修改了原型物件的三個屬性。對於建構函式中新增的屬性,這些屬性儲存在例項化的物件中,而不是儲存在原型物件的屬性當中。對於修改的屬性,屬性保留原來的儲存位置。

準確說法應該是這個佔有獨立空間的副本也是一個物件,這個指向副本的連結才是變數,叫做引用變數。所以變數也可以進行例項化,其實例項化的是變數指向的副本物件。這個我就把副本物件叫做變數以區分例項化和派生。

右鍵圖片-在新標籤頁開啟圖片-檢視清晰圖片
這裡寫圖片描述

所以在js中要想例項化一個物件,進而建立一個變數的過程都需要有一個原型物件,和一個建構函式。我們把這個原型物件叫做函式的構造繫結物件,把函式叫做原型物件的建構函式。

要注意區分函式的原型物件是Function物件

為了表達這種物件與建構函式的緊密關係,js在在物件中使用constructor屬性儲存當前物件的建構函式的引用,在建構函式中使用prototype儲存對物件的引用。物件例項化的變數中,constructor指向建構函式、__proto__指向這個物件。我們也可以稱這個物件是這個變數的原型物件。

我們可以通過物件例項訪問儲存在原型中的值,但卻不能通過物件例項重寫原型中的值。

而我們使用函式當做建構函式時,並沒有建立這個原型物件呀?

這是因為在定義函式時,系統除了將函式的proto屬性指向Function物件外,還會自動由Object物件派生了一個物件,作為這個函式的構造繫結物件,在函式中用prototype指向這個物件。

原型鏈的向上搜尋

派生物件或例項化物件,都要為新物件分配一個獨佔的空間。並且把原型物件的屬性和方法複製一份給新物件,而這個複製僅僅是引用複製(即淺複製)

(其實在js中有很多種構造方式,每種構造方式都有不同的例項過程,在java、c++、c#中,例項化物件的過程是固定的,這也就造成了js的功能複雜性。這裡討論大家常用的例項化方法,即使用new來建立物件的方法)

當然我們也可以在修改原型物件的屬性或替換原型物件。

在查詢屬性或方法時,當前物件沒有查詢到時,會自動在原型物件中查詢,依次沿原型鏈向上。

由於在派生和例項化的過程中,新物件和新變數都會保留對原型物件的引用。當函式呼叫時,需查詢和獲取的變數和元素都會通過原型鏈機制一層層的往上搜索在原型物件或繼承來的物件中獲得。

例項化物件產生新變數的三種方式

1、字面量方式

通過Object函式建立D變數。

var D={}

Object物件通過Object建構函式,例項化獲得變數D。變數D的__proto__指向Object物件。

var a = {};
console.log(a.prototype);  //undefined,未定義
console.log(a.__proto__);  //{},物件Object
console.log(a.constructor); //[Function: Object],函式Object
console.log(a.__proto__.constructor);  //[Function: Object],函式Object

2、建構函式方式

通過建構函式B建立物件C

function B(){}
var C=new B()

B函式定義時,系統會自動由Object物件派生一箇中間物件作為函式的構造繫結物件Temp。通過函式B例項化變數時,就是對Temp物件進行例項化得到變數C。變數C就擁有Temp物件的屬性方法(就是原始Object物件的屬性和方法)+建構函式中的屬性方法。變數的__proto__ 指向這個Temp物件,變數的Constructor指向函式。

var A = function(){};
console.log(A.prototype);  //A {},A函式的構造繫結物件
console.log(A.__proto__);  //[Function],Function物件
var a = new A();
console.log(a.__proto__); //A {},A函式的構造繫結物件
console.log(a.constructor); //[Function: A],函式A
console.log(a.constructor.prototype); //A {},A函式的構造繫結物件
console.log(a.__proto__.__proto__); // {},(Object物件)
console.log(a.__proto__.__proto__.__proto__); //null

3、通過Object.creat建立物件

如圖中通過物件D建立物件E

var E=Object.creat(D)

E變數的原型鏈指向物件D。

var a1 = {'age':1}
var a2 = Object.create(a1);
console.log(a2.__proto__); //Object { age: 1 }
console.log(a2.constructor); //[Function: Object]

案例講解

function Person () {
}
var person1 = new Person();       
Person.prototype.age= 18;
Person.__proto__.name= "小明";
var person2 = new Person(); 
console.log(person1.age);//18
console.log(person2.age); //18
console.log(person2.name);  //未定義

var person1 = new Person(); 這條語句。通過函式例項化了一個變數,系統自動建立一個Object物件派生的中間物件Temp作為與建構函式繫結的原型物件。Person.prototype就指向這個中間物件Temp。
Person.prototype.age修改了Temp物件。
Person.__proto__.name,我們知道函式都是由Function物件派生的,這句話就是修改的Function物件物件。
var person2 = new Person(); 這個語句同樣通過函式例項化一個物件。一個建構函式只能繫結一個原型物件,所以這個原型物件就是Temp物件
person1.age訪問了age屬性,先在當前空間中查詢,沒有找到,於是沿原型鏈向上查詢這個原型物件Temp。查詢成功。
person2.name在變數和原型物件Temp中都不存在,所以顯示未定義。

下面的留給讀者自己理解

var a1 = {'age':1}
console.log(a1.prototype);  //undefined,未定義
console.log(a1.__proto__);  //{},物件Object
console.log(a1.constructor); //[Function: Object],函式Object
console.log(a1.__proto__.constructor);  //[Function: Object],函式Object

var a2 = Object.create(a1);
console.log(a2.__proto__); //{ age: 1 },物件a1
console.log(a2.constructor); //[Function: Object],物件Function

var Person = function(){};
console.log(Person.prototype);  //Person {},函式Person的構造繫結物件
console.log(Person.__proto__);  //[Function],物件Function
var person1 = new Person();
console.log(person1.__proto__); //Person {},函式Person的構造繫結物件
console.log(person1.constructor); //[Function: Person],函式Person
console.log(person1.constructor.prototype); //Person {},函式Person的構造繫結物件
console.log(person1.__proto__.__proto__); // {},(Object物件)
console.log(person1.__proto__.__proto__.__proto__); //null


Person.prototype.age= 18;
Person.__proto__.name= "小明";
var person2 = new Person(); 
console.log(person1.age);//18
console.log(person2.age); //18
console.log(person2.name);  //未定義

【作用域】

在c++,java,c#中,在變數宣告的程式碼段之外,變數是不可見的,我們通常稱為塊級作用域。而在JS中沒有塊級作用域。js中有全域性的作用域,函式作用域。就是說函式是一個作用域的基本單位。

作用域是在定義時確定的而不是執行時確定的。

所以在js中if語句,for語句內的不存在只有他們能訪問的變數。只有在函式記憶體在區域性變數。

if(1){
  var name2='lp2'
}
console.log(name2)   //lp2
for(var i=0;i<10;i++){
    var name3='lp3'
}
console.log(name3)  //lp3
Person={
    name4:'lp4'
}
console.log(name4)  //undefined
function person(){
  var name1='lp1'
}
console.log(name1)  //undefined

函式內不使用var宣告的變數是全域性變數
函式內宣告的所有變數在函式體內始終是可見的。並且,變數在宣告之前就可以使用了,這種情況就叫做宣告提前(hoisting)
tip:宣告提前是在js引擎預編譯時就進行了,在程式碼被執行之前已經有宣告提前的現象產生了

var name="one";
function test(){
     console.log(name); //undefined
     var name="two";
     console.log(name); //two
}

test();

這裡寫圖片描述

上面的圖就是作用域鏈的全部內容。

每個函式都有一個作用域(window有一個全域性作用域,可以把window想象成全域性函式),每個函式會對應有一個活動物件、函式作用域鏈、函式環境物件作用域鏈。他們並不是一直存在,而是動態建立的。

在定義新的函式時(不是執行到新函式時),就會為這個函式生成一個指標列表。這個指標列表就是作用域鏈,它每一項都指向一個外部作用域的活動物件。作用域鏈實現以後,它的地址儲存在window或函式的[[Scope]]屬性中。作用域鏈是棧的結構,它由上至下,依次排列著由內到外的作用域對應的活動物件。全域性活動物件始終都是作用域鏈中的最後一個物件。函式的作用域鏈的第一項是定義函式時所處的作用域對應的活動物件,而不是函式自己的活動物件。

每個函式的活動物件中存放了當前作用域中定義的所有屬性和方法。函式的活動物件在執行到作用域時才建立的,這也就是為什麼定義函式時建立的函式作用域鏈不包含函式自己的活動物件,因為定義函式時,函式的活動物件還沒有建立。

建立一個活動物件後,首先將該函式的每個形參和實參,都新增為該活動物件的屬性和值;將該函式體內顯示宣告的變數和函式,也新增為該活動的的屬性(在剛進入該函式執行環境時,未賦值,所以值為undefined,這個是JS的提前宣告機制),函式一邊執行一邊對活動物件中的屬性進行賦值。

在執行到一個函式時,除了建立函式的活動物件,還會建立函式的環境物件,併為這個環境物件也建立一個作用域鏈。環境物件的作用域鏈是通過複製函式的作用域鏈再新增上函式的活動物件地址而形成。

在執行函式函式中查詢變數,就是在這個函式的環境物件的作用域鏈中查詢的。
所以在執行過程中我們只是根據環境物件來確定變數的使用。

在執行完函式後,如果沒有變數再指向這個函式的環境物件,則這個環境物件就會銷燬。而物件的銷燬是由垃圾回收機制決定的,沒有指向的物件都會被銷燬。

這裡寫圖片描述

【閉包】

Javascript的垃圾回收機制

在Javascript中,如果一個物件不再被引用,那麼這個物件就會被GC回收。如果兩個物件互相引用,而不再被第3者所引用,那麼這兩個互相引用的物件也會被回收。函式a被b引用,b又被c引用,那麼abc都不會被清除。

理解閉包要先理解下面的執行過程。

從上面關於作用域鏈的內容,我們知道函式的定義與函式的作用域鏈有關,函式的執行與函式環境物件的作用域鏈有關,變數的查詢,是在執行時沿著函式環境物件的作用域鏈進行的。

程式碼的執行,是在一個執行棧中進行的。執行到哪個作用域就將該作用域的環境物件壓入棧頂。作用域執行完畢,若沒有其他作用域中的變數指向該環境物件中的屬性,就將這個環境物件彈出執行棧。否則將該環境物件儲存在執行棧中。

棧中有一個指標負責指向程式碼執行處當前作用域的環境物件。

看下面的程式碼,最後輸出10。

function A()
{
    var max=10;
    function B()
    {
        console.log(max);
    }
    return B;
}
var f1=A();
var max=100;
f1();

我們來看一下過程。

這裡寫圖片描述

由於在程式碼執行中涉及物件、函式的宣告、定義、執行。具體的過程在作用域鏈中已經講解。

函式執行中變數的查詢,是在執行時沿著函式環境物件的作用域鏈進行的。而函式環境物件是在函式執行時建立的。所以為了簡化理解,我們只關心函式的執行(不關心函式的定義)。在整個程式碼的執行中,先進入全域性函式執行,然後執行A函式,然後返回全域性函式,然後執行B函式,然後返回全域性函式。程式碼執行結束。

所以建立環境物件的過程也是先建立全域性環境物件,然後建立函式A環境物件,由於執行完函式A後,函式A的環境物件中的屬性被函式B的作用域鏈引用(因為函式的作用域鏈是在函式定義時建立的)。所以函式A的環境物件沒有被彈出銷燬。但是當前環境已經進入全域性環境物件。然後呼叫B函式,又在棧頂建立了函式B的環境物件。函式B執行完後,函式B的環境物件彈出銷燬。但是函式B的作用域鏈並沒有銷燬,所以此時仍然有變數指向函式A的環境物件。但是當前環境已經進入全域性環境物件,繼續執行。

在上面的執行過程中,變數的查詢時沿著環境物件的作用域鏈,環境物件的作用域鏈是環境物件的一個屬性。不要查找了別的環境物件。

閉包的定義

閉包是指有權訪問另一個函式作用域中的變數的函式,這裡要把它與匿名函式區分開(匿名函式:建立一個函式並將它賦值給一個變數,這種情況下建立的函式叫匿名函式,匿名函式的name屬性是空字串),建立閉包的常見方式就是在一個函式內部建立另一個函式。閉包儲存的是整個變數的物件。

閉包的作用:

在函式執行過程中,為讀取和寫入變數的值,就需要在作用域鏈中查詢變數,這時靈活方便的閉包就派上用場,我們知道當一個函式被呼叫時就會建立一個環境物件(也叫上下文物件、執行環境物件)及環境物件的作用域鏈,那麼閉包就會沿著環境物件的作用域鏈向上獲取到開發者想要的變數及元素。

閉包可以用在許多地方。它的最大用處有兩個,一個是前面提到的可以讀取函式內部的變數,另一個就是讓這些變數的值始終保持在記憶體中。

關於一直儲存在記憶體中的說法,你需要知道任何物件(包含函式)如何在記憶體中有變數指向他,他就不會被js垃圾清理機制銷燬。因為內部函式儲存了對外部函式變數的引用,所以外部函式就不能被垃圾清理機制銷燬,也就一直儲存在記憶體中。

閉包靈活方便,也可以實現封裝,這樣就只能通過物件的特定方法才能訪問到其屬性。但是,不合理的使用閉包會造成空間的浪費,記憶體的洩露和效能消耗。

當函式被建立,就有了作用域,當被呼叫時,就有了作用域鏈,當被繼承時就有了原型鏈,當需要獲取作用域鏈或原型鏈上的變數或值時,就有了閉包。

使用閉包的注意點

1)由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除。

2)閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。

閉包的應用場景

保護函式內的變數安全。以最開始的例子為例,函式a中i只有函式b才能訪問,而無法通過其他途徑訪問到,因此保護了i的安全性。

在記憶體中維持一個變數。依然如前例,由於閉包,函式a中i的一直存在於記憶體中,因此每次執行c(),都會給i自加1。

通過保護變數的安全實現JS私有屬性和私有方法(不能被外部訪問)

私有屬性和方法在Constructor外是無法被訪問的

function Constructor(...) {  
  var that = this;  
  var membername = value; 
  function membername(...) {...}
}