首頁 > webfront > ECMAS > js > > 正文

從java發微javascript語法里的一些難點問題-js變量,棧區,作用域

點擊:

本文分析javascript語法里的難點問題-js變量, 棧區,作用域。結合代碼闡述問題背后原理。并結合java分析。

前不久我建立的技術群里一位MM問了一個這樣的問題,她貼出的代碼如下所示:
var a = 1;
function hehe() {
  console.log(a);//undefind
  var a = 2;
  console.log(a);//2
}
hehe();

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

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

當時群里很多人都給出了問題的解答,我也給出了我自己的解答,其實這個問題很久之前我的確研究過,但是剛被問起了我居然還是有個卡殼期,在加上最近研究javascript MVC的寫法,發現自己讀代碼時候對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開發時候,經常有人會說變量未定義也是可以使用,怎么我的例子里卻不能使用了?那么我們看看下面的代碼:

var xxx = "outer xxx";

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

function testFtn(){

  sss = "inner sss";

  console.log(sss);// 運行結果:"inner sss"

}

testFtn();

console.log(sss);//運行結果:"inner sss"

在javascript定義變量需要使用var關鍵字,但是javascript可以不使用var預先定義好變量,在javascript我們可以直接賦值給沒有被var定義的變量,不過此時你這么操作變量,不管這個操作是在全局作用域里還是在局部作用域里,變量最終都是屬于window對象,我們看看window對象的結構,如下圖所示:

由這兩個場景我們可以知道在js里的變量不能正常使用即“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。

講解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指針的使用。

我們先看看下面的代碼:

    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標簽里我們可以直接使用this指針,this指針就是window對象,我們看到即使使用三等號它們也是相等的。全局作用域常常會干擾我們很好的理解javascript語言的特性,這種干擾的本質就是:

在javascript語言里全局作用域可以理解為window對象,記住window是對象而不是類,也就是說window是被實例化的對象,這個實例化的過程是在頁面加載時候由javascript引擎完成的,整個頁面里的要素都被濃縮到這個window對象,因為程序員無法通過編程語言來控制和操作這個實例化過程,所以開發時候我們就沒有構建這個this指針的感覺,常常會忽視它,這就是干擾我們在代碼里理解this指針指向window的情形。

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

1
2
3
4
5
6
7
8
<script type="text/javascript">
    function ftn01(){
       console.log("I am ftn01!");
    }
    var ftn02 = function(){
        console.log("I am ftn02!");
    }

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

1
2
3
4
5
6
7
8
9
10
<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!");
    }

這又是一段沒有按順序執行的代碼,先看看ftn02,打印結果是undefined,undefined我在前文里講到了,在內存的棧區已經有了變量的名稱,但是沒有棧區的變量值,同時堆區是沒有具體的對象,這是javascript引擎在預處理(群里東方說預處理比預加載更準確,我同意他的說法,以后文章里我都寫為預處理)掃描變量定義所致,但是ftn01的打印結果很令人意外,既然打印出完成的函數定義了,而且代碼并沒有按順序執行,這只能說明一個問題:

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

關于函數表達式的寫法還有秘密可以探尋,我們看下面的代碼:

1
2
3
4
5
6
7
8
9
<script type="text/javascript">
    function ftn03(){
        var ftn04 = function(){
            console.log(this);// window
        };
        ftn04();
    }
    ftn03();

運行結果我們發現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操作符。我們看看下面的代碼:

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

這是我上篇講到的關于this使用的一個例子,寫法一是我們大伙都愛寫的一種寫法,里面的this指針不是指向window的,而是指向Object的實例,firebug的顯示讓很多人疑惑,其實Object就是面向對象的類,大括號里就是實例對象了,即obj和otherObj。Javascript里通過字面量方式定義對象的方式是new Object的簡寫,二者是等價的,目的是為了減少代碼的書寫量,可見即使不用new操作字面量定義法本質也是new操作符,所以通過new改變this指針的確是不過攻破的真理。下面我使用javascript來重寫本篇開頭用java定義的類,代碼如下:

 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();

看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指針指向方法的第一個參數。

我們看看下面的代碼:

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");

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

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

其實理清上面情況也是有跡可循的,就以定義對象里的方法里傳入函數為例:

情形一:傳入的參數是函數的別名,那么函數的this就是指向window;

情形二:傳入的參數是被new過的構造函數,那么this就是指向實例化的對象本身;

情形三:如果我們想把被傳入的函數對象里this的指針指向外部字面量定義的對象,那么我們就是用apply和call

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

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);
});

結果如下:

最后再總結一下:

如果在javascript語言里沒有通過new(包括對象字面量定義)、call和apply改變函數的this指針,函數的this指針都是指向window的。

本文轉載自:http://www.codeceo.com/article/javascript-problems.html