標籤:lob 轉化 改變 也會 包含 效能最佳化 位置 授權 一個
從瀏覽器如何編譯JS代碼說起
很久以來我就在思考,當我們把代碼交給瀏覽器,瀏覽器是如何把代碼轉換為活靈活現的網頁的。JS引擎在執行我們的代碼前,瀏覽器對我們的代碼還做了什麼,這個過程對我來說就像黑匣子一般,神秘而又讓人好奇。
理解
var a = 2
我們每天都會寫類似var a = 2這樣的簡單的JS代碼,可是瀏覽器是機器,它可只認識二進位的0和1,var a = 2對它來說肯定比外語對我們還難。不過有困難不要緊,至少我們現在問題清晰了,要知道它是如何把有意義的人類字元轉化為符合一定規則的機器的0 和 1 。
想想我們是如何閱讀一句話的(可以想想我們不那麼熟悉的外語),我們不熟悉英語的時候,我們其實優先去理解的是一個個的詞,這些詞按照一定的規則就成了有意義的句子。瀏覽器其實也是如此var a = 2,瀏覽器其實看到的是var,a,=,2這是一個個的詞。這個過程叫做詞法解析階段,換句話說是這個過程會將由字元組成的字串分解成(對程式設計語言來說)有意義的代碼塊。
就像我們按照文法規則群組合單詞為句子一樣,瀏覽器也會把上述已經分解好的代碼塊組合為代表了程式文法結構的樹(AST),這個階段稱為文法分析階段,AST對瀏覽器來說已經是有意義的外語了,不過距離它直接理解還差一步代碼產生,轉碼為有意義的機器語言(二進位語言)。
我們總結一下經曆的三階段
- 詞法分析:分解代碼為有意義的詞語;* 文法分析:把有意義的詞語按照文法規則群組合成代表程式文法結構的樹(AST);* 代碼產生:將 AST 轉換為可執行代碼
通過上述三個階段,瀏覽器已經可以運行我們得到的可執行代碼了,這三個階段還有一個合稱呼叫做編譯階段。我們把之後對可執行代碼的執行稱為運行階段。
JS的範圍在何時確定
程式設計語言中,範圍一般來說有兩種,詞法範圍和動態範圍。詞法範圍就是依賴編程時所寫的代碼結構確定的範圍,一般來說在編譯結束後,範圍就已經確定,代碼運行過程中不再改變。而動態範圍聽名字就知道是在代碼運行過程中範圍會動態改變。一般認為我們的javascript的範圍是詞法範圍(說一般,是因為javascript提供了一些動態改變範圍的方法,後文會有介紹)。
詞法範圍就是依賴編程時所寫的代碼結構確定的範圍,對比一下瀏覽器在編譯階段做的事情,我們發現,詞法範圍就是在編譯階段確定的。看到這裡是不是突然理解了為什麼以前我們常常聽到的“函數的範圍在函數定義階段就確定了”這句話了。接下來我們就來說明函數範圍是按照什麼規則確定的。
JS中的範圍範圍是什嗎?
關於範圍是什嗎?《You don’t know js》給出了這麼一個概念:
使用一套嚴格的規則來分辨哪些標識符對那些文法有存取權限。
好吧,好抽象的一句話,標識符又是什麼呢?範圍到底要怎麼理解啊?我們一個個來看。
標識符:
我們知道,當我們的程式啟動並執行時候,我們的資料(”字串”,“對象”,“函數”等等都是要載入記憶體的)。那我們該如何訪問到對應的記憶體地區呢,標識符就在這時候起作用了,通過它我們就能找到對應的資料,從這個角度來看,變數名,函數名等等都是標識符。
對標識符的操作
知道了標識符,我們來想想,平時我們會對標識符進行哪些操作。其實無外乎兩種,看下面的代碼:
// 第一種定義了標識符`a`並把數值2賦值給了`a`這種操作有一個專門的術語叫做`LHS`var a = 2;// 第二種,var b = a ,其實對應a ,b 兩個操作符是不同的操作,對b來說是一個賦值操作,這是LHS,但是對a來說卻是取到a對應的值,這種操作也有一個專門的術語叫做“RHS”var b = a;
小結一下,對標識符來說有以下兩種操作
- 賦值操作(LHS);常見的是函數定義,函數傳參,變數賦值等等* 取值操作(RHS);常見包括函數調用
再回過頭來看範圍
明白了標識符及對標識符的兩種操作,我們可以很容易的理解範圍了,範圍其實就是定義了我們的呈現在運行期,進行標識符操作的範圍,對應到實際問題來說,就是我們熟悉的函數或者變數可以在什麼地方調用。
範圍也可以看做是一套依據名稱尋找變數的規則。那我們再細看一下這個規則,在當前範圍中無法找到某個變數時,引擎就會在外層嵌套的範圍中繼續尋找,直到找到該變數, 或抵達最外層的範圍(也就是全域範圍)為止。
這裡提到了嵌套一詞,我們接下來看js中那些因素可以形成範圍。
JS中的範圍類型函數範圍
函數範圍是js中最常見的範圍了,函數範圍給我們最直觀的體會就是,內建函式可以調用外部函數中的變數。一層層的函數,很直觀的就形成了嵌套的範圍。不過只說這一點真對不起本文的標題,還記得我們常常聽到的“如果在函數內部我們給一個未定義的變數賦值,這個變數會轉變為一個全域變數”。對我來說之前這句話幾乎是背下來的,我一直都沒能理解。我們從對標識符的操作的角度來理解這句話。
var a = 1;function foo(){// b第一次出現在函數foo中 b = a ;}foo();// 全域可以訪問到bconsole.log(b); //1
在我們調用foo()時,對b其實是進行了LHS操作(取得a的值並賦值給b),b前面並不存在var let 等,因此瀏覽器首先在foo()範圍裡面尋找b這個標識符,結果在b裡面沒有找到,按照範圍的規則,瀏覽器會繼續在foo()的外層範圍尋找標識符b,結果還是沒有找到,說明在這次查詢標識符b的範圍內並不存在已經定義的b,在非strict 模式下LHS操作會在可尋找範圍的最外層(也就是全域)定義一個b,因此b也就成了一個全域的變數了(strict 模式LHS找不到返回ReferenceError錯誤)。這樣那句話就可以理解了。同樣值得我們注意的是對操作符進行RHS操作會出現不同的情況,無論嚴格或者非strict 模式RHS找不到對返回ReferenceError錯誤(對RHS找到的值進行不合理的操作會返回錯誤TypeError(範圍判別成功,操作非法。))。
閉包:閉包是基於詞法範圍書寫代碼時所產生的自然結果,你甚至不需要為了利用它們而有意 識地建立閉包。閉包的建立和使用在你的代碼中隨處可見。你缺少的是根據你自己的意願 來識別、擁抱和影響閉包的思維環境。
塊範圍
除了函數範圍,JS也提供塊範圍。我們應該明確,範圍是針對標識符來說的,塊範圍把標識符限制在{}中。
ES6 提供的let,const方法聲明的標識符都會固定於塊中。常被大家忽略的try/catch的catch語句也會建立一個塊範圍。
改變函數範圍的方法
一般說來詞法範圍在代碼編譯階段就已經確定,這種確定性其實是很有好處的,代碼在執行過程中,能夠預測在執行過程中如何對它們進行尋找。能夠提高代碼運行階段的執行效率。不過JS也提供動態改變範圍的方法。eval()函數和with關鍵字.
eval()方法:
這個方法接受一個字串為參數,並將其中的內容視為好像在書寫時就存在於程式中這個位置的代碼。換句話說,可以在你寫的代碼中用程式產生代碼並運行,就好像代碼是寫在那個位置的一樣。
function foo(str,a){ eval(str);//欺騙範圍,詞法階段階段foo()函數中並沒有定義標識符,但是在函數運行階段卻臨時定義了一個b; console.log(a,b); } var b = 2; foo("var b =3;",1);//1,3 // strict 模式下,`eval()`會產生自己的範圍,無法修改所在的範圍 function foo(str){ ‘use strict‘; eval(str); console.log(a);//ReferenceError: a is not defined } foo(‘var a =2‘);
eval()有時候挺有用,但是效能消耗很大,可能也會帶來安全隱患,因此不推薦使用。
with關鍵字:
with 通常被當作重複引用同一個對象中的多個屬性的捷徑。
var obj = { a: 1, b: 2, c: 3 }; // 單調乏味的重複 "obj" obj.a = 2; obj.b = 3; obj.c = 4; // 簡單的捷徑 with (obj) { a = 3; b = 4; c = 5; } function foo(obj) { with (obj) { a = 2; } } var o1 = { a: 3 }; var o2 = { b: 3 }; foo( o1 ); console.log( o1.a ); // 2 foo( o2 ); console.log( o2.a ); // undefined console.log( a ); // 2——不好,a被泄漏到全域範圍上了! // 執行了LHS查詢,不存在就在全域建立了一個。 // with 聲明實際上是根據你傳遞給它的對象憑空建立了一個全新的詞法範圍。
with也會帶來效能的損耗。
JavaScript 引擎會在編譯階段進行數項的效能最佳化。其中有些最佳化依賴於能夠根據代碼的詞法進行靜態分析,並預先確定所有變數和函數的定義位置,才能在執行過程中快速找到標識符。
聲明提升
範圍關係到的是標識符的作用範圍,而標識符的作用範圍和它的聲明位置是密切相關的。在js中有一些關鍵字是專門用來宣告身份識別符的(比如var,let,const),非匿名函數的定義也會宣告身份識別符。
關於聲明也許大家都聽說過聲明提升一詞。我們來分析一下造成聲明提升的原因。
我們已經知道引擎會在解釋 JavaScript 代碼之前首先對其進行編譯。編譯階段中的一部分工作就是找到所有的聲明,並用合適的範圍將它們關聯起來(詞法範圍的核心)。
這樣的話,聲明好像被提到了前面。
值得注意的是每個範圍都會進行提升操作。聲明會被提升到所在範圍的頂部。
不過並非所有的聲明都會被提升,不同聲明提升的權重也不同,具體來說函式宣告會被提升,函數運算式不會被提升(就算是有名稱的函數運算式也不會提升)。
通過var 定義的變數會提升,而let和const進行的聲明不會提升。
函式宣告和變數聲明都會被提升。但是一個值得注意的細節也就是函數會首先被提升,然後才是變數,也就是說如果一個變數聲明和一個函式宣告同名,那麼就算在語句順序上變數聲明在前,該標識符還是會指向相關函數。
如果變數或函數有重複聲明以會第一次聲明為主。
最後一點需要注意的是:
聲明本身會被提升,而包括函數運算式的賦值在內的賦值操作並不會提升。
範圍的一些應用
看到這裡,我想大家對JS的範圍應該有了一個比較細緻的瞭解。下面說一下對JS範圍的一些拓展應用。
最小特權原則
也叫最小授權或最小暴露原則。這個原則是指在軟體設計中,應該最小限度地暴露必要內容,而將其他內容都“隱藏”起來,比如某個模組或對象的 API 設計。也就是儘可能多的把部分代碼私人化。
函數可以產生自己的範圍,因此我們可以採用函數封裝(函數運算式和函式宣告都可以)的方法來實現這一原則。
// 函數運算式var a = 2;(function foo() { // <-- 添加這一行 var a = 3; console.log(a); // 3 })(); // <-- 以及這一行 console.log( a ); // 2
這裡順便說明一下如何區分函數運算式和函式宣告:
如果 function 是聲明中 的第一個詞,那麼就是一個函式宣告,否則就是一個函數運算式。
函式宣告和函數運算式之間最重要的區別是它們的名稱標識符將會綁定在何處。函數運算式可以是匿名的,而函式宣告則不可以省略函數名——在 JavaScript 的文法中這是非法的。
可以使用立即執行的函數運算式(IIFE)的方式來封裝。
立即執行的函數運算式(IIFE)
var a = 2; (function foo() { var a = 3; console.log(a); // 3 })(); console.log(a); // 2
函數運算式後面加上一個括弧後會立即執行。
(function(){ .. }())是IIFE的另外一種表達方式括弧加在裡面和外面,功能是一樣的。
順便說一下,IIFE 的另一個非常普遍的進階用法是把它們當作函數調用並傳遞參數進去。
var a = 2; (function IIFE(global) { var a = 3; console.log(a); // 3 console.log( global.a ); // 2 })(window); console.log(a); // 2
閉包
一般大家都會這麼形容閉包。
當一個函數的傳回值是另外一個函數,而返回的那個函數如果調用了其父函數內部的其它變數,如果返回的這個函數在外部被執行,就產生了閉包。
function foo() { var a = 2; function bar() { console.log(a); } return bar; } var baz = foo(); baz(); // 2 —— 這就是閉包的效果。在函數外訪問了函數內的標識符 // bar()函數持有對其父範圍的引用,而使得父範圍沒有被銷毀,這就是閉包
一般來說,由於記憶體回收機制的存在,函數在執行完以後會被銷毀,不再使用的記憶體空間。上例中由於看上去 foo()的內容不會再被使用,所以很自然地會考慮對其進行回收。而閉包的“神奇”之處正是可以阻止這件事情的發生(以前總有人說要減少使用閉包,害怕記憶體流失什麼的,其實這個也不大比擔心)。
其實上面這個定義,在好久之前我就知道,不過同時我也誤以為我平時很少用到閉包,因為我真的並沒有主動去用過閉包,不過其實我錯了,無意中,我一直在使用閉包。
本質上無論何時何地,如果將函數(訪問它們各自的詞法範圍)當作第一 級的實值型別併到處傳遞,你就會看到閉包在這些函數中的應用。在定時器、事件監聽器、 Ajax請求、跨視窗通訊、Web Workers或者任何其他的非同步(或者同步)任務中,只要使 用了回呼函數,實際上就是在使用閉包!
所以你應該知道,你已經用過很多次閉包了。
這裡說一個大家可能都遇到過的坑,一個沒有正確理解範圍和閉包造成的坑。
for (var i = 1; i <= 5; i++) { setTimeout(function timer() { console.log(i); }, i * 1000); }// 其實我們想得到的結果是1,2,3,4,5,結果卻是五個6
我們分析一下造成這個結果的原因:
我們試圖假設迴圈中的每個迭代在運行時都會給自己“捕獲”一個 i 的副本。但是根據範圍的工作原理,實際情況是儘管迴圈中的五個函數是在各個迭代中分別定義的(前面說過以第一次定義為主,後面的會被忽略), 但是它們都被封閉在一個共用的全域範圍中,因為在時間到了執行timer函數時,全域裡面的這個i就是6,因此無法達到預期。
理解了是範圍的問題,這裡我們有兩種解決辦法:
// 辦法1 for (var i = 1; i <= 5; i++) { (function(j) { setTimeout(function timer() { console.log(j); }, j * 1000); })(i); //通過一個立即執行函數,為每次迴圈建立一個單獨的範圍。 } // 辦法2 for (var i = 1; i <= 5; i++) { let j = i; // 是的,閉包的塊範圍! setTimeout( function timer() { console.log(j); }, j * 1000); } // let 每次迴圈都會建立一個塊範圍
現在的開發都離不開模組化,下面說說模組是如何利用閉包的。
模組是如何利用閉包的:
最常見的實現模組模式的方法通常被稱為模組暴露。
我們來看看如何定義一個模組
function CoolModule() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log(something); } function doAnother() { console.log(another.join(" ! ")); } // 返回的是一個對象,對象中可能包含各種函數 return { doSomething: doSomething, doAnother: doAnother }; } var foo = CoolModule();// 在外面調用返回對象中的方法就形成了閉包 foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
模組的兩個必要條件:
原文連結
JavaScript的範圍和閉包