You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
在開頭的時候就先用CreateFunctionContext新建了一個 function context,接著可以看到存取變數的方式也與之前單純用暫存器不同,這邊用的是:StaCurrentContextSlot跟LdaCurrentContextSlot,碰到看不懂的指令都可以去 /src/interpreter/interpreter-generator.cc 查一下定義:
// StaCurrentContextSlot <slot_index>//// Stores the object in the accumulator into |slot_index| of the current// context.IGNITION_HANDLER(StaCurrentContextSlot, InterpreterAssembler) {
Node*value=GetAccumulator();
Node*slot_index=BytecodeOperandIdx(0);
Node*slot_context=GetContext();
StoreContextElement(slot_context, slot_index, value);
Dispatch();
}
// LdaCurrentContextSlot <slot_index>//// Load the object in |slot_index| of the current context into the accumulator.IGNITION_HANDLER(LdaCurrentContextSlot, InterpreterAssembler) {
Node*slot_index=BytecodeOperandIdx(0);
Node*slot_context=GetContext();
Node*result=LoadContextElement(slot_context, slot_index);
SetAccumulator(result);
Dispatch();
}
// CreateClosure <index> <slot> <tenured>//// Creates a new closure for SharedFunctionInfo at position |index| in the// constant pool and with the PretenureFlag <tenured>.IGNITION_HANDLER(CreateClosure, InterpreterAssembler) {
Node*context=GetContext();
Node*result=CallRuntime(Runtime::kNewClosure, context, shared, feedback_cell);
SetAccumulator(result);
Dispatch();
}
因此,在 inner function 裡面呼叫 LdaCurrentContextSlot 的時候,就會載入到正確的 context 以及正確的 i。
結論:
var 的版本是 CreateFunctionContext,從頭到尾就一個 context
let 的版本每一圈迴圈都會 CreateBlockContext,總共會有 10 個 context
前言
在我以前寫過的兩篇文章:我知道你懂 hoisting,可是你了解到多深?以及所有的函式都是閉包:談 JS 中的作用域與 Closure 裡面,都談到了 let 與 var 作用域的不同。
let 的作用域是 block,而 var 則是 fucntion,像這個就是經典案例:
原本預期會依序輸出 1~10,沒想到卻輸出了 10 個 11。背後原因就是因為第三行那個 i 永遠都只有一個,就是 for 迴圈宣告的那個
var i
,從頭到尾都是同一個變數。而經典解法也很簡單,把 var 改成 let 就搞定了:
搞定的原因是,可以把上面程式碼看作是下面這種形式:
由於 let 的作用域是 block,所以在每一圈迴圈裡面,其實都是一個新的 i,因此迴圈跑 10 圈就有了 10 個不同的 i,最後當然是輸出 10 個不同的數字。
因此 var 跟 let 在這個範例最大的區別就在於變數的數量,前者只有 1 個,後者卻有了 10 個。
好,既然知道了 let 與 var 的差別以後,就可以來看看這篇最主要想討論的問題。
其實這問題是來自於 YDKJS(You Dont Know JS,中譯本翻做:你不知道的JavaScript) 的作者 @getify 在他的推特上所提出的:
若是沒有看得很懂,可以繼續看延伸的另外一則推:
簡單來說呢,平常用 let 搭配迴圈的時候,不是如我們上面所說的,每一圈都會有一個新的
i
嗎?既然是這樣的話,那 var 與 let 應該就會有效能上的差異,因為 let 必須每一圈都 new 一個新的變數出來,所以 let 會比較慢。那如果迴圈裡面並不需要每一圈都有新的 i,JS 引擎會做優化嗎?這個問題就是這樣,主要是想探討 JS 引擎會不會針對這種行為去做優化。
那要怎麼知道呢?要嘛你是 JS 引擎的開發者,要嘛你去看 JS 引擎的原始碼,但這兩種難度都有點太高。不過別擔心,還有第三種:看 JS bytecode。
JavaScript Bytecode
若是不知道 bytecode 是什麼,可以參考這一篇很經典的文章:Understanding V8’s Bytecode,中譯版:理解 V8 的字节码。
先來看文章裡面解釋得最清楚的一張圖片:
在執行 JavaScript 的時候,V8 會先把程式碼編譯成 bytecode,然後再把 bytecode 編譯成 machine code,最後才執行。
舉個現實生活中的範例好了,若是你想把一篇英文文章翻譯成文言文,通常會先把英文文章翻譯成白話文,再從白話文翻譯成文言文。因為直接從英文翻譯成文言文難度過高,先翻成白話文會比較好翻譯;同時,在翻譯成白話文的時候也可以先做一些優化,這樣會比較好翻成文言文。
在這個比喻中,白話文就是我們這篇的主角:bytecode。
在以前寫 C/C++ 的時候,若是想知道編譯器會不會針對某一段程式碼做優化,最直接的方法就是輸出編譯過後的 assembly code,從組合語言裡面反推回原本的程式碼,就可以知道編譯器有沒有做事。
而 bytecode 也是一樣的,可以從產生出來的 bytecode 往回推,就知道 V8 有沒有做事情了。
那要怎麼看 V8 產生出來的 bytecode 呢?最簡單的方式就是使用 Node.js 的指令:
node --print-bytecode a.js
,只要加上--print-bytecode
這個 flag 就行了。但如果你真的去試了,會發現輸出了一大堆東西,這很正常。因為除了你寫的程式碼以外,本來就還有一大堆內建的東西,所以我們可以用
--print-bytecode-filter
去過濾 function 的名稱。var 與 let:Round 1
我準備的測試程式碼如下:
接著就可以用指令:
node --print-bytecode --print-bytecode-filter="find_me*" a.js > byte_code.txt
,把結果存到byte_code.txt
裡面,內容如下:第一行都有標明是哪一個 function,方便我們做辨識:
[generated bytecode for function: find_me_let_for]
,再來就是實際的 bytecode 了。在看 bytecode 以前有一個預備知識非常重要,那就是在 bytecode 執行的環境底下,有一個叫做 accumulator 的暫存器。通常指令裡面如果有
a
這個字,就是 accumulator 的簡寫(以下簡稱 acc)。例如說 bytecode 的第二三行:
LdaZero
與Star r0
,前者就是:LoaD Accumulator Zero
,設置 acc register 為 0,接著下一行Star r0
就是Store Accumulator to register r0
,就是r0=acc
,所以 r0 會變成 0。我把上面的
find_me_let_for
翻成了白話文:若是看不習慣這種形式的人,可能是沒有看過組合語言(實際上組合語言比這個難多了就是了...),多看幾次就可以習慣了。
總之呢,上面的程式碼就是一個會一直 log r0 的迴圈,直到 r0>=10 為止。而這個 r0 就是我們程式碼裡面的 i。
仔細看的話,會發現 let 跟 var 的版本產生出來的 bytecode 是一模一樣的,從頭到尾都只有一個變數 r0。因此呢,就可以推測出 V8 的確會對這種情形做優化,不會真的每一圈迴圈都新建一個 i,用 let 的時候不需要擔心跟 var 會有效能上的差異。
var 與 let:Round 2
再來我們可以試試看「一定需要每一圈新建一個 i」的場合,那就是當裡面有 closure 需要存取 i 的時候。這邊準備的範例程式碼如下:
用跟剛剛同樣的指令,一樣可以看到產生出來的 bytecode,我們先來看一下那兩個 inner function 有沒有差別:
可以看到唯一的差別是 let 的版本多了一個:
ThrowReferenceErrorIfHole
,這一個在我知道你懂 hoisting,可是你了解到多深?裡面有提過,其實就是 TDZ(Temporal Dead Zone)在 V8 上的實作。最後就是我們的主菜了,先從 var 開始看吧:
在開頭的時候就先用
CreateFunctionContext
新建了一個 function context,接著可以看到存取變數的方式也與之前單純用暫存器不同,這邊用的是:StaCurrentContextSlot
跟LdaCurrentContextSlot
,碰到看不懂的指令都可以去 /src/interpreter/interpreter-generator.cc 查一下定義:簡單來說呢,StaCurrentContextSlot 就是把 acc 的東西存到現在的 context 的某個 slot_index,而 LdaCurrentContextSlot 則是相反,把東西取出來放到 acc 去。
因此可以先看開頭這幾行:
就是把 0 放到 current context 的 slot_index 4 裡面去,接著再拿去放到 r1,然後再去跟 10 比較。這一段其實就是 for 迴圈裡面的
i<10
。而後半段的:
其實就是 i++。
所以 i 會存在 current context slot 的 index 為 4 的位置。再來我們回顧一下前面所說的 inner function:
有沒有注意到
LdaCurrentContextSlot [4]
這一行?這一行就呼應了我們上面所說的,在 inner function 用這一行把 i 給拿出來。所以在 var 的範例裡面,開頭就會先新增一個 function context,然後從頭到尾都只有這一個 context,會把 i 放在裡面 slot index 為 4 的位置,而 inner function 也會從這個位置把 i 拿出來。
因此從頭到尾 i 都只有一個。
最後來看看複雜許多的 let 的版本:
因為這程式碼有點太長而且不容易閱讀,所以我刪改了一下,改寫了一個比較白話的版本:
第一個重點是每一圈迴圈都會呼叫
CreateBlockContext
,新建一個 context,然後再迴圈結束前把 CurrentContextSlot(也就是 i)的值存到 r0,下一圈迴圈時再讓新的 block context slot 的值從 r0 讀取出來然後 +1,藉此來實作不同 context 值的累加。然後你可能會很好奇,那這個 block context 到底會用在哪裡?
上面的 bytecode 裡面,呼叫
setTimeout
的是這一段:在我們呼叫
CreateClosure
把這個 closure 傳給 setTimeout 的時候,就一起傳進去了(非完整程式碼,只保留 context 的部分):因此,在 inner function 裡面呼叫
LdaCurrentContextSlot
的時候,就會載入到正確的 context 以及正確的 i。結論:
總結
有些你以為答案「顯而易見」的問題,其實並不一定。
例如說以下這個範例:
v1 跟 v2 哪一個比較快?
「v1 裡面每一圈迴圈都會重新宣告一次 a 並且賦值,v2 只會在外面宣告一次,裡面迴圈只有賦值而已,所以 v1 比較快」
答案是兩個一模一樣,因為如果你夠瞭解 JS,就會知道根本沒有什麼「重新宣告」這種事,宣告早在編譯階段就被處理掉了。
就算效能真的有差,可是到底差多少?是值得我們投注心力在上面的差異嗎?
例如說在寫 React 的時候,常常被教導要避免掉 inline function:
用頭腦想一想十分合理,下面那個每跑一次 render 就會產生一個新的 function,上面那個則是永遠都共用同一個。儘管他們的確有效能上的差異,但這個差異或許比你想的還要小。
再舉最後一個例子:
若是 A 跟 B 都表示同一個很大的物件,哪一個會比較快?
以直覺來看,顯然是 A,因為 B 看起來就是多此一舉,先把 object 變成字串然後再丟給 JSON.parse,多了一個步驟。但事實上,B 比較快,而且快了 1.5 倍以上。
很多東西以直覺來看是一回事,實際上又是另外一回事。因為直覺歸直覺,但是底層牽涉到了 compiler 或甚至是作業系統幫你做的優化,把這些考慮進來的話,很可能又是另外一回事。
就如同這篇文章裡面在探討的題目,以直覺來看 let 是會比 var 還要慢的。但事實證明了在不需要用到 closure 的場合裡面,兩者並沒有差異。
針對這些問題,你當然可以猜測,但你要知道的是這些僅僅只是猜測。想知道正確答案為何,必須要有更科學的方法,而不只是「我覺得」。
The text was updated successfully, but these errors were encountered: