1) 引子
前不久我建立的技術羣裏一位MM問了一個這樣的問題,她貼出的代碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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世界裏一切皆爲對象也不爲過。
但是實際開發裏如果我們對基本類型和引用類型的區別不是很清晰,就會碰到我們很多不能理解的問題,下面我們來看看下面的代碼:
1
2
3
4
5
6
7
|
var
str
=
"sharpxiajun";
str.attr01
=
"hello world";
console.log(str);// 運行結果:sharpxiajun
console.log(str.attr01);//
運行結果:undefined
|
運行之,我們發現作爲基本數據類型,我們沒法爲這個變量添加屬性,當然方法也同樣不可以,例如下面的代碼:
1 2 3 4 5 6 7 | str.ftn = function(){ console.log("str ftn"); } str.ftn(); |
運行之,結果如下圖所示:
當我們使用引用類型時候,結果就和上面完全不同了,大家請看下面的代碼:
1
2
3
4
5
|
var
obj1
=
new
Object();
obj1.name
=
"obj1 name";
console.log(obj1.name);//
運行結果:obj1 name
|
javascript裏的基本類型和引用類型的區別和其他語言類似,這是一個老調長談的問題,但是在現實中很多人都理解它,但是卻很難應用它去理解問題。
Javascript裏的基本變量是存放在棧區的(棧區指內存裏的棧內存),它的存儲結構如下圖所示:
javascript裏引用變量的存儲就比基本類型存儲要複雜多,引用類型的存儲需要內存的棧區和堆區(堆區是指內存裏的堆內存)共同完成,如下圖所示:
在javascript裏變量的存儲包含三個部分:
部分一:棧區的變量標示符;
部分二:棧區變量的值;
部分三:堆區存儲的對象。
變量不同的定義,這三個部分也會隨之發生變化,下面我來列舉一些典型的場景:
場景一:如下代碼所示:
1 2 3 | var qqq; console.log(qqq);// 運行結果:undefined |
運行結果是undefined,上面的代碼的標準解釋就是變量被命名了,但是還未初始化,此時在變量存儲的內存裏只擁有棧區的變量標示符而沒有棧區的變量值,當然更沒有堆區存儲的對象。
場景二:如下代碼所示:
1
2
3
4
5
|
var
qqq;
console.log(qqq);//
運行結果:undefined
console.log(xxx);
|
會提示變量未定義。在任何語言裏變量未定義就使用都是違法的,我們看到javascript裏也是如此,但是我們做javascript開發時候,經常有人會說變量未定義也是可以使用,怎麼我的例子裏卻不能使用了?那麼我們看看下面的代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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對象。
由上面我列舉的兩個場景我們來理解下引子裏網友提出的問題,下面我修改一下代碼,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
//var
a = 1;
function
hehe()
{
console.log(a);
var
a
=
2;
console.log(a);
}
hehe();
|
結果如下圖所示:
我再改下代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | //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更耗內存了,那麼我們看看下面的代碼:
1
2
3
4
5
6
7
8
9
10
11
|
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基本類型,在變量定義屬性程序還是不會報錯,這是提升程序健壯性的一個重要手段,由引子的例子我們還知道,變量定義最好放在變量所述作用域的最前端,這麼做也是保證代碼健壯性的一個重要手段。
下面我們再看一段代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | 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呢?
1
2
3
4
5
6
7
8
9
10
11
|
var
num
=
0;
if
(!!num){
console.log("true");
}else{
console.log("false");
}
|
運行之,結果是false。
如果我們把num改爲負數或正數,那麼運行之的結果就是true了。
這說明了一個道理:我們定義變量初始化值的時候,如果基本類型是string,我們賦值空字符串,如果基本類型是number我們賦值爲0,這樣使用if語句我們就可以判斷該變量是否是被使用過了。
但是當變量是對象時候,結果卻不一樣了,如下代碼:
1 2 3 4 5 6 7 8 9 10 11 | var obj = {}; if (!!obj){ console.log("true"); }else{ console.log("false"); } |
運行之,代碼是true。
所以在定義對象變量時候,初始化時候我們要給變量賦予null,這樣if語句就可以判斷變量是否初始化過。
其實if加上!運算判斷對象的現象還有玄機,這個玄機要等我把場景三講完才能說清楚哦。
場景三:複製變量的值和函數傳遞參數
首先看看這個場景的代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
var
s1
=
"sharpxiajun";
var
s2
=
s1;
console.log(s1);////
運行結果:sharpxiajun
console.log(s2);////
運行結果:sharpxiajun
s2
=
"xtq";
console.log(s1);////
運行結果:sharpxiajun
console.log(s2);////
運行結果:xtq
|
上面是基本類型變量的賦值,我們再看看下面的代碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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的屬性也被修改。
函數傳遞參數的本質就是外部的變量複製到函數參數的變量裏,我們看看下面的代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
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語言是不允許操作堆內存,因此堆內存的變量並沒有被複制,所以複製引用對象複製的值就是堆內存的地址,而複製雙方的兩個變量使用的對象是相同的,因此複製的變量其中一個修改了對象,另一個變量也會受到影響。
原理講完了,下面我列舉一個拔高的例子,代碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | 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,我們可以通過!運算符計算,因此當我們的代碼如下:
1
2
3
4
5
6
7
8
9
10
11
|
var
obj
=
{};
if
(!!obj){
console.log("true");
}else{
console.log("false");
}
|
結果則是true,因爲var obj = {}相當於var obj = new Object(),雖然對象裏沒什麼內容,但是在堆區裏,對象的內存已經分配了,而變量棧區的值已經是內存地址了,所以if語句判斷就是true了。