JavaScript語法裡一些難點問題彙總

夏天的森林發表於2014-12-10

1) 引子

前不久我建立的技術群裡一位MM問了一個這樣的問題,她貼出的程式碼如下所示:

var a = 1;

function hehe()

{

         window.alert(a);

         var a = 2;

         window.alert(a);

}

hehe();

執行結果如下所示:

第一個alert:

第二個alert:

這是一個令人詫異的結果,為什麼第一個彈出框顯示的是undefined,而不是1呢?這種疑惑的原理我描述如下:

一個頁面裡直接定義在script標籤下的變數是全域性變數即屬於window物件的變數,按照javascript作用域鏈的原理,當一個變數在當前作用域下找不到該變數的定義,那麼javascript引擎就會沿著作用域鏈往上找直到在全域性作用域裡查詢,按上面的程式碼所示,雖然函式內部重新定義了變數的值,但是內部定義之前函式使用了該變數,那麼按照作用域鏈的原理在函式內部變數定義之前使用該變數,javascript引擎應該會在全域性作用域裡找到變數定義,而實際情況卻是變數未定義,這到底是怎麼回事呢?

當時群裡很多人都給出了問題的解答,我也給出了我自己的解答,其實這個問題很久之前我的確研究過,但是剛被問起了我居然還是有個卡殼期,在加上最近研究javascriptMVC的寫法,發現自己讀程式碼時候對new 、prototype、apply以及call的用法任然要體味半天,所以我覺得有必要對javascript基礎語法裡比較難理解的問題做個梳理,其實寫部落格的一個很大的好處就是寫出來的知識邏輯會比你在腦子裡反覆梳理的邏輯映像更加的深刻。

下面開始本文的主要內容,我會從基礎知識一步步講起。

2) Javascript的變數

Java語言裡有一句很經典的話:java的世界裡,一切皆是物件

Javascript雖然跟java沒有半點毛關係,但是很多會使用javascript的朋友同樣認為:javascript的世界裡,一切也皆是物件

其實javascript語言和java語言一樣變數是分為兩種型別:基本資料型別和引用型別。

基本型別是指:Undefined、Null、Boolean、Number和String;而引用型別是指多個指構成的物件,所以javascript的物件指的是引用型別。在java裡能說一切是物件,是因為java語言裡對所有基本型別都做了物件封裝,而這點在javascript語言裡也是一樣的,所以提在javascript世界裡一切皆為物件也不為過。

但是實際開發裡如果我們對基本型別和引用型別的區別不是很清晰,就會碰到我們很多不能理解的問題,下面我們來看看下面的程式碼:

var str = "sharpxiajun";

str.attr01 = "hello world";

console.log(str);//  執行結果:sharpxiajun

console.log(str.attr01);// 執行結果:undefined

執行之,我們發現作為基本資料型別,我們沒法為這個變數新增屬性,當然方法也同樣不可以,例如下面的程式碼:

str.ftn = function(){

    console.log("str ftn");

}

str.ftn();

執行之,結果如下圖所示:

當我們使用引用型別時候,結果就和上面完全不同了,大家請看下面的程式碼:

var obj1 = new Object();

obj1.name = "obj1 name";

console.log(obj1.name);// 執行結果:obj1 name

javascript裡的基本型別和引用型別的區別和其他語言類似,這是一個老調長談的問題,但是在現實中很多人都理解它,但是卻很難應用它去理解問題。

Javascript裡的基本變數是存放在棧區的(棧區指記憶體裡的棧記憶體),它的儲存結構如下圖所示:

javascript裡引用變數的儲存就比基本型別儲存要複雜多,引用型別的儲存需要記憶體的棧區和堆區(堆區是指記憶體裡的堆記憶體)共同完成,如下圖所示:

在javascript裡變數的儲存包含三個部分:

部分一:棧區的變數標示符;

部分二:棧區變數的值;

部分三:堆區儲存的物件。

變數不同的定義,這三個部分也會隨之發生變化,下面我來列舉一些典型的場景:

場景一:如下程式碼所示

var qqq;

console.log(qqq);// 執行結果:undefined

執行結果是undefined,上面的程式碼的標準解釋就是變數被命名了,但是還未初始化,此時在變數儲存的記憶體裡只擁有棧區的變數標示符而沒有棧區的變數值,當然更沒有堆區儲存的物件。

場景二:如下程式碼所示

var qqq;

console.log(qqq);// 執行結果:undefined

console.log(xxx);

會提示變數未定義。在任何語言裡變數未定義就使用都是違法的,我們看到javascript裡也是如此,但是我們做javascript開發時候,經常有人會說變數未定義也是可以使用,怎麼我的例子裡卻不能使用了?那麼我們看看下面的程式碼:

xxx = "outer xxx";

console.log(xxx);// 執行結果:outer xxx

function testFtn(){

     sss = "inner sss";

     console.log(sss);// 執行結果:outer sss

}

testFtn();

console.log(sss);//執行結果:outer sss

console.log(window.sss);//執行結果:outer sss

在javascript定義變數需要使用var關鍵字,但是javascript可以不使用var預先定義好變數,在javascript我們可以直接賦值給沒有被var定義的變數,不過此時你這麼操作變數,不管這個操作是在全域性作用域裡還是在區域性作用域裡,變數最終都是屬於window物件,我們看看window物件的結構,如下圖所示:

由這兩個場景我們可以知道在javascript裡的變數不能正常使用即報出“xxx is not defined”錯誤(這個錯誤下,後續的javascript程式碼將不能正常執行)只有當這個變數既沒有被var定義同時也沒有進行賦值操作才會發生,而只有賦值操作的變數不管這個變數在那個作用域裡進行的賦值,這個變數最終都是屬於全域性變數即window物件

由上面我列舉的兩個場景我們來理解下引子裡網友提出的問題,下面我修改一下程式碼,如下所示:

//var a = 1;

function hehe()

{

     console.log(a);

     var a = 2;

     console.log(a);

}

hehe();

結果如下圖所示:

我再改下程式碼:

//var a = 1;

function hehe()

{

    console.log(a);

    // var a = 2;

    console.log(a);

}

hehe();

執行之,結果如下所示:

對比二者程式碼以及引子裡的程式碼,我們發現問題的關鍵是var a=2所引起的。在程式碼一里我註釋了全域性變數的定義,結果和引子裡程式碼的結果一致,這說明函式內部a變數的使用和全域性環境是無關的,程式碼二里我註釋了關鍵程式碼var a = 2,程式碼執行結果發生了變化,程式報錯了,的確很讓人困惑,困惑之處在於區域性作用域裡變數定義的位置在變數第一次使用之後,但是程式沒有報錯,這不符合javascript變數未定義既要報錯的原理。

其實這個變數任然被定義即記憶體儲存裡有了標示符,只不過沒有被賦值,程式碼一則說明,內部變數a已經和外部環境無關,怎麼回事?如果我們按照程式碼執行是按照順序執行的邏輯來理解,這個程式碼也就沒法理解。

其實javascript裡的變數和其他語言有很大的不同,javascript的變數是一個鬆散的型別,鬆散型別變數的特點是變數定義時候不需要指定變數的型別,變數在執行時候可以隨便改變資料的型別,但是這種特性並不代表javascript變數沒有型別,當變數型別被確定後javascript的變數也是有型別的。但是在現實中,很多程式設計師把javascript鬆散型別理解為了javascript變數是可以隨意定義即你可以不用var定義,也可以使用var定義,其實在javascript語言裡變數定義沒有使用var,變數必須有賦值操作,只有賦值操作的變數是賦予給window,這其實是javascript語言設計者提升javascript安全性的一個做法。

此外javascript語言的鬆散型別的特點以及執行時候隨時更改變數型別的特點,很多程式設計師會認為javascript變數的定義是在執行期進行的,更有甚者有些人認為javascript程式碼只有執行期,其實這種理解是錯誤的,javascript程式碼在執行前還有一個過程就是:預載入,預載入的目的是要事先構造執行環境例如全域性環境,函式執行環境,還要構造作用域鏈(關於作用域鏈和環境,本文後續會做詳細的講解),而環境和作用域的構造的核心內容就是指定好變數屬於哪個範疇,因此在javascript語言裡變數的定義是在預載入完成而非在執行時期。

所以,引子裡的程式碼在函式的區域性作用域下變數a被重新定義了,在預載入時候a的作用域範圍也就被框定了,a變數不再屬於全域性變數,而是屬於函式作用域,只不過賦值操作是在執行期執行(這就是為什麼javascript語言在執行時候會改變變數的型別,因為賦值操作是在執行期進行的),所以第一次使用a變數時候,a變數在區域性作用域裡沒有被賦值,只有棧區的標示名稱,因此結果就是undefined了。

不過賦值操作也不是完全不對預載入產生影響,預載入時候javascript引擎會掃描所有程式碼,但不會執行它,當預載入掃描到了賦值操作,但是賦值操作的變數有沒有被var定義,那麼該變數就會被賦予全域性變數即window物件。

根據上面的內容我們還可以理解下javascript兩個特別的型別:undefined和null,從javascript變數儲存的三部分角度思考,當變數的值為undefined時候,那麼該變數只有棧區的標示符,如果我們對undefined的變數進行賦值操作,如果值是基本型別,那麼棧區的值就有值了,如果棧區是物件那麼堆區會有一個物件,而棧區的值則是堆區物件的地址,如果變數值是null的話,我們很自然認為這個變數是物件,而且是個空物件,按照我前面講到的變數儲存的三部分考慮:當變數為null時候,棧區的標示符和值都會有值,堆區應該也有,只不過堆區是個空物件,這麼說來null其實比undefined更耗記憶體了,那麼我們看看下面的程式碼:

var ooo = null;

console.log(ooo);// 執行結果:null

console.log(ooo == undefined);// 執行結果:true

console.log(ooo == null);// 執行結果:true

 console.log(ooo === undefined);// 執行結果:false

console.log(ooo === null);// 執行結果:true

執行之,結果很震驚啊,null居然可以和undefined相等,但是使用更加精確的三等號“===”,發現二者還是有點不同,其實javascript裡undefined型別源自於null即null是undefined的父類,本質上null和undefined除了名字這個馬甲不同,其他都是一樣的,不過要讓一個變數是null時候必須使用等號“=”進行賦值了。

當變數為undefined和null時候我們如果濫用它javascript語言可能就會報錯,後續程式碼會無法正常執行,所以javascript開發規範裡要求變數定義時候最好馬上賦值,賦值好處就是我們後面不管怎麼使用該變數,程式都很難因為變數未定義而報錯從而終止程式的執行,例如上文裡就算變數是string基本型別,在變數定義屬性程式還是不會報錯,這是提升程式健壯性的一個重要手段,由引子的例子我們還知道,變數定義最好放在變數所述作用域的最前端,這麼做也是保證程式碼健壯性的一個重要手段。

