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語言一樣變數是分為兩種類型:基礎資料型別 (Elementary Data Type)和參考型別。
基本類型 是指: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
運行之,我們發現作為基礎資料型別 (Elementary Data Type),我們沒法為這個變數添加屬性,當然方法也同樣不可以,例如下面的代碼:
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時候,棧區的標示符和值都會有值,堆區應該也有,只不過堆區是個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時候最好使用!運算子。
代碼四裡我們看到當字串被賦值了,但是賦值是個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,我們賦值Null 字元串,如果基本類型是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、“”(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了。
看來本主題又沒法寫完,其實本來我寫本文是想講new,prototype,call(apply)以及this,沒想講變數定義就講了這麼多,算了,先發表出來吧,吃了晚飯接著寫,希望今天寫完。