如何編寫高質量的 JS 函數(1) — 敲山震虎篇
- 2019 年 10 月 3 日
- 筆記
本文首發於 vivo互聯網技術 微信公眾號
鏈接:https://mp.weixin.qq.com/s/7lCK9cHmunvYlbm7Xi7JxQ
作者:楊昆
一千個讀者,有一千個哈姆雷特。
此系列文章將會從函數的執行機制、魯棒性、函數式編程、設計模式等方面,全面闡述如何通過 JavaScript 編寫高質量的函數。
一、引言
如何通過 JavaScript 編寫高質量的函數,這是一個很難回答的問題,不同人心中對高質量有自己的看法,這裡我將全面的闡述我個人對如何編寫高質量函數的一些看法。看法可能不夠全面,也可能會有一些錯誤的見解,歡迎一起討論,就像過日子的人,小吵小鬧總會不經意的出現,一顆包容的心莫過於是最好的 best practice 。
我打算用幾篇文章來完成《如何編寫高質量的 JS 函數》 這個系列。
主要從以下幾個方面進行闡述:
- 函數(一切皆有可能)
- 函數的命名
- 函數的注釋
- 函數的複雜度
- 函數的魯棒性(防禦性編程)
- 函數的入參和出參(返回)
- 如何用函數式編程打通函數的任督二脈
- 如何用設計模式讓函數如虎添翼
- 編寫對 V8 友好的函數是一種什麼 style
- 前端工程師的函數狂想錄
本篇只說第一節 函數 ,擒賊先擒王,下面我們來盤一盤函數的七七八八。
二、函數(一切皆有可能)
函數二字,代表着一切皆有可能。
我們想一下:我們用的函數究竟離我們有多遠。就像打麻將一樣,你覺得你能像雀神那樣,想摸啥就來啥么(誇張修辭手法)。
天天和函數打交道,函數出現的目的是什麼?再深入想,函數的執行機制是什麼?下面我們就來簡單的分析一下。
1、函數出現的目的
函數是迄今為止發明出來的用於節約空間和提高性能的最重要的手段。
PS: 注意,沒有之一。
2、函數的執行機制
有句話說的好,知己知彼,百戰不殆。想要勝利,一定要非常的了解敵人。JS 肯定不是敵人啦,但是要想掌握 JS 的函數,要更輕鬆的編寫高質量的函數,那就要掌握在 JS 中函數的執行機制。
怎麼去解釋函數的執行機制呢?
先來模仿一道前端面試題:輸入一個 url 後,會發生什麼?
執行一個函數,會發生什麼?
參考下面代碼:
如果讓我來答,我大致會這樣說:
首先我會創建一個函數。如果你學過 C++ ,可能會說我要先開闢一個堆內存。
所以,我會從創建函數到執行函數以及其底層實現,這三個層次進行分析。
(1)創建函數
函數不是平白無故產生的,需要創建。創建函數時會發生什麼呢?
第一步:開闢一個新的堆內存
每個字母都是要存儲空間的,只要有數據,就一定得有存儲數據的地方。而計算機組成原理中,堆允許程序在運行時動態地申請某個大小的內存空間,所以你可以在程序運行的時候,為函數申請內存。
第二步:創建一個函數 say ,把這個函數體中的代碼放在這個堆內存中。
函數體是以字符串的形式放在堆內存中的。
為什麼呢?我們來看一下 say 函數體的代碼:
第三步:在當前上下文中聲明 say 函數(變量),函數聲明和定義會提升到最前面
注意,當前上下文,我們可以理解為上下文堆棧(棧),say 是放在堆棧(棧)中的,同時它的右邊還有一個堆內存地址,用來指向堆中的函數體的。
PS: 建議去學習一下數據結構,棧中的一塊一塊的,我們稱為幀。你可以把棧理解中 DOM 樹,幀理解為節點,每一幀( 節點 )都有自己的名字和內容。
第四步:把開闢的堆內存地址賦值給函數名 say
這裡關鍵是把堆內存地址賦值給函數名 say 。
下面我畫了一個簡單的示意圖:
結合上圖 say 右邊的存儲,再去理解上面的四個步驟,是不是有點感悟了呢。
(2)你真的懂賦值這個操作嗎?
這裡提到賦值操作。我把堆內存地址賦值給函數名 say 意味着什麼呢?
賦值操作是從計算機組成原理角度看,內存分為好幾個區域,比如代碼區域,棧區域,堆區域等。
這幾個區域每一個存儲空間的內存地址都是不一樣。也就是說,賦值(引用類型)的操作就是將堆區域的某一個地址,通過總線管道流入(複製)到對應棧區域的某一個地址中,從而使棧區域的某一個地址內的存儲空間中有了引用堆區域數據的地址。業界叫句柄,也就是指針。只不過在高級語言中,把指針隱藏了,直接用變量代替指針。
所以一個簡單的賦值,其在計算機底層實現上,都是很複雜的。這裡,也許通過彙編語言,可以更好的去理解賦值的真正含義,比如 1 + 1 用彙編語言編寫,就是下面代碼:
PS: 所以如果是值類型,那就是直接把數據,流(移動)到指定內存地址的存儲空間中。
以上是我從計算機底層去解釋一些創建函數方面最基礎的現象,先闡述到這裡。
(3)執行函數
執行函數過程也非常重要,我用個人的總結去解釋執行這個過程。
思考一個點。
我們知道,函數體的代碼是以字符串形式的保存在堆內存中的。如果我們要執行堆內存中的代碼,首先要將字符串變成真正的 JS 代碼,就像數據傳輸中的序列化和反序列化。
思考題一:為什麼會存在序列化和反序列化?大家可以自行思考一下,有些越簡單的道理,背後越是有着非凡的思想。
(4)將字符串變成真正的 JS 代碼
每一個函數調用,都會在函數上下文堆棧中創建幀。棧是一個基本的數據結構。
為什麼函數執行要在棧中執行呢?
棧是先進後出的數據結構,也就意味着可以很好的保存和恢復調用現場。
來看一段代碼:
函數上下文堆棧是什麼?
函數上下文堆棧是一個數據結構,如果學過 C++ 或者 C 的,可以理解成是一個 struct (結構體)。這個結構體負責管理函數執行已經關閉變量作用域。函數上下文堆棧在程序運行時產生,並且一開始加入到棧裏面的是全局上下文幀,位於棧底。
(5)開始執行函數
首先要明白一點:執行函數(函數調用)是在棧上完成的 。
這也就是為什麼 JS 函數可以遞歸。因為棧先進後出的數據結構,賦予了其遞歸能力。
繼續往下看,函數執行大致有以下四個步驟:
第一步:形成一個供代碼執行的環境,也是一個棧內存。
這裡,我們先思考幾個問題:
- 這個供代碼執行的環境是什麼?
- 這個棧內存是怎麼分配出來的?
- 這個棧內存的內部是一種什麼樣的樣子?
第二步:將存儲的字符串複製一份到新開闢的棧內存中,使其變為真正的 JS 代碼。
第三步:先對形參進行賦值,再進行變量提升,比如將 var function 變量提升。
第四步:在這個新開闢的作用域中自上而下執行。
思考題:為什麼是自上而下執行呢?
將執行結果返回給當前調用的函數
思考題:將執行結果返回給當前調用的函數,其背後是如何實現的呢?
三、談談底層實現
1、計算機中最本質的閉包解釋
函數在執行的時候,都會形成一個全新的私有作用域,也叫私有棧內存。
目的有如下兩點:
- 第一點:把原有堆內存中存儲的字符串變成真正的 JS 代碼。
- 第二點:保護該棧內存的私有變量不受外界的干擾。
函數執行的這種保護機制,在計算機中稱之為 閉包 。
可能有人不明白,咋就私有了呢?
沒問題,我們可以反推。假設不是私有棧內存的,那麼在執行一個遞歸時,基本就結束了,因為一個函數上下文堆棧中,有很多相同的 JS 代碼,比如局部變量等,如果不私有化,那豈不亂套了?所以假設矛盾,私有棧內存成立。
2、棧內存是怎麼分配出來?
JS 的棧內存是系統自動分配的,大小固定。如果自動適應的話,那就基本不存在除死循環這種情況之外的棧溢出了。
3、這個棧內存的內部是一種什麼樣的樣子?
舉個例子,每天寫 return 語句,那你知道 return 的底層是如何實現的嗎?每天寫子程序,那你知道子程序底層的一些真相嗎?
我們來看一張圖:
上圖顯示了一次函數調用的棧結構,從結構中可以看到,內部的組成部分,比如實參,局部變量,返回地址。
看下面代碼:
上面這行代碼的底層含義就是,f() 函數在私有棧內存中執行完後,使用 return 後,將執行結果傳遞給 EAX (累加寄存器),常用於函數返回值。
這裡說一下 Return Addr ,Addr 主要目的是讓子程序能夠多次被調用。
看下面代碼:
如上,在 main 函數中進行多次調用子程序 say ,在底層實現上面,是通過在棧結構中保存一個 Addr 用來保存函數的起始運行地址,當第一個 say 函數運行完以後,Addr 就會指向起始運行地址,以備後面多次調用子程序。
四、JS 引擎是如何執行函數
上面從很多方面分析了函數執行的機制。現在來簡要分析一下,JS 引擎是如何執行函數的。
推薦一篇博客《探索JS引擎工作原理》,我將在此篇博客的基礎上分析一些很重要的細節。
代碼如下:
//定義一個全局變量 x var x = 1 function A(y) { //定義一個局部變量 x var x = 2 function B(z) { //定義一個內部函數 B console.log(x + y + z) } //返回函數B的引用 return B } //執行A,返回B var C = A(1) //執行函數B C(1)
執行 A 函數時
JS 引擎構造的 ESCstack 結構如下:
簡稱 A 圖:
執行 B 函數時
JS 引擎構造的 ESCstack 結構如下:
簡稱 B 圖:
1、局部變量是如何被保存起來的
核心代碼:
那 AO(B) 的目的是什麼?其實 AO(B) 就是每個鏈表的節點其指向的內容。
同時,這裡還定義了 [scope] 屬性,我們可以理解為指針,[scope] 指向了 AO(A) ,而 AO(A) 就是函數 A 的活動對象。
函數活動對象保存着 局部變量、參數數組、this 屬性。這也是為什麼可以在函數內部使用 this 和 arguments 的原因。
scopeChain 是作用域鏈,熟悉數據結構的同學肯定知道我函數作用域鏈本質就是鏈表,執行哪個函數,那鏈表就初始化為哪個函數的作用域,然後把當前指向的函數活動對象放到 scopeChain 鏈表的表頭中。
比如執行 B 函數,那 B 的鏈表看起來就是 AO(B) –> AO(A)
同時,A 函數也是有自己的鏈表的,為 AO(A) –> VO(G) 。所以整個鏈表就串起來來,B 的鏈表(作用域)就是:AO(B) –> AO(A) –> VO(G)
鏈表是一個閉環,因為查了一圈,回到自己的時候,如果還沒找到,那就返回 undefined 。
思考題:[scope] 和 [[scope]] 為什麼以這種形式命名?
2、通過 A 函數的 ECS 我們能看到什麼
我們能看到,JS 語言是靜態作用域語言,在執行函數之前,整個程序的作用域鏈就確定了,從 A 圖中的函數 B 的 B[[scope]] 就可以看到作用域鏈已經確定。不像 lisp 那種在運行時才能確定作用域。
3、執行環境,上下文環境是一種什麼樣的存在
執行環境的數據結構是棧結構,其實本質上是給一個數組增加一些屬性和方法。
執行環境可以用 ECStack 表示,可以理解成 ECSack = [] 這種形式。
棧(執行環境)專門用來存放各種數據,比如最經典的就是保存函數執行時的各種子數據結構。比如 A 函數的執行環境是 EC(A)。當執行函數 A 的時候,相當於 ECStack.push[A] ,當屬於 A 的東西被放入到棧中,都會被包裹成一個私有棧內存。
私有棧是怎麼形成的?從彙編語言角度去看,一個棧的內存分配,棧結構的各種變換,都是有底層標準去控制的。
4、開啟上帝模式看穿 this
this 為什麼在運行時才能確定
上面兩張圖中的紅色箭頭,箭頭處的信息非常非常重要。
看 A 圖,執行 A 函數時,只有 A 函數有 this 屬性,執行 B 函數時,只有 B 函數有 this 屬性,這也就證實了 this 只有在運行時才會存在。
this 的指向真相
看一下 this 的指向,A 函數調用的時候,屬性 this 的屬性是 window ,而 通過 var C = A(1) 調用 A 函數後,A 函數的執行環境已經 pop 出棧了。此時執行 C() 就是在執行 B 函數,EC(B) 已經在棧頂了,this 屬性值是 window 全局變量。
通過 A 圖 和 B 圖的比較,直接展示 this 的本質。
5、作用域的本質是鏈表中的一個節點
通過 A 圖 和 B 圖的比較,直接秒殺 作用域 的所有用法
看 A 圖,執行 A 函數時,B 函數的作用域是創建 A 函數的活動對象 AO(A) 。作用域就是一個屬性,一個屬於 A 函數的執行環境中的屬性,它的名字叫做 [scope] 。
[scope] 指向的是一個函數活動對象,核心點是把這個函數對象當成一個作用域,最好理解成一個鏈表節點。
PS: B 執行 B 函數時,只有 B 函數有 this 屬性,這也就交叉證實了 this 只有在運行時才會存在。
6、作用域鏈的本質就是鏈表
通過比較 A 圖和 B 圖的 scopeChain ,可以確定的是:
作用域鏈本質就是鏈表,執行哪個函數,鏈表就初始化為哪個函數的作用域,然後將該函數的 [scope] 放在表頭,形成閉環鏈表。作用域鏈是通過鏈表查找的,如果走了一圈還沒找到,那就返回 undefined 。
五、用一道面試題讓你更上一層樓(走火入魔)
再舉一個例子,這是一道經常被問的面試題,看下面代碼:
第一個程序如下:
第二個程序如下:
輸出結果大家應該都知道了,結果分別是如下截圖:
第一個程序,輸出 10 個 10 :
第二個程序,輸出 0 到 9 :
那麼問題來了,其內部的原理機制是什麼呢?
- 一部分 coder 只能答到立即調用,閉包。
- 大多數 coder 可以答到作用域相關知識。
- 極少部分 coder (大佬級別) 可以從核心底層原因來分析。
下面從核心底層原因來分析 。
1、分析輸出10個10
代碼如下:
在 kun 函數的 AO(kun) 中的 i 屬性是 10 。
繼續分享, kun 函數的作用域鏈如下:
注意:這裡的 AO(kun) 表示一個節點。這個節點有指針和數據,其中指針指向了 VO(G) ,數據就是 kun 函數的活動對象。
那麼,當一次執行 result 中的數組的時候,會發生什麼現象?
注意:result 數組中的每一個函數其作用域都已經確定了,而 JS 是靜態作用域語言,其在程序聲明階段,所有的作用域都將確定。
那麼 ,result 數組中每一個函數其作用域鏈如下:
因此 result 中的每一個函數執行時,其 i 的值都是沿着這條作用域鏈去查找的,而且由於 kun 函數只執行了一次,導致了 i 值是最後的結果,也就是 10 。所以輸出結果就是 10 個 10 。
總結一下,就是 result 數組中的 10 個函數在聲明後,總共擁有了 10 個鏈表(作用域鏈),都是 AO(result[i]) –> AO(kun) –> VO(G)這種形式,但是 10 個作用域鏈中的 AO(kun) 都是一樣的。所以導致了,輸出結果是 10 個 10 。
下面我們來分析輸出 0 到 9 的結果。
2、分析輸出0到9
代碼如下:
下面通過偽代碼來展示:
ECSack = [ EC(kun) = { [scope]: VO(G) AO(kun) = { i: 0, result[0] = function() {...// return i}, arguments:[], this: window }, scopeChain:<AO(kun), kun[[scope]]> }, // ..... EC(kun) = [ [scope]: VO(G) AO(kun) = { i: 9, result[9] = function() {...// return i}, arguments:[], this: window }, scopeChain:<AO(kun), kun[[scope]]> ] ]
上面簡單的用結構化的語言表示了 kun 函數在聲明時的內部情況,需要注意一下兩點:
第一點:每一個 EC(kun) 中的 AO(kun) 中的 i 屬性值都是不一樣的,比如通過上面結構化表示,可以看到:
- result[0] 函數的父執行環境是 EC(kun) ,這個 VO(kun) 裏面的 i 值 是 0 。
- result[9] 函數的父執行環境是 EC(kun) ,這個 VO(kun) 裏面的 i 值 是 9 。
記住 AO(kun) 是一段存儲空間。
第二點:關於作用域鏈,也就是 scopeChain ,result 中的函數的 鏈表形式仍然是下面這種形式:
所以總結就是:執行 result 數組中的 10 個函數時,運行10 個不同的鏈表,同時每個鏈表的 AO(kun) 節點是不一樣的。每個 AO(kun) 節點中的 i 值也是不一樣的。
所以輸出的結果最後顯示為 0 到 9 。
六、總結
通過對底層實現原理的分析,我們可以更加深刻的去理解函數的執行機制,從而寫出高質量的函數。
如何減少作用域鏈(鏈表)的查找
比如很多庫,像JQ 等,都會在立即執行函數的最外面傳一個 window 參數。這樣做的目的是因為,window 是全局對象,通過傳參,避免了查找整個作用域鏈,提高了函數的執行效率。
如何防止棧溢出?每一次執行函數,都會創建函數的執行環境,也就意味着佔用一些棧內存,而棧內存大小是固定的,如果寫了很大的遞歸函數,就會造成棧內存溢出,引發錯誤。
我覺得,我們要去努力的達成這樣一個成就:
做到當我在手寫一個函數時,我心中非常清楚的知道我正在寫的每一行代碼,其在內存中是怎麼表現的,或者說其在底層是如何執行的,從而達到** 眼中有碼,心中無碼** 的境界。
如果能做到這樣的話,那還怕寫不出高質量的函數嗎?
七、參考文檔
更多內容敬請關注 vivo 互聯網技術 微信公眾號
註:轉載文章請先與微信號:labs2020 聯繫。