下面我們再看一段程式碼:

var str;

    if (undefined != str && null != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (undefined != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (null != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (!!str){

        console.log("true");

    }else{

        console.log("false");

    }

    str = "";

    if (!!str){

        console.log("true");

    }else{

        console.log("false");

    }

執行之,結果都是列印出false。

使用雙等號“==”,undefined和null是一回事,所以第一個if語句的寫法完全多餘,增加了不少程式碼量,而第二種和第三種寫法是等價,究其本質前三種寫法本質都是一致的,但是現實中很多程式設計師會選用寫法一,原因就是他們還沒理解undefined和null的不同,第四種寫法是更加完美的寫法,在javascript裡如果if語句的條件是undefined和null,那麼if判斷的結果就是false,使用!運算子if計算結果就是true了,再加一個就是false,所以這裡我建議在書寫javascript程式碼時候判斷程式碼是否為未定義和null時候最好使用!運算子。

程式碼四里我們看到當字串被賦值了,但是賦值是個空字串時候,if的條件判斷也是false,javascript裡有五種基本型別,undefined、null、boolean、Number和string,現在我們發現除了Number都可以使用!來判斷if的ture和false,那麼基本型別Number呢?

var num = 0;

if (!!num){

        console.log("true");

}else{

        console.log("false");

}

執行之,結果是false。

如果我們把num改為負數或正數,那麼執行之的結果就是true了。

這說明了一個道理:我們定義變數初始化值的時候,如果基本型別是string,我們賦值空字串,如果基本型別是number我們賦值為0,這樣使用if語句我們就可以判斷該變數是否是被使用過了。

但是當變數是物件時候,結果卻不一樣了,如下程式碼:

var obj = {};

if (!!obj){

        console.log("true");

}else{

        console.log("false");

}

執行之,程式碼是true。

所以在定義物件變數時候,初始化時候我們要給變數賦予null,這樣if語句就可以判斷變數是否初始化過。

其實if加上!運算判斷物件的現象還有玄機,這個玄機要等我把場景三講完才能說清楚哦。

場景三:複製變數的值和函式傳遞引數

首先看看這個場景的程式碼:

var s1 = "sharpxiajun";

var s2 = s1;

console.log(s1);//// 執行結果:sharpxiajun

console.log(s2);//// 執行結果:sharpxiajun

s2 = "xtq";

console.log(s1);//// 執行結果:sharpxiajun

console.log(s2);//// 執行結果:xtq

上面是基本型別變數的賦值,我們再看看下面的程式碼:

var obj1 = new Object();

obj1.name = "obj1 name";

console.log(obj1.name);// 執行結果:obj1 name

var obj2 = obj1;

console.log(obj2.name);// 執行結果:obj1 name

obj1.name = "sharpxiajun";

console.log(obj2.name);// 執行結果:sharpxiajun

我們發現當複製的是物件,那麼obj1和obj2兩個物件被串聯起來了,obj1變數裡的屬性被改變時候,obj2的屬性也被修改。

函式傳遞引數的本質就是外部的變數複製到函式引數的變數裡,我們看看下面的程式碼:

function testFtn(sNm,pObj){

        console.log(sNm);// 執行結果:new Name

        console.log(pObj.oName);// 執行結果:new obj

        sNm = "change name";

        pObj.oName = "change obj";

}

var sNm = "new Name";

var pObj = {oName:"new obj"};

testFtn(sNm,pObj);

console.log(sNm);// 執行結果:new Name

console.log(pObj.oName);// 執行結果:change obj

這個結果和變數賦值的結果是一致的。

在javascript裡傳遞引數是按值傳遞的

上面函式傳參的問題是很多公司都愛面試的問題,其實很多人都不知道javascript傳參的本質是怎樣的,如果把上面傳參的例子改的複雜點,很多朋友都會栽倒到這個面試題下。

為了說明這個問題的原理,就得把上面講到的變數儲存原理綜合運用了,這裡我把前文的內容再複述一遍,兩張圖,如下所示:

這是引用型別儲存的記憶體結構。

還有個知識,如下:

在javascript裡變數的儲存包含三個部分:

部分一:棧區的變數標示符;

部分二:棧區變數的值;

部分三:堆區儲存的物件。

在javascript裡變數的複製(函式傳參也是變數賦值)本質是傳值,這個值就是棧區的值,而基本型別的內容是存放在棧區的值裡,所以複製基本變數後,兩個變數是獨立的互不影響,但是當複製的是引用型別時候,複製操作還是複製棧區的值,但是這個時候值是堆區物件的地址,因為javascript語言是不允許操作堆記憶體,因此堆記憶體的變數並沒有被複制,所以複製引用物件複製的值就是堆記憶體的地址,而複製雙方的兩個變數使用的物件是相同的,因此複製的變數其中一個修改了物件,另一個變數也會受到影響。

原理講完了,下面我列舉一個拔高的例子,程式碼如下:

var ftn1 = function(){

        console.log("test:ftn1");

    };

var ftn2 = function(){

        console.log("test:ftn2");

};

function ftn(f){

       f();

       f = ftn2;

}

ftn(ftn1);// 執行結果:test:ftn1

console.log("====================華麗的分割線======================");

ftn1();// 執行結果:test:ftn1

這個程式碼是很早之前有位朋友考我的,我當時答對了,但是我是蒙的,問我的朋友答錯了,其實當時我們兩個都沒搞懂其中緣由,我朋友是這麼分析的他認為f是函式的引數,屬於函式的區域性作用域,因此更改f的值,是沒法改變ftn1的值,因為到了外部作用域f就失效了,但是這種解釋很難說明我上文裡給出的函式傳參的例項,其實這個問題答案就是函式傳參的原理,只不過這裡加入了個混淆因素函式,在javascript函式也是物件,區域性作用域裡f = ftn2操作是將f在棧區的地址改為了ftn2的地址,對外部的ftn1和ftn2沒有任何改變。

記住:javascript裡變數複製和函式傳參都是在傳遞棧區的值

棧區的值除了變數複製起作用,它在if語句裡也會起到作用,當棧區的值為undefined、null、“”(空字串)、0、false時候,if的條件判斷則是為false,我們可以通過!運算子計算,因此當我們的程式碼如下:

var obj = {};

    if (!!obj){

        console.log("true");

    }else{

        console.log("false");

}

結果則是true,因為var obj = {}相當於var obj = new Object(),雖然物件裡沒什麼內容,但是在堆區裡,物件的記憶體已經分配了,而變數棧區的值已經是記憶體地址了,所以if語句判斷就是true了。

3) 作用域鏈相關的問題

作用域鏈是javascript語言裡非常紅的概念,很多學習和使用javascript語言的程式設計師都知道作用域鏈是理解javascript裡很重要的一些概念的關鍵,這些概念包括this指標,閉包等等,它非常紅的另一個重要原因就是作用域鏈理解起來太難,就算有人真的感覺理解了它,但是碰到很多實際問題時候任然會是丈二和尚摸不到頭腦,例如上篇引子裡講到的例子,本篇要講的主題就是作用域鏈,再無別的內容,希望看完本文的朋友能有所收穫。

講作用域鏈首先要從作用域講起,下面是百度百科裡對作用域的定義:

作用域在許多程式設計語言中非常重要。 通常來說,一段程式程式碼中所用到的名字並不總是有效/可用的,而限定這個名字的可用性的程式碼範圍就是這個名字的作用域。 作用域的使用提高了程式邏輯的區域性性,增強程式的可靠性,減少名字衝突。

在我最擅長的服務端語言java裡也有作用域的概念,java裡作用域是以{}作為邊界,不過在純種的面嚮物件語言裡我們沒必要把作用域研究的那麼深,也沒必要思考複雜的作用域巢狀問題,因為這些語言關於作用域的深度運用並不會給我們編寫的程式碼帶來多大好處。但是在javascript裡卻大不相同,如果我們不能很好的理解javascript的作用域我們就沒辦法使用javascript編寫出複雜的或者規模巨集大的程式。

由百度百科裡的定義,我們知道作用域的作用是保證變數的名字不發生衝突,用現實的場景來理解有個人叫做張三,張三雖然只是一個名字,但是認識張三的人根據名字就能唯一確認這個人到底是誰,但是這個世界上叫做張三的人可不止一個,特別是兩個叫張三的人有交集的時候我們就要有個辦法明確指定這個張三絕不是另外一個張三,這時我們可能會根據兩大張三年齡的差異來區分:例如一個張三叫大張三,相對的另外一個張三叫小張三了。程式語言裡的作用域其實就是為了做類似的標記,作用域會設定一個範圍,在這個範圍裡我們是不會弄錯變數的真實含義。

前面我講到在java裡通過{}來設定作用域,在{}裡面的變數會得到保護,這種保護就是不讓{}裡的變數被外部變數混淆和汙染。那麼{}的方式適合於javascript嗎?我們看看下面的例子:

var s1 = "sharpxiajun";

    function ftn(){

        var s2 = "xtq";

        console.log(this);// 執行結果: window

        console.log("s1:" + this.s1 + ";s2:" + this.s2);//執行結果:s1:sharpxiajun;s2:undefined

        console.log("s1:" + this.s1 + ";s2:" + s2);// 執行結果:s1:sharpxiajun;s2:xtq

    }

    ftn();

在javascript世界裡有一個大的作用域環境,這個環境就是window,window環境不需要我們自己使用什麼方式構建,頁面載入時候頁面會自動構造的,上面程式碼裡有一個大括號,這個大括號是對函式的定義,執行之,我們發現函式作用域內部定義的s2變數是不能被window物件訪問的,因此s2變數是被{}保護起來了,它的生命週期和這個函式的生命週期有關。

由這個例子是不是說明在javascript裡,變數也是被{}保護起來了,在javascript語言裡還有非函式的{},我們再看看下面的例子:

if (true){

        var a = "aaaa";

    }

    console.log(a);// 執行結果:aaaa

我們發現javascript裡{}有時是起不到定義作用域的功能。這也說明javascript裡的作用域定義是和其他語言例如java不同的。

在javascript裡作用域有一個專門的定義execution context,有的書裡把這個名字翻譯成執行上下文,有的書籍裡把它翻譯成執行環境,我更傾向於後者執行環境,下文我提到的執行環境就是execution context。這個命名非常形象,這個形象體現在execution這個單詞,execution含義就是執行,我們來想想javascript裡那些情況是執行:

情況一:當頁面載入時候在script標籤下的javascript程式碼會按順序執行,而這些能被執行的程式碼都是屬於window的變數或函式;

情況二:當函式的名字後面加上小括號(),例如ftn(),這也是在執行,不過它執行的是函式。

如此說來,javascript裡的執行環境有兩類一類是全域性執行環境,即window代表的全域性環境,一類是函式代表的函式執行環境,這也就是我們常說的區域性作用域

執行環境在javascript語言裡並非是一個抽象的概念,而是有具體的實現,這個實現其實是個物件,這個物件也有個名字叫做variable object,這個變數有的書裡翻譯為變數物件,這是直譯,有的書裡把它稱為上下文變數,這裡我還是傾向於後者上下文變數,下文裡提到的上下文變數就是指代variable object。上下文變數儲存的是上下文變數所處執行環境裡定義的所有的變數和函式。

全域性執行環境的上下文變數是可以訪問到的,它就是window物件,所以我們說window能代表全域性作用域是有道理的,但是區域性作用域即函式的執行環境裡的上下文變數是程式碼不能訪問到的,不過javascript引擎在處理資料時候會使用到它。

在javascript語言裡還有一個概念,它的名字叫做execution context stack,翻譯成中文就是執行環境棧,每個要被執行的函式都會先把函式的執行環境壓入到執行環境棧裡,函式執行完畢後,這個函式的執行環境就會被執行環境棧彈出,例如上面的例子:函式執行時候函式的執行環境會被壓入到執行環境棧裡,函式執行完畢,執行環境棧會把這個環境彈出,執行環境棧的控制權就會交由全域性環境,如果函式後面還有程式碼,那麼程式碼就是接著執行。如果函式裡巢狀了函式,那麼巢狀函式執行完畢後,執行環境棧的控制權就交由了外部函式,然後依次類推,最後就是全域性執行環境了。

講到這裡我們大名鼎鼎的作用域鏈要登場了,函式的執行環境被壓入到執行環境棧裡後,函式就要執行了,函式執行的第一步不是執行函式裡的第一行程式碼而是在上下文變數裡構造一個作用域鏈,作用域鏈的英文名字叫做scope chain,作用域鏈的作用是保證執行環境裡有權訪問的變數和函式是有序的,這個概念裡有兩個關鍵意思:有權訪問和有序,我們看看下面的程式碼:

var b1 = "b1";

    function ftn1(){

        var b2 = "b2";

        var b1 = "bbb";

        function ftn2(){

            var b3 = "b3";

            b2 = b1;

            b1 = b3;

            console.log("b1:" + b1 + ";b2:" + b2 + ";b3:" + b3);// 執行結果:b1:b3;b2:bbb;b3:b3

        }

        ftn2();

    }

    ftn1();

console.log(b1);// 執行結果:b1

有這個例子我們發現,ftn2函式可以訪問變數b1,b2,這個體現了有權訪問的概念,當ftn1作用域裡改變了b1的值並且把b1變數重新定義為ftn1的區域性變數,那麼ftn2訪問到的b1就是ftn1的,ftn2訪問到b1後就不會在全域性作用域裡查詢b1了,這個體現了有序性。

下面我要總結下上面講述的知識:

本篇的小標題是:作用域鏈的相關問題,這個標題定義的含義是指作用域鏈是大名鼎鼎了,但是作用域鏈在廣大程式設計師的理解裡其實包含的意義已經超越了作用域鏈在javascript語言本身的定義。廣大程式設計師對作用域鏈的理解有兩塊一塊是作用域,而作用域在javascript語言裡指的是執行環境execution context,執行環境在javascript引擎裡是通過上下文變數體現的variable object,javascript引擎裡還有一個概念就是執行環境棧execution context stack,當某一個函式的執行環境壓入到了執行環境棧裡,這個時候就會在上下文變數裡構造一個物件,這個物件就是作用域鏈scope chain,而這個作用域鏈就是廣大程式設計師理解的第二塊知識,作用域鏈的作用是保證執行環境裡有權訪問的變數和函式是有序的,作用域鏈的變數只能向上訪問,變數訪問到window物件即被終止,作用域鏈向下訪問變數是不被允許的。

很多人常常認為作用域鏈是理解this指標的關鍵,這個理解是不正確的的,this指標構造是和作用域鏈同時發生的,也就是說在上文變數構建作用域鏈的同時還會構造一個this物件,this物件也是屬於上下文變數,this變數的值就是當前執行環境外部的上下文變數的一份拷貝,這個拷貝里是沒有作用域鏈變數的,例如程式碼:

var b1 = "b1";

    function ftn1(){

        console.log(this);// 執行結果: window

        var b2 = "b2";

        var b1 = "bbb";

        function ftn2(){

            console.log(this);// 執行結果: window

            var b3 = "b3";

            b2 = b1;

            b1 = b3;

            console.log("b1:" + b1 + ";b2:" + b2 + ";b3:" + b3);// 執行結果:b1:b3;b2:bbb;b3:b3

        }

        ftn2();

    }

    ftn1();

我們看到函式ftn1和ftn2裡的this指標都是指向window,這是為什麼了?因為在javascript我們定義函式方式是通過function xxx(){}形式,那麼這個函式不管定義在哪裡,它都屬於全域性物件window,所以他們的執行環境的外部的執行上下文都是指向window。

但是我們都知道現實程式碼很多this指標都不是指向window,例如下面的程式碼:

var obj = {

    name:"sharpxiajun",

    ftn:function(){

        console.log(this);// 執行結果: Object { name="sharpxiajun", ftn=function()}

        console.log(this.name);//執行結果: sharpxiajun

    }

}

obj.ftn();// :

執行之,我們發現這裡this指標指向了Object,這就怪了我前文不是說javascript裡作用域只有兩種型別:一個是全域性的一個是函式,為什麼這裡Object也是可以製造出作用域了,那麼我的理論是不是有問題啊?那我們看看下面的程式碼:

var obj1 = new Object();

obj1.name = "xtq";

obj1.ftn = function(){

    console.log(this);// 執行結果: Object { name="xtq", ftn=function()}

    console.log(this.name);//執行結果: xtq

}

obj1.ftn();

這兩種寫法是等價的,第一種物件的定義方法叫做字面量定義,而第二種寫法則是標準寫法,Object物件的本質也是個function,所以當我們呼叫物件裡的函式時候,函式的外部執行環境就是obj1本身,即外部執行環境上下文變數代表的就是obj1,那麼this指標也是指向了obj1。

4)   this、new、apply和call詳解

講解this指標的原理是個很複雜的問題,如果我們從javascript裡this的實現機制來說明this,很多朋友可能會越來越糊塗,因此本篇打算換一個思路從應用的角度來講解this指標,從這個角度理解this指標更加有現實意義。

下面我們看看在java語言裡是如何使用this指標的,程式碼如下:

public class Person {

    private String name;
    private String sex;
    private int age;
    private String job;

    public Person(String name, String sex, int age, String job) {
        super();
        this.name = name;
        this.sex = sex;
        this.age = age;
        this.job = job;
    }

    private void showPerson(){
        System.out.println("姓名:" + this.name);
        System.out.println("性別:" + this.sex);
        System.out.println("年齡:" + this.age);
        System.out.println("工作:" + this.job);
    }

    public void printInfo(){
        this.showPerson();
    }

    public static void main(String[] args) {
        Person person = new Person("馬雲", "男", 46, "董事長");
        person.printInfo();
    }

}

//姓名:馬雲
//性別:男
//年齡:46
//工作:董事長

上面的程式碼執行後沒有任何問題,下面我修改下這個程式碼,加一個靜態的方法,靜態方法裡使用this指標呼叫類裡的屬性,如下圖所示:

我們發現IDE會報出語法錯誤“Cannot use this in a static context”,this指標在java語言裡是不能使用在靜態的上下文裡的。

在物件導向程式設計裡有兩個重要的概念:一個是類,一個是例項化的物件,類是一個抽象的概念,用個形象的比喻表述的話,類就像一個模具,而例項化物件就是通過這個模具製造出來的產品,例項化物件才是我們需要的實實在在的東西,類和例項化物件有著很密切的關係,但是在使用上類的功能是絕對不能取代例項化物件,就像模具和模具製造的產品的關係,二者的用途是不相同的。

有上面程式碼我們可以看到,this指標在java語言裡只能在例項化物件裡使用,this指標等於這個被例項化好的物件,而this後面加上點操作符,點操作符後面的東西就是this所擁有的東西,例如:姓名,工作,手,腳等等。

其實javascript裡的this指標邏輯上的概念也是例項化物件,這一點和java語言裡的this指標是一致的,但是javascript裡的this指標卻比java裡的this難以理解的多,究其根本原因我個人覺得有三個原因:

原因一:javascript是一個函式程式語言,怪就怪在它也有this指標,說明這個函式程式語言也是物件導向的語言,說的具體點,javascript裡的函式是一個高階函式,程式語言裡的高階函式是可以作為物件傳遞的,同時javascript裡的函式還有可以作為建構函式,這個建構函式可以建立例項化物件,結果導致方法執行時候this指標的指向會不斷髮生變化,很難控制。

原因二:javascript裡的全域性作用域對this指標有很大的影響,由上面java的例子我們看到,this指標只有在使用new操作符後才會生效,但是javascript裡的this在沒有進行new操作也會生效,這時候this往往會指向全域性物件window

原因三:javascript裡call和apply操作符可以隨意改變this指向,這看起來很靈活,但是這種不合常理的做法破壞了我們理解this指標的本意,同時也讓寫程式碼時候很難理解this的真正指向

上面的三個原因都違反了傳統this指標使用的方法,它們都擁有有別於傳統this原理的理解思路,而在實際開發裡三個原因又往往會交織在一起,這就更加讓人迷惑不解了,今天我要為大家理清這個思路,其實javascript裡的this指標有一套固有的邏輯,我們理解好這套邏輯就能準確的掌握好this指標的使用。

我們先看看下面的程式碼:

<script type="text/javascript">
    this.a = "aaa";
    console.log(a);//aaa
    console.log(this.a);//aaa
    console.log(window.a);//aaa
    console.log(this);// window
    console.log(window);// window
    console.log(this == window);// true
    console.log(this === window);// true
</script>

在script標籤裡我們可以直接使用this指標,this指標就是window物件,我們看到即使使用三等號它們也是相等的。全域性作用域常常會干擾我們很好的理解javascript語言的特性,這種干擾的本質就是:

在javascript語言裡全域性作用域可以理解為window物件,記住window是物件而不是類,也就是說window是被例項化的物件,這個例項化的過程是在頁面載入時候由javascript引擎完成的,整個頁面裡的要素都被濃縮到這個window物件,因為程式設計師無法通過程式語言來控制和操作這個例項化過程,所以開發時候我們就沒有構建這個this指標的感覺,常常會忽視它,這就是干擾我們在程式碼裡理解this指標指向window的情形。

干擾的本質還和function的使用有關,我們看看下面的程式碼:

<script type="text/javascript">
    function ftn01(){
       console.log("I am ftn01!");
    }
    var ftn02 = function(){
        console.log("I am ftn02!");
    }
</script>

上面是我們經常使用的兩種定義函式的方式,第一種定義函式的方式在javascript語言稱作宣告函式,第二種定義函式的方式叫做函式表示式,這兩種方式我們通常認為是等價的,但是它們其實是有區別的,而這個區別常常會讓我們混淆this指標的使用,我們再看看下面的程式碼:

<script type="text/javascript">
    console.log(ftn01);//ftn01()  注意:在firebug下這個列印結果是可以點選,點選後會顯示函式的定義
    console.log(ftn02);// undefined
    function ftn01(){
       console.log("I am ftn01!");
    }
    var ftn02 = function(){
        console.log("I am ftn02!");
    }
</script>

這又是一段沒有按順序執行的程式碼,先看看ftn02,列印結果是undefined,undefined我在前文裡講到了,在記憶體的棧區已經有了變數的名稱,但是沒有棧區的變數值,同時堆區是沒有具體的物件,這是javascript引擎在預處理(群裡東方說預處理比預載入更準確,我同意他的說法,以後文章裡我都寫為預處理)掃描變數定義所致,但是ftn01的列印結果很令人意外,既然列印出完成的函式定義了,而且程式碼並沒有按順序執行,這隻能說明一個問題:

在javascript語言通過宣告函式方式定義函式,javascript引擎在預處理過程裡就把函式定義和賦值操作都完成了,在這裡我補充下javascript裡預處理的特性,其實預處理是和執行環境相關,在上篇文章裡我講到執行環境有兩大類:全域性執行環境和區域性執行環境,執行環境是通過上下文變數體現的,其實這個過程都是在函式執行前完成,預處理就是構造執行環境的另一個說法,總而言之預處理和構造執行環境的主要目的就是明確變數定義,分清變數的邊界,但是在全域性作用域構造或者說全域性變數預處理時候對於宣告函式有些不同,宣告函式會將變數定義和賦值操作同時完成,因此我們看到上面程式碼的執行結果。由於宣告函式都會在全域性作用域構造時候完成,因此宣告函式都是window物件的屬性,這就說明為什麼我們不管在哪裡宣告函式,宣告函式最終都是屬於window物件的原因了

關於函式表示式的寫法還有祕密可以探尋,我們看下面的程式碼:

<script type="text/javascript">
    function ftn03(){
        var ftn04 = function(){
            console.log(this);// window
        };
        ftn04();
    }
    ftn03();
</script>

執行結果我們發現ftn04雖然在ftn03作用域下,但是執行它裡面的this指標也是指向window,其實函式表示式的寫法我們大多數更喜歡在函式內部寫,因為宣告函式裡的this指向window這已經不是祕密,但是函式表示式的this指標指向window卻是常常被我們所忽視,特別是當它被寫在另一個函式內部時候更加如此。

其實在javascript語言裡任何匿名函式都是屬於window物件,它們也都是在全域性作用域構造時候完成定義和賦值,但是匿名函式是沒有名字的函式變數,但是在定義匿名函式時候它會返回自己的記憶體地址,如果此時有個變數接收了這個記憶體地址,那麼匿名函式就能在程式裡被使用了,因為匿名函式也是在全域性執行環境構造時候定義和賦值,所以匿名函式的this指向也是window物件,所以上面程式碼執行時候ftn04的this也是指向window,因為javascript變數名稱不管在那個作用域有效,堆區的儲存的函式都是在全域性執行環境時候就被固定下來了,變數的名字只是一個指代而已。

這下子壞了,this都指向window,那我們到底怎麼才能改變它了?

在本文開頭我說出了this的祕密,this都是指向例項化物件,前面講到那麼多情況this都指向window,就是因為這些時候只做了一次例項化操作,而這個例項化都是在例項化window物件,所以this都是指向window。我們要把this從window變成別的物件,就得要讓function被例項化,那如何讓javascript的function例項化呢?答案就是使用new操作符。我們看看下面的程式碼:

<script type="text/javascript">
    var obj = {
        name:"sharpxiajun",
        job:"Software",
        show:function(){
            console.log("Name:" + this.name + ";Job:" + this.job);
            console.log(this);// Object { name="sharpxiajun", job="Software", show=function()}
        }
    };
    var otherObj = new Object();
    otherObj.name = "xtq";
    otherObj.job = "good";
    otherObj.show = function(){
        console.log("Name:" + this.name + ";Job:" + this.job);
        console.log(this);// Object { name="xtq", job="good", show=function()}
    };
    obj.show();//Name:sharpxiajun;Job:Software
    otherObj.show();//Name:xtq;Job:good
</script>

這是我上篇講到的關於this使用的一個例子,寫法一是我們大夥都愛寫的一種寫法,裡面的this指標不是指向window的,而是指向Object的例項,firebug的顯示讓很多人疑惑,其實Object就是物件導向的類,大括號裡就是例項物件了,即obj和otherObj。Javascript裡通過字面量方式定義物件的方式是new Object的簡寫,二者是等價的,目的是為了減少程式碼的書寫量,可見即使不用new操作字面量定義法本質也是new操作符,所以通過new改變this指標的確是不過攻破的真理。

下面我使用javascript來重寫本篇開頭用java定義的類,程式碼如下:

<script type="text/javascript">
    function Person(name,sex,age,job){
        this.name = name;
        this.sex = sex;
        this.age = age;
        this.job = job;
        this.showPerson = function(){
            console.log("姓名:" + this.name);
            console.log("性別:" + this.sex);
            console.log("年齡:" + this.age);
            console.log("工作:" + this.job);
            console.log(this);// Person { name="馬雲", sex="男", age=46, 更多...}
        }
    }
    var person = new Person("馬雲", "男", 46, "董事長");
    person.showPerson();
</script>

看this指標的列印,類變成了Person,這表明function Person就是相當於在定義一個類,在javascript裡function的意義實在太多,function既是函式又可以表示物件,function是函式時候還能當做建構函式,javascript的建構函式我常認為是把類和建構函式合二為一,當然在javascript語言規範裡是沒有類的概念,但是我這種理解可以作為建構函式和普通函式的一個區別,這樣理解起來會更加容易些

下面我貼出在《javascript高階程式設計》裡對new操作符的解釋:

new操作符會讓建構函式產生如下變化:

1. 建立一個新物件;

2. 將建構函式的作用域賦給新物件(因此this就指向了這個新物件);

3. 執行建構函式中的程式碼(為這個新物件新增屬性);

4. 返回新物件

關於第二點其實很容易讓人迷惑,例如前面例子裡的obj和otherObj,obj.show(),裡面this指向obj,我以前文章講到一個簡單識別this方式就是看方法呼叫前的物件是哪個this就指向哪個,其實這個過程還可以這麼理解,在全域性執行環境裡window就是上下文物件,那麼在obj裡區域性作用域通過obj來代表了,這個window的理解是一致的。

第四點也要著重講下,記住建構函式被new操作,要讓new正常作用最好不能在建構函式裡寫return,沒有return的建構函式都是按上面四點執行,有了return情況就複雜了,這個知識我會在講prototype時候講到。

Javascript還有一種方式可以改變this指標,這就是call方法和apply方法,call和apply方法的作用相同,就是引數不同,call和apply的第一個引數都是一樣的,但是後面引數不同,apply第二個引數是個陣列,call從第二個引數開始後面有許多引數。Call和apply的作用是什麼,這個很重要,重點描述如下:

Call和apply是改變函式的作用域(有些書裡叫做改變函式的上下文)

這個說明我們參見上面new操作符第二條:

將建構函式的作用域賦給新物件(因此this就指向了這個新物件);

Call和apply是將this指標指向方法的第一個引數。

我們看看下面的程式碼:

<script type="text/javascript">
    var name = "sharpxiajun";
    function ftn(name){
        console.log(name);
        console.log(this.name);
        console.log(this);
    }
    ftn("101");
    var obj = {
      name:"xtq"
    };
    ftn.call(obj,"102");
    /*
    * 結果如下所示:
    *101
     T002.html (第 73 行)
     sharpxiajun
     T002.html (第 74 行)
     Window T002.html
     T002.html (第 75 行)
     T002.html (第 73 行)
     xtq
     T002.html (第 74 行)
     Object { name="xtq"}
    * */
</script>

我們看到apply和call改變的是this的指向,這點在開發裡很重要,開發裡我們常常被this所迷惑,迷惑的根本原因我在上文講到了,這裡我講講表面的原因:

表面原因就是我們定義物件使用物件的字面表示法,字面表示法在簡單的表示裡我們很容易知道this指向物件本身,但是這個物件會有方法,方法的引數可能會是函式,而這個函式的定義裡也可能會使用this指標,如果傳入的函式沒有被例項化過和被例項化過,this的指向是不同,有時我們還想在傳入函式裡通過this指向外部函式或者指向被定義物件本身,這些亂七八糟的情況使用交織在一起導致this變得很複雜,結果就變得糊里糊塗。

其實理清上面情況也是有跡可循的,就以定義物件裡的方法裡傳入函式為例:

情形一:傳入的引數是函式的別名,那麼函式的this就是指向window

情形二:傳入的引數是被new過的建構函式,那麼this就是指向例項化的物件本身;

情形三:如果我們想把被傳入的函式物件裡this的指標指向外部字面量定義的物件,那麼我們就是用apply和call

我們可以通過程式碼看出我的結論,程式碼如下:

<script type="text/javascript">
var name = "I am window";
var obj = {
    name:"sharpxiajun",
    job:"Software",
    ftn01:function(obj){
        obj.show();
    },
    ftn02:function(ftn){
        ftn();
    },
    ftn03:function(ftn){
        ftn.call(this);
    }
};
function Person(name){
    this.name = name;
    this.show = function(){
        console.log("姓名:" + this.name);
        console.log(this);
    }
}
var p = new Person("Person");
obj.ftn01(p);
obj.ftn02(function(){
   console.log(this.name);
   console.log(this);
});
obj.ftn03(function(){
    console.log(this.name);
    console.log(this);
});
</script>

結果如下:

最後再總結一下:

如果在javascript語言裡沒有通過new(包括物件字面量定義)、call和apply改變函式的this指標,函式的this指標都是指向window

相關文章