如何編寫高質量的 JS 函數(1) -- 敲山震虎篇

本文首發於 vivo互聯網技術 微信公衆號 
連接:https://mp.weixin.qq.com/s/7lCK9cHmunvYlbm7Xi7JxQ
做者:楊昆javascript

一千個讀者,有一千個哈姆雷特。html

此係列文章將會從函數的執行機制、魯棒性、函數式編程、設計模式等方面,全面闡述如何經過 JavaScript 編寫高質量的函數。前端

1、引言

如何經過 JavaScript 編寫高質量的函數,這是一個很難回答的問題,不一樣人心中對高質量有本身的見解,這裏我將全面的闡述我我的對如何編寫高質量函數的一些見解。見解可能不夠全面,也可能會有一些錯誤的看法,歡迎一塊兒討論,就像過日子的人,小吵小鬧總會不經意的出現,一顆包容的心莫過因而最好的 best practice 。java

我打算用幾篇文章來完成《如何編寫高質量的 JS 函數》 這個系列。面試

主要從如下幾個方面進行闡述:編程

  • 函數(一切皆有可能)
  • 函數的命名
  • 函數的註釋
  • 函數的複雜度
  • 函數的魯棒性(防護性編程)
  • 函數的入參和出參(返回)
  • 如何用函數式編程打通函數的任督二脈
  • 如何用設計模式讓函數如虎添翼
  • 編寫對 V8 友好的函數是一種什麼 style
  • 前端工程師的函數狂想錄

本篇只說第一節 函數 ,擒賊先擒王,下面咱們來盤一盤函數的七七八八。設計模式

2、函數(一切皆有可能)

函數二字,表明着一切皆有可能。數組

咱們想一下:咱們用的函數究竟離咱們有多遠。就像打麻將同樣,你以爲你能像雀神那樣,想摸啥就來啥麼(誇張修辭手法)。微信

每天和函數打交道,函數出現的目的是什麼?再深刻想,函數的執行機制是什麼?下面咱們就來簡單的分析一下。前端工程師

一、函數出現的目的

函數是迄今爲止發明出來的用於節約空間和提升性能的最重要的手段。

PS: 注意,沒有之一。

二、函數的執行機制

有句話說的好,知己知彼,百戰不殆。想要勝利,必定要很是的瞭解敵人。JS 確定不是敵人啦,可是要想掌握 JS 的函數,要更輕鬆的編寫高質量的函數,那就要掌握在 JS 中函數的執行機制。

怎麼去解釋函數的執行機制呢?

先來模仿一道前端面試題:輸入一個 url 後,會發生什麼?

執行一個函數,會發生什麼?

參考下面代碼:

function say() {
  let str = 'hello world'
  console.log(str)    
}

這道面試題要是交給你,你能答出多少呢?

若是讓我來答,我大體會這樣說:

首先我會建立一個函數。若是你學過 C++ ,可能會說我要先開闢一個堆內存。

因此,我會從建立函數到執行函數以及其底層實現,這三個層次進行分析。

(1)建立函數

函數不是無緣無故產生的,須要建立。建立函數時會發生什麼呢?

第一步:開闢一個新的堆內存

每一個字母都是要存儲空間的,只要有數據,就必定得有存儲數據的地方。而計算機組成原理中,堆容許程序在運行時動態地申請某個大小的內存空間,因此你能夠在程序運行的時候,爲函數申請內存。

第二步:建立一個函數 say ,把這個函數體中的代碼放在這個堆內存中。

函數體是以字符串的形式放在堆內存中的。

爲何呢?咱們來看一下 say 函數體的代碼:

let str = 'hello world'
console.log(str)

這些語句以字符串的形式放在堆內存中比較好,由於沒有規律。若是是對象,因爲有規律,能夠按照鍵值對的形式存儲在堆內存中。而沒規律的一般都是變成字符串的形式。

第三步:在當前上下文中聲明 say 函數(變量),函數聲明和定義會提高到最前面

注意,當前上下文,咱們能夠理解爲上下文堆棧(棧),say 是放在堆棧(棧)中的,同時它的右邊還有一個堆內存地址,用來指向堆中的函數體的。

PS: 建議去學習一下數據結構,棧中的一塊一塊的,咱們稱爲幀。你能夠把棧理解中 DOM 樹,幀理解爲節點,每一幀( 節點 )都有本身的名字和內容。

第四步:把開闢的堆內存地址賦值給函數名 say

這裏關鍵是把堆內存地址賦值給函數名 say 。

下面我畫了一個簡單的示意圖:

結合上圖 say 右邊的存儲,再去理解上面的四個步驟,是否是有點感悟了呢。

(2)你真的懂賦值這個操做嗎?

這裏提到賦值操做。我把堆內存地址賦值給函數名 say 意味着什麼呢?

賦值操做是從計算機組成原理角度看,內存分爲好幾個區域,好比代碼區域,棧區域,堆區域等。

這幾個區域每個存儲空間的內存地址都是不同。也就是說,賦值(引用類型)的操做就是將堆區域的某一個地址,經過總線管道流入(複製)到對應棧區域的某一個地址中,從而使棧區域的某一個地址內的存儲空間中有了引用堆區域數據的地址。業界叫句柄,也就是指針。只不過在高級語言中,把指針隱藏了,直接用變量代替指針。

因此一個簡單的賦值,其在計算機底層實現上,都是很複雜的。這裏,也許經過彙編語言,能夠更好的去理解賦值的真正含義,好比 1 + 1 用匯編語言編寫,就是下面代碼:

start:
mov ax, 1
mov bx, 1
add ax, bx
end start;
從上面代碼中,咱們能夠看到,把 1 賦值給 ax ,使用到了 mov 指令。而 mov 是 move 移動的縮寫,這也證實了,在賦值這個操做上,本質上是數據或者數據的句柄在一張地址表中的流動。

PS: 因此若是是值類型,那就是直接把數據,流(移動)到指定內存地址的存儲空間中。

以上是我從計算機底層去解釋一些建立函數方面最基礎的現象,先闡述到這裏。

(3)執行函數

執行函數過程也很是重要,我用我的的總結去解釋執行這個過程。

思考一個點。

咱們知道,函數體的代碼是以字符串形式的保存在堆內存中的。若是咱們要執行堆內存中的代碼,首先要將字符串變成真正的 JS 代碼,就像數據傳輸中的序列化和反序列化。

思考題一:爲何會存在序列化和反序列化?你們能夠自行思考一下,有些越簡單的道理,背後越是有着非凡的思想。

(4)將字符串變成真正的 JS 代碼

每個函數調用,都會在函數上下文堆棧中建立幀。棧是一個基本的數據結構。

爲何函數執行要在棧中執行呢?

棧是先進後出的數據結構,也就意味着能夠很好的保存和恢復調用現場。

來看一段代碼:

function f1() {
  let b = 1;
  function f2() {
    cnsole.log(b)
  }
  return f2
}

let fun = f1()
fun()

函數上下文堆棧是什麼?

函數上下文堆棧是一個數據結構,若是學過 C++ 或者 C 的,能夠理解成是一個 struct (結構體)。這個結構體負責管理函數執行已經關閉變量做用域。函數上下文堆棧在程序運行時產生,而且一開始加入到棧裏面的是全局上下文幀,位於棧底。

(5)開始執行函數

首先要明白一點:執行函數(函數調用)是在棧上完成的 。

這也就是爲何 JS 函數能夠遞歸。由於棧先進後出的數據結構,賦予了其遞歸能力。

繼續往下看,函數執行大體有如下四個步驟:

第一步:造成一個供代碼執行的環境,也是一個棧內存。

這裏,咱們先思考幾個問題:

  • 這個供代碼執行的環境是什麼?
  • 這個棧內存是怎麼分配出來的?
  • 這個棧內存的內部是一種什麼樣的樣子?

第二步:將存儲的字符串複製一份到新開闢的棧內存中,使其變爲真正的 JS 代碼。

第三步:先對形參進行賦值,再進行變量提高,好比將 var function 變量提高。

第四步:在這個新開闢的做用域中自上而下執行。

思考題:爲何是自上而下執行呢?

將執行結果返回給當前調用的函數

思考題:將執行結果返回給當前調用的函數,其背後是如何實現的呢?

3、談談底層實現

一、計算機中最本質的閉包解釋

函數在執行的時候,都會造成一個全新的私有做用域,也叫私有棧內存。

目的有以下兩點:

  • 第一點:把原有堆內存中存儲的字符串變成真正的 JS 代碼。
  • 第二點:保護該棧內存的私有變量不受外界的干擾。

函數執行的這種保護機制,在計算機中稱之爲 閉包 。

可能有人不明白,咋就私有了呢?

沒問題,咱們能夠反推。假設不是私有棧內存的,那麼在執行一個遞歸時,基本就結束了,由於一個函數上下文堆棧中,有不少相同的 JS 代碼,好比局部變量等,若是不私有化,那豈不亂套了?因此假設矛盾,私有棧內存成立。

二、棧內存是怎麼分配出來?

JS 的棧內存是系統自動分配的,大小固定。若是自動適應的話,那就基本不存在除死循環這種狀況以外的棧溢出了。

三、這個棧內存的內部是一種什麼樣的樣子?

舉個例子,天天寫 return 語句,那你知道 return 的底層是如何實現的嗎?天天寫子程序,那你知道子程序底層的一些真相嗎?

咱們來看一張圖:

上圖顯示了一次函數調用的棧結構,從結構中能夠看到,內部的組成部分,好比實參,局部變量,返回地址。

看下面代碼:

function f1() {
  return 'hello godkun'    
}
let result = f1()
f2(result)

上面這行代碼的底層含義就是,f() 函數在私有棧內存中執行完後,使用 return 後,將執行結果傳遞給 EAX (累加寄存器),經常使用於函數返回值。

這裏說一下 Return Addr ,Addr 主要目的是讓子程序可以屢次被調用。

看下面代碼:

function main() {
  say()
  // TODO:
  say()
}

如上,在 main 函數中進行屢次調用子程序 say ,在底層實現上面,是經過在棧結構中保存一個 Addr 用來保存函數的起始運行地址,當第一個 say 函數運行完之後,Addr 就會指向起始運行地址,以備後面屢次調用子程序。

4、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 圖:

一、局部變量是如何被保存起來的

核心代碼:

EC(B) = {
  [scope]:AO(A),
  var AO(B) = {
    z:1,
    arguments:[],
    this:window
  },
  scopeChain:<AO(B),B[[scope]]>  
}
這是在執行 B 函數 時,建立的 B 函數的執行環境(一個對象結構)。裏面有一個 AO(B) ,這是 B 函數的活動對象。

那 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]] 爲何以這種形式命名?

二、經過 A 函數的 ECS 咱們能看到什麼

咱們能看到,JS 語言是靜態做用域語言,在執行函數以前,整個程序的做用域鏈就肯定了,從 A 圖中的函數 B 的 B[[scope]] 就能夠看到做用域鏈已經肯定。不像 lisp 那種在運行時才能肯定做用域。

三、執行環境,上下文環境是一種什麼樣的存在

執行環境的數據結構是棧結構,其實本質上是給一個數組增長一些屬性和方法。

執行環境能夠用 ECStack 表示,能夠理解成 ECSack = [] 這種形式。

棧(執行環境)專門用來存放各類數據,好比最經典的就是保存函數執行時的各類子數據結構。好比 A 函數的執行環境是 EC(A)。當執行函數 A 的時候,至關於 ECStack.push[A] ,當屬於 A 的東西被放入到棧中,都會被包裹成一個私有棧內存。

私有棧是怎麼造成的?從彙編語言角度去看,一個棧的內存分配,棧結構的各類變換,都是有底層標準去控制的。

四、開啓上帝模式看穿 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 的本質。

五、做用域的本質是鏈表中的一個節點

經過 A 圖 和 B 圖的比較,直接秒殺 做用域 的全部用法

看 A 圖,執行 A 函數時,B 函數的做用域是建立 A 函數的活動對象 AO(A) 。做用域就是一個屬性,一個屬於 A 函數的執行環境中的屬性,它的名字叫作 [scope] 。

[scope] 指向的是一個函數活動對象,核心點是把這個函數對象當成一個做用域,最好理解成一個鏈表節點。

PS: B 執行 B 函數時,只有 B 函數有 this 屬性,這也就交叉證明了 this 只有在運行時纔會存在。

六、做用域鏈的本質就是鏈表

經過比較 A 圖和 B 圖的 scopeChain ,能夠肯定的是:

做用域鏈本質就是鏈表,執行哪一個函數,鏈表就初始化爲哪一個函數的做用域,而後將該函數的 [scope] 放在表頭,造成閉環鏈表。做用域鏈是經過鏈表查找的,若是走了一圈還沒找到,那就返回 undefined 。

5、用一道面試題讓你更上一層樓(走火入魔)

再舉一個例子,這是一道常常被問的面試題,看下面代碼:

第一個程序以下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = function() {
      return i
    }
  }
  return result
}
 
let r = kun()
r.forEach(fn => {
  console.log('fn',fn())
})

第二個程序以下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = (function(n) {
      return function() {
        return n
      }
    })(i)
  }
  return result
}
 
let r = kun()
r.forEach(fn => {
  console.log('fn', fn())
})

輸出結果你們應該都知道了,結果分別是以下截圖:

第一個程序,輸出 10 個 10 :

第二個程序,輸出 0 到 9 :

那麼問題來了,其內部的原理機制是什麼呢?

  • 一部分 coder 只能答到當即調用,閉包。
  • 大多數 coder 能夠答到做用域相關知識。
  • 極少部分 coder (大佬級別) 能夠從核心底層緣由來分析。

