JavaScript由文件物件模型DOM、瀏覽器物件模型BOM以及它的核心ECMAScript這三個部分組成,這篇文章帶來了JavaScript中的底層原理知識,希望對大家有幫助。
JavaScript是直譯式的詮釋式腳本語言,它具有動態性、弱型別、基於原型等特點。 JavaScript植根於我們使用的網頁瀏覽器中,它的解釋器為瀏覽器中的JavaScript引擎。這門廣泛用於客戶端的腳本語言,最早是為了處理以前由伺服器端語言負責的一些輸入驗證操作,隨著Web時代的發展,JavaScript不斷發展壯大,成為一門功能全面的程式語言。它的用途早已不再局限於當初簡單的資料驗證,而是具備了與瀏覽器視窗及其內容等幾乎所有方面互動的能力。它既是一門非常簡單的語言,也是一門及其複雜的語言,要真正精通JavaScript,我們就必須深入的去了解它的一些底層設計原理。本文將參考《JavaScript高級程式設計》和《你不知道的JS》系列叢書,為大家講解一些關於JavaScript的底層知識。
資料類型
# 以儲存方式,JavaScript的資料類型可分為兩種,原始資料類型(原始值)和引用資料類型(引用值)。
原始資料型別目前有六種,包括Number、String、Boolean、Null、Undefined、Symbol(ES6),這些型別是實際可以直接運算的儲存在變數中的值。原始資料類型存放在堆疊中,資料大小確定,它們是直接按值存放的,所以可以直接按值存取。
引用資料型別則為Object,在JavaScript中除了原始資料型別以外的都是Object類型,包括陣列、函數、正規表示式等都是物件。引用型別是存放在堆疊記憶體中的對象,變數是保存在堆疊記憶體中的一個指向堆疊記憶體中物件的參考位址。當定義了一個變數並初始化為引用值,若將它賦給另一個變量,則這兩個變數保存的是同一個位址,指向堆記憶體中的同一個記憶體空間。如果透過其中一個變數去修改引用資料型別的值,另一個變數也會跟著改變。
對原始資料類型,除了null比較特殊#(null會被視為一個空的物件參考),其它的我們可以用typeof進行準確判斷:
#表達式 |
傳回值 |
typeof 123 |
'number' |
typeof "abc" |
'string' |
typeof true |
#'boolean' |
typeof null |
#'object' |
typeof undefined |
'undefined' |
#typeof unknownVariable(未定義的變數) |
'undefined' |
typeof Symbol() # |
'symbol' |
#typeof function() {} |
'function' |
#typeof {} |
'object' |
#typeof [] |
|
#'object' |
#typeof(/[0-9,a-z]/) |
'object'# 對於null類型,可以使用全等運算符進行判斷。一個已經宣告但未初始化的變數值會預設賦予undefined
(
也可以手動賦予undefined),在JavaScript中,使用相等運算子==無法區分null和undefined,ECMA-262規定它們的相等性測試要回傳true。要準確區分兩個值,需要使用全等操作符===。 對於引用資料型,除了function在方法設計上較為特殊,可以用typeof進行精確判斷,其它的都回傳object型別。我們可以用instanceof 對引用型別值進行判斷。 instanceof 會偵測一個物件A是不是另一個物件B的實例,它在底層會檢視物件B是否在物件A的原型鏈上存在著
實例和原型鏈文章後面會講)。如果存在,則傳回true,如果不在則傳回false。 | |
# 傳回值 |
[1,2,3] instanceof Array |
'true' |
|
由於所有參考型別值都是Object的實例,所以用instance運算子對它們進行Object的判斷,結果也會回傳true。
表達式 |
#傳回值 |
[1,2,3] instanceof Object |
'true' |
當然,還有更強大的方法,可以精確的判斷任何JavaScript中的任何資料類型,那就是Object.prototype.toString.call() 方法。在ES5中,所有物件(原生物件和宿主物件)都有一個內部屬性[[Class]],它的值是一個字串,記錄了該物件的類型。目前包括"Array", "Boolean", "Date", "Error", "Function", "Math", "Number", "Object", "RegExp", "String",“Arguments”, "JSON", "Symbol」。透過Object.prototype.toString() 方法可以用來檢視該內部屬性,除此自外沒有其它方法。
在Object.prototype.toString()方法被呼叫時,會執行下列步驟:1.取得this物件的[[Class]]屬性值#( 關於this物件文章後面會講)。 2.將該值放在兩個字串”[object ” 與 “]” 中間並拼接。 3.傳回拼接完的字串。
當遇到this的值為null時,Object.prototype.toString()方法直接回傳」[object Null]」。當遇到this的值為undefined時,直接回傳”[object Undefined]」。
表達式 |
|
##[object 布林] |
|
#Object.prototype.toString.call(null) |
##[object Null] |
#Object.prototype.toString.call(undefined) |
[object Undefined] |
Object.prototype.toString.call (Symbol()) |
[object Symbol] |
# #Object.prototype.toString.call(function foo(){}) |
#[object Function] |
#Object.prototype.toString.call([1,2,3]) |
[object Array] |
Object.prototype.toString.call({name:”Alan” }) |
[object Object] |
#Object.prototype.toString.call(new Date()) |
[object Date] |
Object.prototype.toString .call(RegExp()) |
[object RegExp] |
Object.prototype.toString.call(window.JSON) ### |
[物件JSON] |
Object.prototype.toString.call(數學) |
[物件數學] |
call()方法可以改變Object.prototype.toString()方法時this的指向,使它指向我們傳入的對象,因此能取得我們傳入對象的[[Class]]屬性(使用Object.prototype.toString.apply()也能達到相同的效果)。
JavaScript的資料型別也是可轉換的,資料型別轉換分為兩種方式:顯示型別轉換與隱含型別轉換。
顯示型別轉換可以呼叫方法有Boolean()、String()、Number()、parseInt()、parseFloat()和toString() (null 和undefined值沒有這個方法),它們各自的用途一目了然,這裡就不一一介紹了。
由於JavaScript是弱型別語言,使用算術運算子時,運算子兩邊的資料型別可以是任意的,不用像Java或C語言那樣指定相同的類型,引擎會自動為它們進行隱式類型轉換。隱式型別轉換不像顯示型別轉換那麼直觀,主要是三種轉換方式:
1. 將數值轉換為原始值:toPrimitive()
我們可以先透過typeof判斷是否為Number類型,再透過isNaN判斷目前資料是否為NaN。
引用類型與原始包裝類型的主要區別是對象的生存週期,自動創建的原始包裝類型對象,只存在於一行代碼的執行瞬間,然後立即被銷毀,因此我們不能在運行時為原始類型值新增屬性和方法。
在《你不知道的JavaScript》一書中作者表示過,儘管將JavaScript歸類為「動態語言」或「解釋執行語言”,但事實上它是一門編譯語言。 JavaScript運行分為三個步驟:1.語法分析 2.預編譯 3.解釋執行。語法分析和解釋執行都不難理解,一個是檢查程式碼是否有語法錯誤,一個則負責將程式一行一行的執行,但JavaScript中的預編譯階段卻稍微比較複雜。
任何JavaScript程式碼在執行前都要進行編譯,編譯過程大部分情況下發生在程式碼執行前的幾微秒內。編譯階段JavaScript引擎會從目前程式碼執行作用域開始,對程式碼進行RHS查詢,以取得變數的值。接著在執行階段引擎會執行LHS查詢,對變數進行賦值。
在編譯階段,JavaScript引擎的部分工作就是找到所有的聲明,並用適當的作用域將它們關聯起來。在預先編譯過程,如果是在全域作用域下,JavaScript引擎會先在全域作用域上建立一個全域物件(GO對象,Global Object),並將變數宣告和函數宣告進行提升。提升後的變數先預設初始化為undefined,而函數則將整個函數體進行提升(##如果是以函數表達式的形式定義函數,則應用變數提升的規則),然後將它們存放到全域變數中。函數宣告的提升會優先於變數宣告的提升,對於變數宣告來說,重複出現的var宣告會被引擎忽略,而後面出現的函數宣告可以覆寫前面的函數宣告( ES6新的變數宣告語法let情況稍稍有點不一樣,這裡暫時先不討論)。
函數體內部是一個獨立的作用域,在函數體內部也會進行預編譯階段。在函數體內部,首先會建立一個活動物件(AO對象,Active Object),並將形參和變數宣告以及函數體內部的函數宣告進行提升,形參和變數初始化為undefined,內部函數仍為內部函數體本身,然後將它們存放到活動物件中。
編譯階段結束後,就會執行JavaScript程式碼。執行過程依先後順序依序對變數或形參進行賦值操作。引擎會在作用域上尋找是否有對應的變數宣告或形參聲明,如果找到了就會對它們進行賦值操作。對於非嚴格模式來說,若變數未經聲明就進行賦值,引擎會在全域環境自動隱式地為該變數建立一個聲明,但對於嚴格模式來說對未經聲明的變數進行賦值操作則會報錯。因為JavaScript執行是單一執行緒的,所以如果在賦值運算(LHS查詢)執行前就先對變數進行取得(RHS查詢)並輸出,會得到undefined的結果,因為此時變數還未賦值。
# 每個函數都是Function物件的一個實例,在JavaScript中,每個物件都有一個僅供JavaScript引擎存取的內部屬性- [[Scope]]。對於函數來說,[[Scope]]屬性包含了函數被建立的作用域中物件的集合-作用域鏈。當在全域環境中建立函數時,函數的作用域鏈便會插入一個全域對象,包含所有在全域範圍內定義的變數。
#內部作用域可以存取外部作用域,但外部作用域無法存取內部作用域。由於JavaScript沒有區塊級作用域,因此在if語句或for迴圈語句中定義的變數是可以在語句外部存取的。在ES6之前,javascript只有全域作用域和函數作用域,ES6新增了區塊級作用域的機制。
而當該函數被執行時,會為執行函數建立一個稱為執行環境(execution context,也稱為執行上下文)#的內部對象。每個執行環境都有自己的作用域鏈,當執行環境被建立時,它的作用域鏈頂端先初始化為目前運行函數的[[Scope]]屬性中的物件。緊接著,函數運行時的活動物件(包括所有局部變數、命名參數、arguments參數集合和this)也會被建立並推入作用域鏈的最頂端。
# 函數每次執行對應的執行環境是獨一無二的,並且多次呼叫同一個函數就會導致創建多個執行環境。當函數執行完畢,執行環境就會被銷毀。當執行環境被銷毀,活動物件也會隨之銷毀(全域執行環境會等到應用程式登出時才會被銷毀,如關閉網頁或瀏覽器)。
函數執行過程中,每遇到一個變量,都會經歷一次標識符解析過程,以決定從哪裡獲取或儲存資料。標識符解析是沿著作用域鏈一級一層地搜尋標識符的過程,全域變數始終都是作用域鏈的最後一個物件(即window物件)。
在JavaScript中,有兩個語句可以執行時暫時改變作用域鏈。第一個是with語句。 with語句會建立一個可變對象,包含參數指定對象的所有屬性,並將該對象推入作用域鏈的首位,這表示函數的活動對像被擠到作用域鏈的第二位。這樣雖然使得存取可變物件的屬性非常快,但存取局部變數等的速度就變慢了。第二條能改變執行環境作用域鏈的語句是try-catch語句中的catch子句。當try程式碼區塊中發生錯誤,執行過程會自動跳到catch子句,然後把異常物件推入一個變數物件並置於作用域的首位。在catch程式碼區塊內部,函數所有局部變數將會放在第二個作用域鏈物件中。一旦catch子句執行完畢,作用域鏈就會回到先前的狀態。
JavaScript中的建構子可以用來建立特定類型的物件。為了區別於其它函數,建構函數一般使用大寫字母開頭。不過在JavaScript中這並不是必須的,因為JavaScript不存在定義建構子的特殊語法。在JavaScript中,建構函數與其它函數的唯一區別,就在於呼叫它們的方式不同。任何函數,只要透過new操作符來調用,就可以作為建構函數。
JavaScript#函數有四個呼叫模式:1.獨立函數呼叫模式,如foo (arg)。 2.方法呼叫模式,如obj.foo(arg)。 3.建構器呼叫模式,如new foo(arg)。 4.call/apply呼叫模式,如foo.call(this,arg1,arg2)或foo.apply(this,args) (此處的args是一個陣列)。
要建立建構子的實例,發揮建構子的作用,就必須使用new運算元。當我們使用new運算元實例化建構子時,建構函式內部會執行下列步驟:
1.隱含建立一個this空物件
2.執行建構子中的程式碼(為目前this物件新增屬性)
3.隱式傳回目前this物件
#如果建構函數顯示的傳回一個對象,那麼實例為此傳回的對象,否則則為隱式傳回的this對象。
當我們呼叫建構函式建立實例後,實例就具備建構函式所有的實例屬性與方法。對於透過建構函式所建立的不同實例,它們之間的實例屬性和方法都是各自獨立的。那怕是同名的引用型別值,不同實例之間也不會互相影響。
原型與原型鏈既是JavaScript這門語言的精髓之一,也是這門語言的困難之一。原型prototype(顯式原型)是函數特有的屬性,任何時候,只要建立了一個函數,這個函數就會自動建立一個prototype屬性,並指向該函數的原型物件。所有原型物件都會自動取得一個constructor(建構者,也可翻譯為建構子)屬性,這個屬性包含一個指向prototype屬性所在函數(即建構子本身)的指標。而當我們透過建構函式建立一個實例後,該實例內部將包含一個[[Prototype]]的內部屬性(隱式原型),同樣也指向建構函式的原型物件。在Firefox、Safari和Chrome瀏覽器中,每個物件都可以透過__proto__屬性存取它們的[[Prototype]]屬性。而對其它瀏覽器而言,這個屬性對腳本是完全不可見的。
建構子的prototype屬性和實例的[[Prototype]]都是指向建構子的原型對象,實例的[[ Prototype]] 屬性與建構函式之間並沒有直接的關係。要知道實例的 [[Prototype]] 屬性是否指向某個建構子的原型對象,我們可以使用isPrototypeOf()或Object.getPrototypeOf() 方法。
每當讀取某個物件實例的某個屬性時,都會執行一次搜索,目標是具有給定名字的屬性。搜尋首先從物件實例本身開始,如果在實例中找到了具有給定名稱的屬性,就傳回該屬性的值;如果沒有找到,則繼續搜尋該物件[[Prototype]]屬性指向的原型對象,在原型物件中尋找給定名稱的屬性,如果找到再傳回該屬性的值。
#判斷物件是哪個建構函式的直接實例,可以直接在實例上存取constructor屬性,實例會透過[[Prototype]]讀取原型物件上的constructor屬性傳回建構函式本身。
原型物件中的值可以透過物件實例訪問,但不能透過物件實例進行修改。如果在實例中加入一個與實例原型物件同名的屬性,那我們就在實例中建立該屬性,這個實例屬性會阻止我們存取原型物件中的那個屬性,但不會修改那個屬性。簡單將該實例屬性設為null並不能恢復存取原型物件中該屬性的連接,若要恢復存取原型物件中的該屬性,可以用delete操作符完全刪除物件實例的該屬性。
使用hasOwnProperty()方法可以偵測一個屬性是存在於實例中,還是存在於原型中。這個方法只有在給定屬性存在於物件實例中時,才會傳回true。若要取得物件本身所有可列舉的實例屬性,可以使用ES5的Object.keys() 方法。若要取得所有實例屬性,不論是否可列舉,可以使用Object.getOwnPropertyNames() 方法。
原型具有動態性,原型物件所做的任何修改都能立即從實例上反應出來,但如果重寫出整個原型對象,情況就不一樣了。呼叫建構函式會為物件實例新增一個指向最初原型物件的[[Prototype]] 指針,而重寫整個原型物件後,建構函式指向新的原型對象,所有的原型物件屬性和方法都存在著與新的原型對像上;而對象實例也指向最初的原型對象,這樣一來構造函數與最初原型對象之間指向同一個原型對象產生的聯繫就被切斷了,因為它們分別指向了不同的原型對象。
若要恢復此聯繫,可以在建構子prototype重寫後再實例化物件實例,或修改物件實例的__proto__屬性重新指向建構函數新的原型物件。
JavaScript將原型鍊作為實作繼承的主要方式,它利用原型讓一個引用型別繼承另一個引用型別的屬性與方法。建構函數的實例有一個指向原型物件的[[Prototype]] 屬性,當我們讓建構子的原型物件等於另一個類型的實例,原型物件也會包含一個指向另一個原型的[[Prototype]] 指針,假如另一個原型又是另一個類型的實例…如此層層遞進,就構成了實例與原型的鏈條。這就是所謂原型鏈的基本概念。
原型鏈擴充了原型搜尋機制,當讀取一個執行個體屬性時,首先會在實例中搜尋該屬性。如果沒有找到該屬性,則會繼續搜尋實例[[Prototype]] 所指向的原型對象,原型物件此時也變成了另一個建構函式的實例,如果該原型物件上也找不到,就會繼續搜尋這個原型物件[[Prototype]] 指向的另一個原型物件…搜尋過程沿著原型鏈不斷向上搜索,在找不到指定屬性或方法的情況下,搜尋過程就會一環一環地執行到原型鏈末端才會停下來。
如果沒有對函數的原型物件進行修改,則所有參考型別都有一個[[Prototype]] 屬性預設指向Object的原型物件。因此,所有函數的預設原型都是Object的實例,這也正是所有自訂類型都會繼承toString()、valueOf() 等預設方法的根本原因。可以使用instanceof運算子或isPrototypeOf() 方法來判斷實例的原型鏈中是否存在某個建構子的原型。
原型鏈雖然很強大,但它也存在一些問題。第一個問題是原型物件上的引用類型值是所有實例共享的,這意味著不同實例的引用類型屬性或方法都指向同一個堆內存,一個實例在原型上修改引用值會同時影響到所有其它實例在原型物件上的該引用值,這就是為何要在建構函式中定義私有屬性或方法,而不是在原型上定義的原因。原型鏈的第二個問題,在於當我們將一個建構函式的原型prototype等於另一個建構函式的實例時,如果我們在這時候給另一個建構子參數設定屬性值,那麼就基於原來的建構子所有實例的這個屬性都會因為原型鏈的關係跟著被賦予相同的值,而這有時並不是我們想要的結果。
閉包是JavaScript最強大的功能之一,在JavaScript中,閉包,是指有權存取另一個函數作用域中的變數的函數,它意味著函數可以存取局部作用域之外的資料。創建閉包的常見方式,就是在一個函數內部建立另一個函數並傳回這個函數。
大致上講,當函數執行完畢後,局部活動物件就會被銷毀,記憶體中只保存全域作用域。但是,閉包的情況有所不同。
閉包函數的[[Scope]]屬性會初始化為包裹它的函數的作用域鏈,所以閉包包含了與執行環境作用域鏈相同的物件的參考。一般來講,函數的活動物件會隨著執行環境而銷毀。但引入閉包時,由於引用仍存在於閉包的[[Scope]]屬性中,因此原始函數的活動物件無法被銷毀。這意味著閉包函數與非閉包函數相比,需要更多的記憶體開銷,導致更多的記憶體洩漏。此外,當閉包存取原包裹函數的活動物件時,在作用域鏈上需要先跨過對自身活動物件的識別符解析,找到更上面的一層,因此閉包使用原包裹函數的變數對效能也是有很大的影響。
在定時器、事件監聽器、Ajax請求、跨視窗通訊、Web Workers或任何其他的非同步或同步任務中,只要使用了回呼函數,實際上就是在使用閉包。
典型的閉包問題就是在for迴圈中使用計時器輸出循環變數:
這段程式碼,對於不熟悉JavaScript閉包的朋友來說,可能會想當然的認為結果會依序輸出0、1、2、3,然而,實際的情況是,這段程式碼輸出的四個數字都是4。
這是因為,由於定時器是非同步載入機制,會等for迴圈遍歷完畢才會執行。每次執行定時器,定時器都會在它外部作用域中尋找i變數。由於循環已經結束,外部作用域的i變數早已更新為4,所以4個定時器取得的i變數都為4,而不是我們理想中輸出0,1,2,3。
解決這個問題,我們可以建立一個包裹立即執行函數的新的作用域,將每次循環中外部作用域的i變量保存到新創建的作用域中,讓定時器每次都先從新作用域中取值,我們可以用立即執行函數來創建這個新的作用域:
# 這樣循環執行的結果就會依序輸出0,1, 2,3了,我們還可以把這個立即執行函數再簡化一些,直接將i作用實參傳給立即執行函數,就不用在裡面給j賦值了:
當然,採用立即執行函數不是必須的,你也可以創建一個非匿名的函數並在每次循環的時候執行它,只不過這樣就會多佔用一些內存來保存函數聲明了。
因為在ES6之前還沒有區塊級作用域的設定,所以我們只能採取手動建立一個新的作用域的方法來解決這個問題。 ES6開始設定了區塊級作用域,我們可以使用let定義區塊級作用域的方法:
let運算子建立區塊級作用域,透過let聲明的變數保存在目前區塊級作用域中,所以每個立即執行函數每次都會從它目前的區塊級作用域中尋找變數。
let還有一個特殊的定義,它使變數在循環過程中不只被宣告一次,每次循環都會重新聲明,並用上一個循環結束時的值來初始化新宣告的變量,所以,我們也可以直接在for迴圈頭使用let:
(ES6新增的箭頭函數裡的this有所不同,它的指向取決於函數宣告的位置。)
還記得我前面提到的函數四種呼叫模式嗎: 1.獨立函數呼叫模式,如foo(arg)。 2.物件方法呼叫模式,如obj.foo(arg)。 3.建構器呼叫模式,如new foo(arg)。 4.call/apply呼叫模式,如foo.call(this)或foo.apply(this) 。
對於獨立函數呼叫模式來說,在非嚴格模式下,它裡面的this會預設指向全域物件。而在嚴格模式中,this不允許預設綁定到全域對象,因此會綁定為undefined。 對於物件方法呼叫模式來說,函數中的this會指向呼叫它的物件本身: 對於建構器呼叫模式,前面有介紹過建構子內部的執行步驟: 1.隱式建立一個this空物件
2.執行建構子中的程式碼(為目前this物件新增屬性)
3.隱式返回目前this物件
以上是深入理解JS資料型態、預編譯、執行上下文等JS底層機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!