談談javascript語法裡一些難點問題(一)

發表於2014-12-10

1)    引子

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

執行結果如下所示:

第一個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世界裡一切皆為物件也不為過。

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

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

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

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

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

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

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

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

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

部分二:棧區變數的值;

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

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

場景一:如下程式碼所示

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

場景二:如下程式碼所示

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

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

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

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

結果如下圖所示:

我再改下程式碼:

執行之,結果如下所示:

對比二者程式碼以及引子裡的程式碼,我們發現問題的關鍵是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更耗記憶體了,那麼我們看看下面的程式碼:

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

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

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

執行之,結果都是列印出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呢?

執行之,結果是false。

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

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

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

執行之,程式碼是true。

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

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

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

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

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

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

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

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

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

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

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

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

還有個知識,如下:

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

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

部分二:棧區變數的值;

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

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

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

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

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

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

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

看來本主題又沒法寫完,其實本來我寫本文是想講new,prototype,call(apply)以及this,沒想講變數定義就講了這麼多,算了,先發表出來吧,吃了晚飯接著寫,希望今天寫完。

相關文章