下面從核心底層緣由來分析 。

一、分析輸出10個10

代碼以下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = function() {
      return i
    }
  }
  return result
}
 
let r = kun()
r.forEach(fn => {
  console.log('fn',fn())
})

 只有函數在執行的時候,函數的執行環境纔會生成。依據這個規則,在完成 r = kun() 的時候,kun 函數只執行了一次,生成了對應的 AO(kun) 。以下:

AO(kun):{
  i = 10;
  kun = function(){...};
  kun[[scope]] = this;
}

這時,在執行 kun() 以後,i 的值已是 10 了。請注意,kun 函數只執行了一次,也就意味着:

在 kun 函數的 AO(kun) 中的 i 屬性是 10 。

繼續分享, kun 函數的做用域鏈以下:

AO(kun) --> VO(G)

並且 kun 函數已經從棧頂被刪除了,只留下 AO(kun) 。

注意:這裏的 AO(kun) 表示一個節點。這個節點有指針和數據,其中指針指向了 VO(G) ,數據就是 kun 函數的活動對象。

那麼,當一次執行 result 中的數組的時候,會發生什麼現象?

注意:result 數組中的每個函數其做用域都已經肯定了,而 JS 是靜態做用域語言,其在程序聲明階段,全部的做用域都將肯定。

那麼 ,result 數組中每個函數其做用域鏈以下:

AO(result[i]) --> AO(kun) --> VO(G)

所以 result 中的每個函數執行時,其 i 的值都是沿着這條做用域鏈去查找的,並且因爲 kun 函數只執行了一次,致使了 i 值是最後的結果,也就是 10 。因此輸出結果就是 10 個 10 。

總結一下,就是 result 數組中的 10 個函數在聲明後,總共擁有了 10 個鏈表(做用域鏈),都是 AO(result[i]) --> AO(kun) --> VO(G)這種形式,可是 10 個做用域鏈中的 AO(kun) 都是同樣的。因此致使了,輸出結果是 10 個 10 。

下面咱們來分析輸出 0 到 9 的結果。

二、分析輸出0到9

代碼以下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = (function(n) {
      return function() {
        return n
      }
    })(i)
  }
  return result
}
 
let r = kun()
r.forEach(fn => {
  console.log('fn', fn())
})

首先,在聲明函數 kun 的時候,就已經執行了 10 次匿名函數。函數在執行時將生成執行環境,也就意味着,在 ECS 棧中,有 10 個 EC(kun) 執行環境,分別對應result 數組中的 10 個函數。

下面經過僞代碼來展現:

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 中的函數的 鏈表形式仍然是下面這種形式:

AO(result[i]) --> AO(kun) --> VO(G)

 但不同的是,對應節點的存儲地址不同,至關因而 10 個新的 AO(kun) 。而每個 AO(kun) 的節點內容中的 i 值是不同的。

因此總結就是:執行 result 數組中的 10 個函數時,運行10 個不一樣的鏈表,同時每一個鏈表的 AO(kun) 節點是不同的。每一個 AO(kun) 節點中的 i 值也是不同的。

因此輸出的結果最後顯示爲 0 到 9 。

6、總結

經過對底層實現原理的分析,咱們能夠更加深入的去理解函數的執行機制,從而寫出高質量的函數。

如何減小做用域鏈(鏈表)的查找

好比不少庫,像JQ 等,都會在當即執行函數的最外面傳一個 window 參數。這樣作的目的是由於,window 是全局對象,經過傳參,避免了查找整個做用域鏈,提升了函數的執行效率。

如何防止棧溢出?每一次執行函數,都會建立函數的執行環境,也就意味着佔用一些棧內存,而棧內存大小是固定的,若是寫了很大的遞歸函數,就會形成棧內存溢出,引起錯誤。

我以爲,咱們要去努力的達成這樣一個成就:

作到當我在手寫一個函數時,我心中很是清楚的知道我正在寫的每一行代碼,其在內存中是怎麼表現的,或者說其在底層是如何執行的,從而達到** 眼中有碼,心中無碼** 的境界。

若是能作到這樣的話,那還怕寫不出高質量的函數嗎?

7、參考文檔

  1. JS 函數的建立和執行機制

  2. 探索JS引擎工做原理

  3. 程序內存空間(代碼段、數據段、堆棧段)

  4. 函數調用–函數棧

  5. 堆棧向上增加和向下增加的深刻理解

更多內容敬請關注 vivo 互聯網技術 微信公衆號

注:轉載文章請先與微信號:labs2020 聯繫。

相關文章
相關標籤/搜索