如何编写高质量的 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 联系。

相关文章
相关标签/搜索