如何编写高质量的 JS 函数(1) -- 敲山震虎篇

本文首发于 vivo互联网技术 微信公众号 
链接:https://mp.weixin.qq.com/s/7lCK9cHmunvYlbm7Xi7JxQ
作者:杨昆

一千个读者,有一千个哈姆雷特。

此系列文章将会从函数的执行机制、鲁棒性、函数式编程、设计模式等方面,全面阐述如何通过 JavaScript 编写高质量的函数。

一、引言

如何通过 JavaScript 编写高质量的函数,这是一个很难回答的问题,不同人心中对高质量有自己的看法,这里我将全面的阐述我个人对如何编写高质量函数的一些看法。看法可能不够全面,也可能会有一些错误的见解,欢迎一起讨论,就像过日子的人,小吵小闹总会不经意的出现,一颗包容的心莫过于是最好的 best practice 。

我打算用几篇文章来完成《如何编写高质量的 JS 函数》 这个系列。

主要从以下几个方面进行阐述:

  • 函数(一切皆有可能)

  • 函数的命名

  • 函数的注释

  • 函数的复杂度

  • 函数的鲁棒性(防御性编程)

  • 函数的入参和出参(返回)

  • 如何用函数式编程打通函数的任督二脉

  • 如何用设计模式让函数如虎添翼

  • 编写对 V8 友好的函数是一种什么 style

  • 前端工程师的函数狂想录

本篇只说第一节 函数 ,擒贼先擒王,下面我们来盘一盘函数的七七八八。

二、函数(一切皆有可能)

函数二字,代表着一切皆有可能。

我们想一下:我们用的函数究竟离我们有多远。就像打麻将一样,你觉得你能像雀神那样,想摸啥就来啥么(夸张修辞手法)。

天天和函数打交道,函数出现的目的是什么?再深入想,函数的执行机制是什么?下面我们就来简单的分析一下。

1、函数出现的目的

函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段。

PS: 注意,没有之一。

2、函数的执行机制

有句话说的好,知己知彼,百战不殆。想要胜利,一定要非常的了解敌人。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 。

下面我画了一个简单的示意图:

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

结合上图 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 变量提升。

第四步:在这个新开辟的作用域中自上而下执行。

思考题:为什么是自上而下执行呢?

将执行结果返回给当前调用的函数

思考题:将执行结果返回给当前调用的函数,其背后是如何实现的呢?

三、谈谈底层实现

1、计算机中最本质的闭包解释

函数在执行的时候,都会形成一个全新的私有作用域,也叫私有栈内存。

目的有如下两点:

  • 第一点:把原有堆内存中存储的字符串变成真正的 JS 代码。
  • 第二点:保护该栈内存的私有变量不受外界的干扰。

函数执行的这种保护机制,在计算机中称之为 闭包 。

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

没问题,我们可以反推。假设不是私有栈内存的,那么在执行一个递归时,基本就结束了,因为一个函数上下文堆栈中,有很多相同的 JS 代码,比如局部变量等,如果不私有化,那岂不乱套了?所以假设矛盾,私有栈内存成立。

2、栈内存是怎么分配出来?

JS 的栈内存是系统自动分配的,大小固定。如果自动适应的话,那就基本不存在除死循环这种情况之外的栈溢出了。

3、这个栈内存的内部是一种什么样的样子?

举个例子,每天写 return 语句,那你知道 return 的底层是如何实现的吗?每天写子程序,那你知道子程序底层的一些真相吗?

我们来看一张图:

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

上图显示了一次函数调用的栈结构,从结构中可以看到,内部的组成部分,比如实参,局部变量,返回地址。

看下面代码:

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 就会指向起始运行地址,以备后面多次调用子程序。

四、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 图:

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

执行 B 函数时

JS 引擎构造的 ESCstack 结构如下:

简称 B 图:

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

1、局部变量是如何被保存起来的

核心代码:

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]] 为什么以这种形式命名?

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 。

五、用一道面试题让你更上一层楼(走火入魔)

再举一个例子,这是一道经常被问的面试题,看下面代码:

第一个程序如下:

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 :

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

第二个程序,输出 0 到 9 :

如何编写高质量的 JS 函数(1) -- 敲山震虎篇

那么问题来了,其内部的原理机制是什么呢?

  • 一部分 coder 只能答到立即调用,闭包。

  • 大多数 coder 可以答到作用域相关知识。

  • 极少部分 coder (大佬级别) 可以从核心底层原因来分析。

下面从核心底层原因来分析 。

1、分析输出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 的结果。

2、分析输出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 。

六、总结

通过对底层实现原理的分析,我们可以更加深刻的去理解函数的执行机制,从而写出高质量的函数。

如何减少作用域链(链表)的查找

比如很多库,像JQ 等,都会在立即执行函数的最外面传一个 window 参数。这样做的目的是因为,window 是全局对象,通过传参,避免了查找整个作用域链,提高了函数的执行效率。

如何防止栈溢出?每一次执行函数,都会创建函数的执行环境,也就意味着占用一些栈内存,而栈内存大小是固定的,如果写了很大的递归函数,就会造成栈内存溢出,引发错误。

我觉得,我们要去努力的达成这样一个成就:

做到当我在手写一个函数时,我心中非常清楚的知道我正在写的每一行代码,其在内存中是怎么表现的,或者说其在底层是如何执行的,从而达到 眼中有码,心中无码 的境界。

如果能做到这样的话,那还怕写不出高质量的函数吗?

七、参考文档

  1. JS 函数的创建和执行机制
  2. 探索JS引擎工作原理
  3. 程序内存空间(代码段、数据段、堆栈段)
  4. 函数调用–函数栈
  5. 堆栈向上增长和向下增长的深入理解

上一篇:Spring源码分析(一)-导读


下一篇:MySQL数据库常见错误及解决方案